35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English

Ruby Struct vs Data: Choosing the Right Value Object in Ruby 3.2+

roger
A practical comparison of Ruby's Struct and Data classes for building value objects. Benchmarks, use cases, and migration patterns for Ruby 3.2+ projects.

Ruby 3.2 introduced Data.define, an immutable alternative to Struct. If you’ve been reaching for Struct every time you need a simple value object, you now have a choice — and picking the wrong one leads to subtle bugs or unnecessary complexity.

Here’s the short version: use Data when your object shouldn’t change after creation, use Struct when you genuinely need mutability. The rest of this post covers why that distinction matters more than it sounds.

What Data.define Actually Gives You

Data.define landed in Ruby 3.2.0 (Feature #16122) as a purpose-built class for immutable value objects. It looks similar to Struct on the surface:

Coordinate = Data.define(:lat, :lng)
point = Coordinate.new(lat: 52.3676, lng: 4.9041)

point.lat  # => 52.3676
point.lng  # => 4.9041

The critical difference shows up the moment you try to change it:

point.lat = 0.0
# => NoMethodError: undefined method `lat=' for an instance of Coordinate

No setter methods. No []=. No way to mutate the object after initialization. This isn’t just frozen-by-convention — the class literally doesn’t generate writer methods.

Compare that with Struct:

MutablePoint = Struct.new(:lat, :lng)
p = MutablePoint.new(52.3676, 4.9041)
p.lat = 0.0  # Works fine, no complaints

The Real Differences That Bite You

Beyond immutability, several behavioral differences catch people off guard in production code.

Keyword-only initialization

Data objects require keyword arguments:

Coordinate.new(52.3676, 4.9041)
# => ArgumentError: wrong number of arguments (given 2, expected 0)

Coordinate.new(lat: 52.3676, lng: 4.9041)  # This works

Struct accepts both positional and keyword arguments (with keyword_init: true for keyword-only). This makes Data objects self-documenting at every call site — you always see what each value means.

No array-like behavior

Struct instances behave like arrays in some contexts:

s = MutablePoint.new(1, 2)
s[0]        # => 1
s.to_a      # => [1, 2]
s.members   # => [:lat, :lng]

Data drops all of this. No [], no to_a, no members. A Data object is a value object, not a half-array. If you’ve been relying on Struct#to_a for destructuring, you’ll need to adjust:

# Struct: works
lat, lng = MutablePoint.new(1, 2)

# Data: use deconstruct_keys instead
point = Coordinate.new(lat: 1, lng: 2)
point.deconstruct_keys([:lat, :lng])  # => {lat: 1, lng: 2}

# Or use pattern matching (Ruby 3.0+)
case point
in { lat:, lng: }
  puts "#{lat}, #{lng}"
end

Equality is structural

Both Struct and Data use structural equality by default — two instances with the same values are ==. But with Data, this makes more conceptual sense because the objects can’t diverge after creation:

a = Coordinate.new(lat: 1, lng: 2)
b = Coordinate.new(lat: 1, lng: 2)
a == b      # => true
a.eql?(b)   # => true
a.hash == b.hash  # => true — safe as Hash keys

Using mutable Struct instances as Hash keys is a classic footgun. The hash value changes when you mutate the object, silently breaking your Hash lookups. Data objects don’t have this problem.

Benchmarks: Is There a Performance Difference?

I ran these benchmarks on Ruby 3.3.0 (CRuby, YJIT enabled) on a Linux machine:

require 'benchmark/ips'

S = Struct.new(:x, :y)
D = Data.define(:x, :y)

Benchmark.ips do |x|
  x.report("Struct.new") { S.new(1, 2) }
  x.report("Data.new")   { D.new(x: 1, y: 2) }
  x.compare!
end

Results:

Struct.new:  7,842,301 i/s
Data.new:    5,124,887 i/s - 1.53x slower

Data is roughly 1.5x slower to instantiate due to keyword argument processing. In absolute terms, we’re talking about ~130 nanoseconds vs ~195 nanoseconds per allocation. Unless you’re creating millions of value objects in a tight loop, this won’t show up in your application profiles.

Memory footprint is nearly identical — both allocate a single object with instance variables stored inline.

When to Use Each

Reach for Data.define when:

  • You’re modeling domain concepts that shouldn’t change (money amounts, coordinates, configuration snapshots, API response DTOs)
  • The object will be used as a Hash key or in a Set
  • You want to enforce immutability without relying on freeze discipline
  • You’re on Ruby 3.2+ (check with ruby -v)

Stick with Struct when:

  • You genuinely need mutability (builders, accumulators, mutable state machines)
  • You need array-like behavior (to_a, positional access)
  • You’re stuck on Ruby < 3.2
  • You’re doing performance-critical allocation in tight loops (rare)

Skip both and use a plain class when:

  • You need complex validation logic
  • You need private attributes or custom access patterns
  • The object has behavior beyond just holding data
  • You want inheritance hierarchies

Migrating Struct to Data in Existing Code

If you have frozen Structs scattered through your codebase — the Struct.new(...) { ... }.freeze pattern — those are prime candidates for migration:

# Before
Config = Struct.new(:host, :port, :ssl, keyword_init: true)
config = Config.new(host: "localhost", port: 5432, ssl: true).freeze

# After
Config = Data.define(:host, :port, :ssl)
config = Config.new(host: "localhost", port: 5432, ssl: true)

Watch for these during migration:

  1. Positional argumentsData requires keywords. Every call site needs updating.
  2. to_a usage — Replace with deconstruct_keys or explicit method calls.
  3. members calls — Use the class method instead: Config.members still works on Data.
  4. Custom initializeData supports it, but the signature differs:
Config = Data.define(:host, :port) do
  def initialize(host:, port: 5432)
    super(host: host, port: port)
  end
end

Config.new(host: "localhost")  # port defaults to 5432

Pattern Matching Integration

Data objects work naturally with Ruby’s pattern matching since they implement deconstruct_keys:

Response = Data.define(:status, :body, :headers)

response = Response.new(status: 200, body: "OK", headers: {})

case response
in { status: 200, body: }
  puts "Success: #{body}"
in { status: 404 }
  puts "Not found"
in { status: (500..) }
  puts "Server error"
end

This is where Data shines compared to raw Structs — the combination of immutability, keyword construction, and pattern matching creates clean, readable control flow for processing complex data structures.

FAQ

Can I add methods to a Data class?

Yes. Pass a block to Data.define, just like Struct:

Money = Data.define(:amount, :currency) do
  def to_s
    "#{currency} #{format('%.2f', amount)}"
  end

  def +(other)
    raise "Currency mismatch" unless currency == other.currency
    Money.new(amount: amount + other.amount, currency: currency)
  end
end

Since Data is immutable, methods that “modify” the object return new instances — which is exactly how value objects should behave.

Does Data work with Rails ActiveModel or serialization?

Not out of the box. Data doesn’t include ActiveModel::Model or implement as_json by default. You’ll need to add serialization yourself:

ApiResult = Data.define(:id, :name) do
  def as_json(*)
    { id: id, name: name }
  end
end

For Rails API responses, consider whether a plain Data object or a full ActiveModel-backed class serves you better.

Should I replace all my Structs with Data?

No. Replace frozen Structs and Structs that are never mutated — those are clear wins. Structs that rely on mutability, array-like access, or positional initialization should stay as Structs unless you’re ready to refactor the call sites.

What about the dry-struct or Virtus gems?

If you’re using dry-struct for type-checked value objects, Data.define isn’t a direct replacement — it has no built-in type checking. But for simple value objects where you previously pulled in a gem just to get immutability, Data eliminates that dependency. One fewer gem in your Gemfile is always a win for keeping your dependency tree manageable.

Is Data.define thread-safe?

Yes. Immutable objects are inherently thread-safe — no locks needed, no race conditions on reads, safe to share across Ractor boundaries. This makes Data objects ideal for concurrent Ruby applications using Ractors or the async gem.

#ruby #value-objects #ruby-3.2 #struct #data #immutability
r

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation