/ Tags: RUBY 3 / Categories: RUBY

Struct vs OpenStruct vs Data — Choosing Lightweight Value Objects in Ruby

Ruby gives you three tools for creating lightweight objects without the boilerplate of a full class definition: Struct, OpenStruct, and the newer Data (Ruby 3.2+). Each serves a different purpose, and the wrong choice shows up in production as either unexplained mutation bugs, suspicious test failures, or performance degradation. Understanding what each one actually does saves you from writing a Hash when you need an object, or a full class when you need a value type.

The Problem They Solve


Plain hashes work fine for passing related data around until they don’t — until you’re accessing user[:first_name] instead of user.first_name, until you’re missing a key because someone spelled it firstname, until you want to define methods on your data.

The alternative — writing a full class with attr_reader, initialize, and == — is repetitive boilerplate for something that’s conceptually simple. Struct, OpenStruct, and Data fill that gap.

Example:

# Hash — works, but fragile and verbose
user = { first_name: "Ada", last_name: "Lovelace", role: :admin }
user[:first_name]          # => "Ada"
user[:fiirst_name]         # => nil  (typo, no error)

# Struct — method access, caught typos
User = Struct.new(:first_name, :last_name, :role)
ada  = User.new("Ada", "Lovelace", :admin)
ada.first_name             # => "Ada"
ada.fiirst_name            # => NoMethodError  (caught at call site)

Struct — The Workhorse


Struct creates a class with accessor methods, == based on values, and iteration support. It’s been in Ruby for a long time and is the right default when you need a lightweight mutable object.

Example:

# Basic struct definition
Point = Struct.new(:x, :y)
p = Point.new(3, 4)
p.x        # => 3
p.y = 10   # Mutable by default
p.y        # => 10
p == Point.new(3, 10)  # => true (value equality)

# With keyword arguments (Ruby 3.2+)
Config = Struct.new(:host, :port, :ssl, keyword_init: true)
cfg = Config.new(host: "localhost", port: 5432, ssl: false)
cfg.host   # => "localhost"

Example:

# Structs can have methods
Measurement = Struct.new(:value, :unit) do
  def to_metric
    return self if unit == :meters
    Measurement.new(value * 0.3048, :meters)
  end

  def to_s
    "#{value} #{unit}"
  end
end

height = Measurement.new(6, :feet)
height.to_metric  # => #<struct Measurement value=1.8288, unit=:meters>

Example:

# Struct is enumerable over its members
Point = Struct.new(:x, :y)
p = Point.new(3, 4)
p.to_a    # => [3, 4]
p.members # => [:x, :y]
p.each_pair { |name, val| puts "#{name}: #{val}" }
# x: 3
# y: 4
Feature Struct
Mutability Mutable by default
Equality Value-based (==)
Method definition Supported via block
Keyword args Supported (keyword_init: true)
Performance Fast
Ruby version All versions

OpenStruct — The Flexible One (Use With Caution)


OpenStruct lets you define attributes on the fly without declaring them upfront. It sounds convenient; the reality is that it’s slow and has surprising behavior that makes it a poor choice for most production code.

Example:

require 'ostruct'

user = OpenStruct.new(name: "Ada", role: :admin)
user.name   # => "Ada"
user.email  = "[email protected]"  # Added at runtime
user.email  # => "[email protected]"
user.anything  # => nil (no error — this is the problem)

The nil for undefined attributes feels permissive, but it hides errors. If you mistype user.emal, you get nil instead of a NoMethodError. That nil travels through your code and causes an error somewhere else, far from the typo.

Example:

# Performance comparison — OpenStruct is significantly slower
require 'benchmark'
require 'ostruct'

Point = Struct.new(:x, :y)

Benchmark.bm do |b|
  b.report("Struct:     ") { 1_000_000.times { Point.new(1, 2) } }
  b.report("OpenStruct: ") { 1_000_000.times { OpenStruct.new(x: 1, y: 2) } }
end
# Struct:       ~0.08s
# OpenStruct:   ~1.8s  (roughly 20x slower)

OpenStruct is useful in tests for quick stubs and in exploratory scripts where flexibility matters more than performance or safety. In production application code — models, service objects, value objects — prefer Struct or Data.

Data — Immutable Value Objects (Ruby 3.2+)


Data was introduced in Ruby 3.2 specifically for immutable value objects. Where Struct is mutable, Data instances are frozen by default. No setters, no structural changes after creation.

Example:

# Data definition
Point   = Data.define(:x, :y)
Money   = Data.define(:amount, :currency)
Address = Data.define(:street, :city, :country)

