Struct vs OpenStruct vs Data — Choosing Lightweight Value Objects in Ruby
Ruby gives you three tools for creating lightweight objects without the boilerplate of a full class definition: Struct, OpenStruct, and the newer Data (Ruby 3.2+). Each serves a different purpose, and the wrong choice shows up in production as either unexplained mutation bugs, suspicious test failures, or performance degradation. Understanding what each one actually does saves you from writing a Hash when you need an object, or a full class when you need a value type.
The Problem They Solve
Plain hashes work fine for passing related data around until they don’t — until you’re accessing user[:first_name] instead of user.first_name, until you’re missing a key because someone spelled it firstname, until you want to define methods on your data.
The alternative — writing a full class with attr_reader, initialize, and == — is repetitive boilerplate for something that’s conceptually simple. Struct, OpenStruct, and Data fill that gap.
Example:
# Hash — works, but fragile and verbose
user = { first_name: "Ada", last_name: "Lovelace", role: :admin }
user[:first_name] # => "Ada"
user[:fiirst_name] # => nil (typo, no error)
# Struct — method access, caught typos
User = Struct.new(:first_name, :last_name, :role)
ada = User.new("Ada", "Lovelace", :admin)
ada.first_name # => "Ada"
ada.fiirst_name # => NoMethodError (caught at call site)
Struct — The Workhorse
Struct creates a class with accessor methods, == based on values, and iteration support. It’s been in Ruby for a long time and is the right default when you need a lightweight mutable object.
Example:
# Basic struct definition
Point = Struct.new(:x, :y)
p = Point.new(3, 4)
p.x # => 3
p.y = 10 # Mutable by default
p.y # => 10
p == Point.new(3, 10) # => true (value equality)
# With keyword arguments (Ruby 3.2+)
Config = Struct.new(:host, :port, :ssl, keyword_init: true)
cfg = Config.new(host: "localhost", port: 5432, ssl: false)
cfg.host # => "localhost"
Example:
# Structs can have methods
Measurement = Struct.new(:value, :unit) do
def to_metric
return self if unit == :meters
Measurement.new(value * 0.3048, :meters)
end
def to_s
"#{value} #{unit}"
end
end
height = Measurement.new(6, :feet)
height.to_metric # => #<struct Measurement value=1.8288, unit=:meters>
Example:
# Struct is enumerable over its members
Point = Struct.new(:x, :y)
p = Point.new(3, 4)
p.to_a # => [3, 4]
p.members # => [:x, :y]
p.each_pair { |name, val| puts "#{name}: #{val}" }
# x: 3
# y: 4
| Feature | Struct |
|---|---|
| Mutability | Mutable by default |
| Equality | Value-based (==) |
| Method definition | Supported via block |
| Keyword args | Supported (keyword_init: true) |
| Performance | Fast |
| Ruby version | All versions |
OpenStruct — The Flexible One (Use With Caution)
OpenStruct lets you define attributes on the fly without declaring them upfront. It sounds convenient; the reality is that it’s slow and has surprising behavior that makes it a poor choice for most production code.
Example:
require 'ostruct'
user = OpenStruct.new(name: "Ada", role: :admin)
user.name # => "Ada"
user.email = "[email protected]" # Added at runtime
user.email # => "[email protected]"
user.anything # => nil (no error — this is the problem)
The nil for undefined attributes feels permissive, but it hides errors. If you mistype user.emal, you get nil instead of a NoMethodError. That nil travels through your code and causes an error somewhere else, far from the typo.
Example:
# Performance comparison — OpenStruct is significantly slower
require 'benchmark'
require 'ostruct'
Point = Struct.new(:x, :y)
Benchmark.bm do |b|
b.report("Struct: ") { 1_000_000.times { Point.new(1, 2) } }
b.report("OpenStruct: ") { 1_000_000.times { OpenStruct.new(x: 1, y: 2) } }
end
# Struct: ~0.08s
# OpenStruct: ~1.8s (roughly 20x slower)
OpenStruct is useful in tests for quick stubs and in exploratory scripts where flexibility matters more than performance or safety. In production application code — models, service objects, value objects — prefer Struct or Data.
Data — Immutable Value Objects (Ruby 3.2+)
Data was introduced in Ruby 3.2 specifically for immutable value objects. Where Struct is mutable, Data instances are frozen by default. No setters, no structural changes after creation.
Example:
# Data definition
Point = Data.define(:x, :y)
Money = Data.define(:amount, :currency)
Address = Data.define(:street, :city, :country)
point = Point.new(x: 3, y: 4)
point.frozen? # => true
point.x # => 3
point.x = 5 # => FrozenError (no setter defined)
# Value equality
Point.new(x: 3, y: 4) == Point.new(x: 3, y: 4) # => true
Example:
# Creating modified copies with `with`
price = Money.new(amount: 100.0, currency: "USD")
discounted = price.with(amount: price.amount * 0.9)
# => Money(amount: 90.0, currency: "USD")
price.amount # => 100.0 (original unchanged)
# Pattern matching (Data supports deconstruct_keys)
case point
in Point[x: 0, y:]
"On Y-axis at #{y}"
in Point[x:, y: 0]
"On X-axis at #{x}"
in Point[x:, y:]
"At (#{x}, #{y})"
end
# => "At (3, 4)"
Example:
# Data with validation
Money = Data.define(:amount, :currency) do
def initialize(amount:, currency:)
raise ArgumentError, "amount must be positive" unless amount >= 0
raise ArgumentError, "unknown currency" unless %w[USD EUR GBP].include?(currency)
super
end
end
Money.new(amount: 100, currency: "USD") # Fine
Money.new(amount: -1, currency: "USD") # => ArgumentError
| Feature | Data |
|---|---|
| Mutability | Immutable (always frozen) |
| Equality | Value-based (==) |
| Method definition | Supported via block |
| Keyword args | Always keyword args |
| Pattern matching | Supported |
| Ruby version | 3.2+ |
Side-by-Side Comparison
| Struct | OpenStruct | Data | |
|---|---|---|---|
| Mutable | Yes | Yes | No |
| Dynamic attributes | No | Yes | No |
| Value equality | Yes | Yes | Yes |
| Frozen by default | No | No | Yes |
| Performance | Fast | Slow | Fast |
| Pattern matching | Limited | No | Full support |
| Ruby version | All | All | 3.2+ |
| Best for | Mutable value groups | Test stubs, scripts | Immutable value objects |
When to Use Each
Use Struct when:
- You need a simple container with method access instead of hash keys
- The object needs to be mutated after creation
- You want to add methods to the value object
- You’re targeting Ruby below 3.2
Use Data when:
- The object represents a value that shouldn’t change (money, coordinates, measurements, API responses)
- You’re on Ruby 3.2+ and want the safety of immutability by default
- You’re sharing objects across threads (frozen objects are safe to share across Ractors)
- You want pattern matching support
Use OpenStruct when:
- Writing test doubles or stubs where flexibility matters more than safety
- Prototyping in a REPL or script
- Never in production application code where correctness and performance matter
Pro-Tip: If you’re defining a value object that represents a domain concept — money, a geographic coordinate, a measurement — reach for
Datafirst on Ruby 3.2+. The immutability guarantee isn’t just a safety feature; it’s documentation. When someone readsMoney = Data.define(:amount, :currency), they immediately know this object doesn’t change. That’s information that aStructdoesn’t convey. SaveStructfor objects that genuinely need to be mutable after creation.
Conclusion
Struct, OpenStruct, and Data each solve a different problem. Struct is the reliable default for lightweight mutable objects with method access. Data is the right choice for immutable value types on Ruby 3.2+. OpenStruct has a narrow legitimate use case in tests and scripts, and should be avoided in production code where its dynamic nature hides errors and its performance is a liability. Knowing which to reach for — and why — is one of those small judgments that makes Ruby code cleaner and easier to reason about over time.
FAQs
Q1: Is Data always frozen, or can I unfreeze it?
Data instances are always frozen — freeze is called during initialization and cannot be undone. Use with to create modified copies: point.with(x: 5) returns a new frozen Data instance with the updated value.
Q2: Can I inherit from a Struct?
You can, but it’s unusual and often a sign that a regular class would be clearer. If you find yourself inheriting from a Struct to add significant behavior, consider writing a proper class instead.
Q3: Does Struct support keyword initialization in older Ruby?
The keyword_init: true option was added in Ruby 2.5. For older versions, Struct arguments are positional only. Ruby 3.2 also added the ability to mix keyword and positional arguments in Struct.
Q4: Why is OpenStruct so much slower than Struct?
Struct generates a class with predefined methods at definition time. OpenStruct uses method_missing and dynamically defines new methods at runtime when you first set an attribute. This dynamic method creation is expensive, especially in loops.
Q5: Can Data objects be used as hash keys?
Yes. Data instances implement hash and eql? based on their values, making them safe to use as hash keys. Two Data instances with the same values hash to the same value: {Point.new(x:1, y:2) => "origin"}[Point.new(x:1, y:2)] works correctly.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