Struct and Data Classes

Published by Programming Line in Ruby. Last updated .

A Struct is Ruby’s built-in shortcut for building a class whose entire job is to bundle a handful of named values together — a coordinate pair, a database row, a config entry — without writing attr_accessor, initialize, and == by hand every time. Since Ruby 3.2 you also have Data, a newer sibling built specifically for immutable value objects. Together they cover most cases where you’d otherwise reach for a full custom class just to hold some data, and knowing when to use which is a core Ruby object-oriented design skill.

Overview: How Struct and Data Work

Under the hood, Struct.new(:x, :y) doesn’t return a template or a configuration object — it returns an actual anonymous Class, generated on the spot, that inherits from Struct (which itself includes Enumerable). That new class already has x, x=, y, and y= accessor methods defined for it, along with a generated initialize, ==, eql?, hash, to_a, to_h, each, [], and members — all wired up to the attribute names you passed in. When you write Point = Struct.new(:x, :y), Ruby notices the class is being assigned to a constant and uses that to name the class, which is why Point.new(1, 2).inspect prints #<struct Point x=1, y=2> instead of something anonymous.

Because a Struct-generated class is a real class, you can pass a block to Struct.new and add your own methods to it, exactly as you would inside a normal class...end body. Those methods can freely call the generated accessors (x, y) since they live as plain instance methods on the same class.

By default, Struct.new accepts values positionally: Point.new(1, 2) sets x to 1 and y to 2 in declaration order. Pass keyword_init: true and the generated initialize instead requires keyword arguments (Point.new(x: 1, y: 2)), which is far more readable once a struct has more than two or three fields — a call like Employee.new("Alice", "Engineering", 90000) gives no clue which value is which, while the keyword version reads like documentation.

Data, added in Ruby 3.2, follows the same generation pattern — Data.define(:x, :y) also returns a real anonymous class — but with a different goal: immutability. A Data-generated class only defines readers (x, y), never writers, and its constructor accepts either positional or keyword arguments without needing an option to pick one. Every Data instance is frozen the moment it’s created, so its state can never change afterward. Instead of a y= setter, you get #with, which returns a brand-new instance with the fields you specify replaced, leaving the original completely untouched. Unlike Struct, a Data-generated class does not include Enumerable and has no each, to_a, or [] — it’s deliberately minimal, focused purely on being a safe, shareable value.

Both Struct and Data instances implement deconstruct and deconstruct_keys, the hook methods Ruby’s pattern matching (case/in) uses to destructure a value. That means any Struct or Data object works directly as the target of a case/in match with no extra code — one of the more pleasant surprises of using them in modern Ruby.

Syntax

ClassName = Struct.new(:attr1, :attr2, ...) do
  # optional custom methods
end

ImmutableClassName = Data.define(:attr1, :attr2, ...) do
  # optional custom methods
end
Form Meaning
Struct.new(:a, :b) Anonymous, mutable class with a positional constructor and a/a= style accessors.
Struct.new(:a, :b, keyword_init: true) Same, but the constructor only accepts keyword arguments.
Struct.new(:a, :b) do ... end The block body lets you add custom instance methods to the generated class.
Data.define(:a, :b) Anonymous, immutable class; constructor accepts positional or keyword arguments.
instance.with(a: new_value) Data only — returns a new instance with the given fields replaced.
instance.to_h Hash of attribute name to value, available on both Struct and Data.

Examples

Example 1: A basic mutable Struct

Point = Struct.new(:x, :y) do
  def distance_from_origin
    Math.sqrt(x**2 + y**2)
  end
end

p1 = Point.new(3, 4)
puts p1.x
puts p1.y
puts p1.distance_from_origin
puts p1.to_a.inspect

p1.y = 10
puts p1.y
puts p1 == Point.new(3, 10)

Output:

3
4
5.0
[3, 4]
10
true

Point.new(3, 4) calls the generated constructor, which assigns 3 to x and 4 to y in the order they were declared. distance_from_origin is our custom method, defined in the block, calling the auto-generated readers just like any instance method would. to_a gives the values as a plain array. Then p1.y = 10 uses the auto-generated writer to mutate the struct in place — Struct instances are ordinary mutable objects. Finally, == compares by class and by attribute values, not by object identity, so a brand-new Point.new(3, 10) is considered equal to the mutated p1.

Example 2: keyword_init and pattern matching

Employee = Struct.new(:name, :department, :salary, keyword_init: true) do
  def raise_salary(percent)
    self.salary = (salary * (1 + percent / 100.0)).round(2)
  end
end

alice = Employee.new(name: "Alice", department: "Engineering", salary: 90000)
alice.raise_salary(10)
puts alice.salary

case alice
in { department: "Engineering", salary: }
  puts "Engineer earning #{salary}"
else
  puts "Not matched"
end

Output:

99000.0
Engineer earning 99000.0

Because keyword_init: true was passed, Employee.new only accepts keyword arguments, which makes the three-field call unambiguous. raise_salary uses self.salary = (the explicit receiver is required so Ruby treats it as a method call rather than a local variable assignment) to mutate the struct. The case/in block then destructures alice using the deconstruct_keys method every Struct gets for free: it checks that department equals "Engineering" and, since it matches, binds the local variable salary to alice.salary for use inside the branch.

Example 3: Data.define for immutable value objects

Point3D = Data.define(:x, :y, :z) do
  def magnitude
    Math.sqrt(x**2 + y**2 + z**2)
  end
end

origin = Point3D.new(x: 0, y: 0, z: 0)
p1 = Point3D.new(1, 2, 2)

puts origin.magnitude
puts p1.x
puts p1.magnitude
puts p1 == Point3D.new(x: 1, y: 2, z: 2)