point = Point.new(x: 3, y: 4)
point.frozen?   # => true
point.x         # => 3
point.x = 5     # => FrozenError (no setter defined)

# Value equality
Point.new(x: 3, y: 4) == Point.new(x: 3, y: 4)  # => true

Example:

# Creating modified copies with `with`
price = Money.new(amount: 100.0, currency: "USD")
discounted = price.with(amount: price.amount * 0.9)
# => Money(amount: 90.0, currency: "USD")

price.amount  # => 100.0  (original unchanged)

# Pattern matching (Data supports deconstruct_keys)
case point
in Point[x: 0, y:]
  "On Y-axis at #{y}"
in Point[x:, y: 0]
  "On X-axis at #{x}"
in Point[x:, y:]
  "At (#{x}, #{y})"
end
# => "At (3, 4)"

Example:

# Data with validation
Money = Data.define(:amount, :currency) do
  def initialize(amount:, currency:)
    raise ArgumentError, "amount must be positive" unless amount >= 0
    raise ArgumentError, "unknown currency" unless %w[USD EUR GBP].include?(currency)
    super
  end
end

Money.new(amount: 100, currency: "USD")   # Fine
Money.new(amount: -1, currency: "USD")    # => ArgumentError
Feature Data
Mutability Immutable (always frozen)
Equality Value-based (==)
Method definition Supported via block
Keyword args Always keyword args
Pattern matching Supported
Ruby version 3.2+

Side-by-Side Comparison


  Struct OpenStruct Data
Mutable Yes Yes No
Dynamic attributes No Yes No
Value equality Yes Yes Yes
Frozen by default No No Yes
Performance Fast Slow Fast
Pattern matching Limited No Full support
Ruby version All All 3.2+
Best for Mutable value groups Test stubs, scripts Immutable value objects

When to Use Each


Use Struct when:
  • You need a simple container with method access instead of hash keys
  • The object needs to be mutated after creation
  • You want to add methods to the value object
  • You’re targeting Ruby below 3.2
Use Data when:
  • The object represents a value that shouldn’t change (money, coordinates, measurements, API responses)
  • You’re on Ruby 3.2+ and want the safety of immutability by default
  • You’re sharing objects across threads (frozen objects are safe to share across Ractors)
  • You want pattern matching support
Use OpenStruct when:
  • Writing test doubles or stubs where flexibility matters more than safety
  • Prototyping in a REPL or script
  • Never in production application code where correctness and performance matter

Pro-Tip: If you’re defining a value object that represents a domain concept — money, a geographic coordinate, a measurement — reach for Data first on Ruby 3.2+. The immutability guarantee isn’t just a safety feature; it’s documentation. When someone reads Money = Data.define(:amount, :currency), they immediately know this object doesn’t change. That’s information that a Struct doesn’t convey. Save Struct for objects that genuinely need to be mutable after creation.

Conclusion


Struct, OpenStruct, and Data each solve a different problem. Struct is the reliable default for lightweight mutable objects with method access. Data is the right choice for immutable value types on Ruby 3.2+. OpenStruct has a narrow legitimate use case in tests and scripts, and should be avoided in production code where its dynamic nature hides errors and its performance is a liability. Knowing which to reach for — and why — is one of those small judgments that makes Ruby code cleaner and easier to reason about over time.

FAQs


Q1: Is Data always frozen, or can I unfreeze it?
Data instances are always frozen — freeze is called during initialization and cannot be undone. Use with to create modified copies: point.with(x: 5) returns a new frozen Data instance with the updated value.

Q2: Can I inherit from a Struct?
You can, but it’s unusual and often a sign that a regular class would be clearer. If you find yourself inheriting from a Struct to add significant behavior, consider writing a proper class instead.

Q3: Does Struct support keyword initialization in older Ruby?
The keyword_init: true option was added in Ruby 2.5. For older versions, Struct arguments are positional only. Ruby 3.2 also added the ability to mix keyword and positional arguments in Struct.

Q4: Why is OpenStruct so much slower than Struct?
Struct generates a class with predefined methods at definition time. OpenStruct uses method_missing and dynamically defines new methods at runtime when you first set an attribute. This dynamic method creation is expensive, especially in loops.

Q5: Can Data objects be used as hash keys?
Yes. Data instances implement hash and eql? based on their values, making them safe to use as hash keys. Two Data instances with the same values hash to the same value: {Point.new(x:1, y:2) => "origin"}[Point.new(x:1, y:2)] works correctly.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More