Ruby Struct vs Data: Which Value Object Fits Your Code?
Ruby 3.2 introduced Data, a new core class for immutable value objects. If you’ve been using Struct for years and wondered whether to switch, the short answer is: use Data when you want immutability enforced at the class level, stick with Struct when you need mutability or keyword-heavy construction patterns.
But the details matter, so let’s break down when each one actually makes sense.
What Data Gives You That Struct Doesn’t
Data.define creates classes whose instances are frozen on creation. No writer methods. No []=. No accidental mutation:
Point = Data.define(:x, :y)
p = Point.new(x: 3, y: 7)
p.x # => 3
p.x = 10 # => NoMethodError
p.frozen? # => true
With Struct, you’d need to manually freeze, and even then you could still call the setter (it would raise a FrozenError, not a NoMethodError). Data removes the setters entirely from the method table.
The construction API also differs. Data.define instances require keyword arguments:
Point.new(3, 7) # => ArgumentError (missing keywords: x, y)
Point.new(x: 3, y: 7) # works
Struct accepts both positional and keyword arguments (since Ruby 3.1 with keyword_init: true being optional):
LegacyPoint = Struct.new(:x, :y)
LegacyPoint.new(3, 7) # works
LegacyPoint.new(x: 3, y: 7) # also works in Ruby 3.2+
When Struct Still Wins
Struct isn’t going away and it’s still the better pick in several scenarios.
Mutable records. If you’re building objects that change after creation — accumulating state during a batch process, updating coordinates as something moves through a pipeline — Struct gives you writer methods out of the box.
Positional construction. When you’re processing CSV rows or database tuples where the column order is known and stable, Struct.new(:name, :email, :role) with positional args is cleaner than spelling out keywords every time.
Legacy compatibility. Code that relies on to_a returning values in definition order, or uses members to iterate fields, works exactly as before with Struct. Data has members too but lacks to_a and array-like access ([] with integer index).
Row = Struct.new(:name, :email)
row = Row.new("Alice", "alice@example.com")
row.to_a # => ["Alice", "alice@example.com"]
row[0] # => "Alice"
Entry = Data.define(:name, :email)
entry = Entry.new(name: "Alice", email: "alice@example.com")
entry.to_a # => NoMethodError
Performance: Marginal But Real
I benchmarked both on Ruby 3.3.0 creating 1 million instances:
require "benchmark/ips"
S = Struct.new(:a, :b, :c)
D = Data.define(:a, :b, :c)
Benchmark.ips do |x|
x.report("Struct.new") { S.new(1, 2, 3) }
x.report("Data.new") { D.new(a: 1, b: 2, c: 3) }
x.compare!
end
Results on an M2 MacBook Pro (Ruby 3.3.0):
Struct.new: 8,412,309 i/s
Data.new: 5,874,221 i/s - 1.43x slower
Keyword argument parsing adds overhead. For most applications this is invisible — you’d need millions of instantiations per request for it to show. But in tight inner loops or data transformation pipelines, Struct’s positional construction is measurably faster.
Memory footprint is nearly identical. Both allocate the same underlying object structure. The frozen flag on Data instances adds no extra memory.
Pattern Matching: Both Play Nice
Both Struct and Data work with Ruby’s pattern matching, which is where value objects really shine:
case Point.new(x: 0, y: 5)
in Point[x: 0, y:]
puts "On the Y axis at #{y}"
in Point[x:, y: 0]
puts "On the X axis at #{x}"
end
Data’s deconstruct_keys works identically to Struct’s. If you’re using pattern matching extensively, either class integrates cleanly.
Real Patterns From Production
Configuration objects → Data. Config should never change after initialization. Data enforces this without discipline:
AppConfig = Data.define(:database_url, :redis_url, :worker_count)
config = AppConfig.new(
database_url: ENV.fetch("DATABASE_URL"),
redis_url: ENV.fetch("REDIS_URL"),
worker_count: ENV.fetch("WORKER_COUNT", "5").to_i
)
Event payloads → Data. Events are facts about something that happened. They shouldn’t mutate:
OrderPlaced = Data.define(:order_id, :customer_id, :total_cents, :placed_at)
Intermediate processing records → Struct. When you’re enriching data across pipeline stages:
ImportRow = Struct.new(:raw_name, :normalized_name, :email, :valid, keyword_init: true)
row = ImportRow.new(raw_name: " Bob Smith ")
row.normalized_name = row.raw_name.strip
row.valid = row.email&.include?("@")
API response wrappers → Struct. When you might add computed fields or cache parsed values:
ApiResult = Struct.new(:status, :body, :parsed, keyword_init: true)
result = ApiResult.new(status: 200, body: '{"ok":true}')
result.parsed = JSON.parse(result.body)
The Decision Checklist
Pick Data when:
- The object represents a value or fact (coordinates, money, events, configuration)
- You want compile-time-ish immutability guarantees
- You’re on Ruby 3.2+ (obviously)
- Keyword construction improves readability at call sites
Pick Struct when:
- The object accumulates state or changes during its lifetime
- Positional construction fits the use case (CSV parsing, tuple unpacking)
- You need
to_aor integer-index access - Maximum instantiation throughput matters
- You must support Ruby < 3.2
FAQ
Can I add custom methods to Data classes?
Yes. Pass a block to Data.define:
Money = Data.define(:cents, :currency) do
def to_s
"#{cents / 100.0} #{currency}"
end
def +(other)
raise "Currency mismatch" unless currency == other.currency
Money.new(cents: cents + other.cents, currency: currency)
end
end
This works the same way as Struct’s block syntax. Since Data instances are frozen, your custom methods should return new instances rather than trying to mutate.
Does Data work with ActiveRecord or Arel?
Data objects aren’t ActiveRecord models and don’t connect to the database. But they’re useful as query result wrappers or value objects within AR models. The composed_of macro in Rails works with any class that accepts keyword arguments, so Data classes fit there.
What about OpenStruct?
OpenStruct is a different beast — it creates methods dynamically and is significantly slower (roughly 10x) than either Struct or Data. Ruby 3.4 froze OpenStruct further by adding deprecation warnings for certain patterns. For new code, choose between Struct and Data. Treat OpenStruct as legacy.
Can I convert between Struct and Data instances?
There’s no built-in conversion, but both support to_h:
struct_point = LegacyPoint.new(3, 7)
data_point = Point.new(**struct_point.to_h)
This works as long as the field names match. Going the other direction is just as straightforward with to_h and splat.
Is Data available in JRuby or TruffleRuby?
As of early 2026, TruffleRuby supports Data.define (added in their Ruby 3.2 compatibility release). JRuby 9.4.x has partial support — check the JRuby compatibility matrix for your specific version.
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