moved = p1.with(z: 10)
puts moved.z
puts p1.z

Output:

0.0
1
3.0
true
10
2

Data.define accepts both keyword construction (origin) and positional construction (p1) without needing any option. There is no z= method on Point3D — the only way to get a “changed” point is #with, which builds an entirely new, separately frozen instance. Calling p1.with(z: 10) leaves p1 exactly as it was, which is why p1.z still prints 2 at the end even though moved.z is 10.

How It Works Step by Step

Take Point = Struct.new(:x, :y) do ... end from Example 1: first, Struct.new runs and builds a brand-new anonymous class that inherits from Struct, generating initialize, x, x=, y, y=, ==, and more based on the symbols you passed in. Second, the block is evaluated in that new class’s body, so def distance_from_origin ... end becomes an instance method on it, just as if you’d written it inside class Point < Struct.new(:x, :y). Third, the whole expression is assigned to the constant Point, and Ruby uses that assignment to give the class a real name for inspect and error messages. Fourth, Point.new(3, 4) invokes the generated initialize, which stores 3 and 4 against the declared member names. From then on, every accessor call (p1.x, p1.y = 10) is just a normal method call resolved through the object’s class, the same way any Ruby method call works.

Data.define follows the same first three steps, but its generated initialize freezes the instance (calls freeze) as its very last step, before returning. That single freeze call is the entire mechanism behind Data’s immutability — there’s no special magic beyond “there are no setter methods, and the object is frozen so no instance variable can be reassigned even from the outside.” #with works by calling the constructor again with the merged attributes, producing a fresh, independently frozen object rather than modifying the original.

Common Mistakes

Mistake 1: Assuming assignment copies a Struct

A Struct is a regular object, so assigning it to another variable copies the reference, not the data. Mutating it through one variable mutates it everywhere:

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

def reset_origin(point)
  point.x = 0
  point.y = 0
end

original = Point.new(5, 5)
backup = original
reset_origin(original)

puts backup.x
puts backup.y

Output:

0
0

backup = original does not create a second struct — both variables point at the exact same object, so mutating original inside reset_origin is visible through backup too, which is rarely what a reader intends when they write “backup”. The fix is to explicitly duplicate the struct (or avoid mutation entirely by switching to Data):

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

def reset_origin(point)
  point.x = 0
  point.y = 0
end

original = Point.new(5, 5)
backup = original.dup
reset_origin(original)

puts backup.x
puts backup.y

Output:

5
5

dup creates a shallow copy that is a separate object with its own copy of the member values, so mutating original no longer affects backup.

Mistake 2: Trying to mutate a Data instance

Because Data-generated classes never define writer methods and freeze every instance, calling a setter-style method on one fails at runtime:

Point = Data.define(:x, :y)
point = Point.new(x: 1, y: 2)
point.x = 10 # NoMethodError: undefined method `x=' for an instance of Point

There is no x= method to call, and even if there were, the object is frozen. The corrected approach is to use #with to produce a new instance instead of trying to change the existing one:

Point = Data.define(:x, :y)
point = Point.new(x: 1, y: 2)
moved = point.with(x: 10)

puts point.x
puts moved.x

Output:

1
10

point is left completely unchanged, and moved is an independent object carrying the updated value — the pattern you want whenever you’re working with immutable data.

Best Practices

  • Reach for Struct when you need a simple, mutable data container with named fields, instead of hand-writing attr_accessor plus a matching initialize.
  • Reach for Data.define (Ruby 3.2+) for value objects that shouldn’t change after creation — coordinates, money amounts, date ranges, configuration snapshots.
  • Use keyword_init: true on Struct (or rely on Data.define‘s built-in keyword support) once a class has more than two or three fields, so call sites stay self-documenting.
  • Assign the result of Struct.new or Data.define to a capitalized constant so inspect output and error backtraces show a real class name instead of an anonymous one.
  • Keep custom methods added in the block small and focused; if a Struct or Data class accumulates validation logic and many behaviors, it has probably grown into a full class and deserves a proper class...end definition.
  • Take advantage of the automatic deconstruct/deconstruct_keys support to destructure Struct and Data instances with case/in instead of writing manual attribute checks.
  • If you want value semantics from a Struct without switching to Data, call .freeze on instances you don’t want mutated — but prefer Data when immutability is the actual design intent.

Practice Exercises

  1. Define a Struct called Book with keyword-initialized title, author, and pages. Add a method long? that returns true when a book has more than 400 pages. Create two books and print whether each is long.
  2. Rewrite Book from the previous exercise as a Data.define class. Since you can no longer reassign pages directly, write a small script that creates a corrected, longer edition of a book using #with and prints both the original and the new page counts.
  3. Build an array of three Employee structs (name, department, salary) from Example 2. Use case/in pattern matching inside a loop to print a custom message for employees in the "Engineering" department and a different message for everyone else.

Summary

  • Struct.new generates a real, mutable class with accessors, ==, to_a/to_h, and pattern-matching support, saving you from writing that boilerplate by hand.
  • Data.define (Ruby 3.2+) generates an immutable value-object class: only readers, no writers, and every instance is frozen on creation.
  • Use #with on a Data instance to get a modified copy; the original is never changed.
  • Struct instances are ordinary mutable objects — assigning one to a new variable copies the reference, not the data, so use dup (or switch to Data) when you need an independent copy.
  • Both Struct and Data support case/in destructuring automatically via deconstruct and deconstruct_keys.
  • Prefer keyword construction (keyword_init: true for Struct, or plain keyword args for Data) once a class has more than a couple of fields.
  • Assign your generated class to a constant so it has a real, readable name in inspect output and error messages.