Struct vs OpenStruct: When to Use Which
Ruby gives you two built-in ways to create lightweight data objects: Struct and OpenStruct. They look similar on the surface, but they behave very differently under the hood. Picking the wrong one in the wrong context can cost you real performance, or real flexibility.
Here’s the short version: use Struct in production code, almost always. But let’s look at why.
Struct: the structured one
Struct creates a new class with named attributes, equality semantics, and a handful of useful methods baked in.
Point = Struct.new(:x, :y)
a = Point.new(1, 2)
b = Point.new(1, 2)
a.x # => 1
a == b # => true
a.to_a # => [1, 2]
a.members # => [:x, :y]
You define the shape upfront and you’re locked into it. Trying to access an attribute that doesn’t exist raises a NoMethodError. That’s a feature, not a limitation. It means typos get caught immediately.
Since Ruby 2.5, you can use keyword arguments for clarity:
Point = Struct.new(:x, :y, keyword_init: true)
Point.new(x: 1, y: 2) # much clearer at the call site
And since Ruby 3.2, you can make structs immutable with the freeze option, which is extremely helpful for value objects:
Coordinate = Data.define(:lat, :lng)
# Data.define is the immutable cousin of Struct, added in Ruby 3.2
OpenStruct: the flexible one
OpenStruct lets you set arbitrary attributes on the fly. No predefined shape, no constraints.
require "ostruct"
config = OpenStruct.new(host: "localhost", port: 3000)
config.host # => "localhost"
config.database = "myapp_dev" # just works, no error
config.database # => "myapp_dev"
It feels magical. You can throw any key at it and it’ll happily create a method for it. That flexibility is intoxicating when you’re prototyping.
The performance gap
Here’s the thing nobody tells you until it bites: Struct is roughly 10x faster than OpenStruct for attribute access. The reason is simple. Struct generates actual methods at class creation time. OpenStruct uses method_missing and a backing hash, resolving every attribute lookup dynamically.
require "benchmark/ips"
require "ostruct"
StructPoint = Struct.new(:x, :y)
Benchmark.ips do |b|
b.report("Struct") do
p = StructPoint.new(1, 2)
p.x
p.y
end
b.report("OpenStruct") do
p = OpenStruct.new(x: 1, y: 2)
p.x
p.y
end
b.compare!
end
On a typical machine, you’ll see something like:
Struct: 5.2M i/s
OpenStruct: 500K i/s
Struct is 10.4x faster
For a single object in a script, that doesn’t matter. For objects created in a loop, inside a request cycle, or in a hot path, it matters a lot.
Memory allocation tells a similar story. OpenStruct allocates a hash internally to store its attributes. Struct uses a fixed-size internal array. When you’re creating thousands of these objects, the difference compounds.
When OpenStruct is fine
OpenStruct isn’t evil. It has its place:
- IRB sessions and one-off scripts. When you’re exploring an API response and want to dot-access nested data, OpenStruct is convenient.
- Test doubles. Sometimes you need a quick stand-in object that responds to a couple of methods. An
OpenStructdoes that in one line. - Prototyping. When the shape of your data is genuinely unknown and you’re still figuring things out.
The key word is “temporary.” If the code is going to live beyond this afternoon, reach for Struct instead.
What Struct gives you for free
Beyond raw speed, Struct comes with behavior you’d otherwise have to implement yourself:
Equality. Two structs with the same values are equal. No need to override ==.
Money = Struct.new(:amount, :currency)
a = Money.new(100, "USD")
b = Money.new(100, "USD")
a == b # => true
Pattern matching. Since Ruby 3.0, structs work beautifully with case/in:
case Money.new(0, "USD")
in Money[amount: 0, currency:]
puts "Empty wallet in #{currency}"
end
Destructuring. to_a and deconstruct let you pull structs apart:
x, y = Point.new(3, 4).to_a
Custom methods. You can add methods to a Struct by passing a block:
Money = Struct.new(:amount, :currency) do
def to_s
"#{currency} #{format('%.2f', amount)}"
end
def +(other)
raise "Currency mismatch" unless currency == other.currency
self.class.new(amount + other.amount, currency)
end
end
This gives you a proper value object with almost no boilerplate.
The Data.define alternative
If you’re on Ruby 3.2 or later, Data.define is worth knowing about. It’s essentially an immutable Struct:
Point = Data.define(:x, :y)
p = Point.new(x: 1, y: 2)
p.x # => 1
p.frozen? # => true
p.x = 3 # => FrozenError
No setters, no mutation. It’s the right choice for value objects where immutability is the whole point. Coordinates, money amounts, version numbers, anything where changing a field should produce a new object rather than modifying the existing one.
The decision tree
When you need a lightweight data object in Ruby:
- Do you need immutability? Use
Data.define(Ruby 3.2+). - Do you know the shape upfront? Use
Struct. - Is this throwaway code in a script or IRB?
OpenStructis fine. - Is this production code? Use
Struct. Seriously.
The flexibility of OpenStruct feels like freedom, but it’s the kind of freedom that leads to typo-driven bugs, performance surprises, and objects whose shape nobody can predict by reading the code. Struct is explicit, fast, and readable. In production, those three things win every time.
Working with Struct output in your tests? Try RubyHash to paste and compare hash diffs instantly, no setup required.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.