← Back to blog

Struct vs OpenStruct: When to Use Which

· Lachlan Young

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:

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:

  1. Do you need immutability? Use Data.define (Ruby 3.2+).
  2. Do you know the shape upfront? Use Struct.
  3. Is this throwaway code in a script or IRB? OpenStruct is fine.
  4. 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.