Ruby Struct vs Data: Choosing the Right Value Object in Ruby 3.2+
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
freezediscipline - 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:
- Positional arguments —
Datarequires keywords. Every call site needs updating. to_ausage — Replace withdeconstruct_keysor explicit method calls.memberscalls — Use the class method instead:Config.membersstill works onData.- Custom
initialize—Datasupports 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.
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 TouchRelated Articles
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