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
Since Ruby 3.2, Struct.new also accepts both positional and keyword arguments without you having to declare keyword_init at all. It figures out which style you used at call time:
Point = Struct.new(:x, :y) # no keyword_init needed on 3.2+
Point.new(1, 2) # positional works
Point.new(x: 1, y: 2) # keyword works too
If you need genuine immutability, Ruby 3.2 also added Data.define, the immutable cousin of Struct. We’ll come back to it below, because it deserves its own section rather than a footnote.
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.
Gotchas that bite in production
The performance gap is the headline reason to prefer Struct, but there are sharper edges that only show up once real code is running.
OpenStruct will silently clobber real methods. Because every key becomes a method, an attribute named after something Object already responds to will overwrite it. This one is genuinely dangerous:
require "ostruct"
o = OpenStruct.new(method: "boom")
o.method # => "boom", the attribute
o.method(:itself) # ArgumentError: wrong number of arguments
You just broke Object#method on that instance. The same trap applies to keys like display, class, send, or then. When an OpenStruct is built from external data (a parsed JSON payload, a webhook body, a YAML config), you don’t control the keys, so you can’t rule this out. That is exactly the situation where people reach for OpenStruct, and exactly where it hurts.
OpenStruct was hardened, and it got slower. After a security advisory, OpenStruct was rewritten to be safer, and its own documentation now carries a caveats section warning that it is not suited to performance-sensitive code. So the 10x figure is not a historical quirk that a newer Ruby quietly fixed. The maintainers are telling you the same thing: keep it out of hot paths.
Don’t subclass Struct.new. You’ll see this pattern in older code:
class Point < Struct.new(:x, :y)
end
It works, but it inserts an anonymous class into your ancestry:
Point.ancestors[0, 3]
# => [Point, #<Class:0x...>, Struct]
That extra layer shows up in backtraces and ancestors, and it means you’ve built the class twice. The idiomatic form assigns the result of Struct.new and passes a block for methods:
Point = Struct.new(:x, :y) do
def distance = Math.hypot(x, y)
end
Struct equality is class-sensitive. Two structs are equal only if they share the same class and the same values. Same shape is not enough:
A = Struct.new(:x)
B = Struct.new(:x)
A.new(1) == B.new(1) # => false
This is usually what you want, but it surprises people who expect structural equality. If you’re comparing data from two different struct definitions, convert with to_h first.
Serialization does not do what you’d hope. Calling to_json on a Struct does not give you a tidy JSON object. Depending on your setup you’ll get the inspect string or an array of values, not {"x":1,"y":2}. Reach for to_h explicitly:
Point.new(1, 2).to_h.to_json # => {"x":1,"y":2}
This bites when a struct sneaks into an API response and the client receives something unexpected.
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 # => NoMethodError, there is no setter
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 piece that makes immutability practical rather than annoying is with. Instead of mutating a field, you ask for a copy with that field changed:
origin = Point.new(x: 0, y: 0)
shifted = origin.with(x: 5)
origin.x # => 0, untouched
shifted.x # => 5
That is the pattern you actually want for value objects: the original is never modified, so nothing downstream can be surprised by a mutation it didn’t ask for. Data.define also supports the same pattern matching and keyword construction as Struct, so you lose none of the ergonomics by picking the safer default.
A quick real-world note on why this matters. A common way to get burned is stuffing a mutable config into an OpenStruct, passing it around, and having some far-off caller set a stray attribute that quietly changes behavior three layers up. Because OpenStruct accepts any assignment without complaint, that bug never raises, it just produces the wrong result. A frozen Data object turns the same mistake into an immediate, obvious error at the point where the bad write happens.
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.