rubyguides

Using Ruby Struct for lightweight data objects

Ruby’s Struct class gives you a quick way to bundle related data together without writing a full class. A Ruby struct is a lightweight data container with predetermined attributes, perfect for data transfer objects, configuration objects, or any place where you need a simple container for a few related values.

Intro context

Struct sits in a nice middle ground between a hash and a full class. A hash is flexible, but its keys are easy to mistype and its shape is not obvious from the call site. A full class gives you control, but it also asks for more boilerplate. Struct is the lightweight option when the shape is already known. It gives you named fields, predictable accessors, and a lightweight object layout without much setup.

That makes Struct especially helpful when the data is simple and the fields are known in advance. It works well for return values, parsed records, small settings objects, and anything that needs to travel through your code with a clear shape. If the object is mostly data and only has a little behavior, Struct is often the cleanest first choice.

If you want to compare the tradeoffs with other container types, it also helps to look at Ruby hashes and Ruby arrays. Those types are more flexible, but Struct is often easier to read when the schema matters more than the raw storage mechanism.

Creating a struct

The simplest way to create a struct is with Struct.new, passing in attribute names as symbols or strings:

Point = Struct.new(:x, :y)
point = Point.new(10, 20)

point.x  # => 10
point.y  # => 20

The Point constant becomes a reusable class that you can instantiate as many times as needed. Each call to Point.new creates a fresh instance with its own data, but all instances share the same class definition and the same named accessors.

You can also create an anonymous struct and use it directly:

person = Struct.new(:name, :age).new("Alice", 30)
person.name  # => "Alice"

An anonymous struct is fine for quick one-off data bundles, but it cannot be referenced by name elsewhere in the code. Assigning the struct to a constant like Person or Rectangle makes it reusable across methods and files.

Struct as a class

When you assign a struct to a constant (like Point), it becomes a reusable class:

Rectangle = Struct.new(:width, :height)

rect1 = Rectangle.new(100, 50)
rect2 = Rectangle.new(200, 100)

rect1.width  # => 100

You can call methods on struct instances just like regular Ruby objects:

point = Point.new(3, 4)
point.to_h  # => {:x=>3, :y=>4}
point.values  # => [3, 4]

Keyword arguments

By default, structs use positional arguments. You can switch to keyword arguments with keyword_init: true:

Config = Struct.new(:host, :port, :ssl, keyword_init: true)

config = Config.new(host: "localhost", port: 443, ssl: true)
config.host  # => "localhost"
config.port  # => 443

Keyword initialization makes the call site self-documenting because each value is labelled with the field name it belongs to. When you come back to the code later, you do not need to remember whether port or host came first in the positional list.

This makes calls more explicit and less error-prone when you have many attributes:

# Positional — easy to mix up order
User.new("john", 25, "john@example.com")

# Keyword — self-documenting
User.new(name: "john", age: 25, email: "john@example.com")

Keyword initialization is especially useful when the attribute list grows beyond two or three fields. At that point positional arguments become hard to scan, and a small reorder can cause a subtle bug that is annoying to debug. Keywords make the call site self-documenting, which is one of the biggest reasons people reach for Struct in the first place.

Adding methods to structs

Structs support custom methods right in the definition:

Product = Struct.new(:name, :price) do
  def formatted_price
    "$%.2f" % price
  end
  
  def discounted_price(discount)
    price * (1 - discount)
  end
end

product = Product.new("Widget", 29.99)
product.formatted_price  # => "$29.99"
product.discounted_price(0.1)  # => 26.991

This makes structs incredibly flexible, they’re lightweight classes that still support full Ruby object behavior.

That flexibility is why Struct works so well for small domain objects. You get readable attribute access, plus a place to tuck helper methods that belong with the data. If the behavior starts to grow into validation, branching, or side effects, that is usually the moment to move to a full class.

Default values

You can provide default values for attributes:

Server = Struct.new(:host, :port, :protocol) do
  def initialize(host = "localhost", port = 80, protocol = "http")
    super
  end
end

Server.new  # => #<struct Server host="localhost", port=80, protocol="http">

Or use a block to define defaults more elegantly:

Options = Struct.new(:debug, :verbose) do
  def initialize(*)
    super
    self.debug ||= false
    self.verbose ||= false
  end
end

Ruby 3.2+ also supports default and default_proc for hash-like defaults. Default values are handy when you want a struct to have a predictable shape even when the caller omits some fields. In practice, that means less defensive code in the rest of the app because you are not constantly checking for missing values before you use the object.

The default approach works well for configuration objects where most fields have sensible fallbacks. The caller only needs to provide the values that differ from the standard setup, which keeps the call site concise.

Struct vs OpenStruct

Ruby provides two similar mechanisms for quick data objects:

FeatureStructOpenStruct
PerformanceFaster (fixed layout)Slower (hash-backed)
AttributesDefined at creationAddable at runtime
Keyword argsSupportedSupported (Ruby 3.0+)
MemoryMore efficientMore memory

OpenStruct is slower because it uses a hash internally. Use Struct when you know the attributes ahead of time:

# Struct — faster, fixed schema
Point = Struct.new(:x, :y)

# OpenStruct — flexible, slower
point = OpenStruct.new(x: 1, y: 2)
point.z = 3  # can add attributes freely

The choice here is mostly about flexibility versus predictability. OpenStruct is convenient for exploratory code or highly dynamic data, but Struct gives you a tighter, faster layout that is easier to reason about in production code. When the schema is known, the stricter option usually wins.

Practical examples

Configuration object

AppConfig = Struct.new(:env, :debug, :database_url, keyword_init: true)

config = AppConfig.new(
  env: "production",
  debug: false,
  database_url: "postgresql://localhost/app"
)

This pattern is common in application boot code where you want a plain object that groups related settings without introducing a lot of custom classes. Struct keeps the config readable and makes it easier to pass around as a single value. When the app needs to access settings in multiple places, you can pass the config object instead of threading individual values through method arguments.

Data transfer object

UserDTO = Struct.new(:id, :name, :email, :created_at, keyword_init: true)

def build_user(data)
  UserDTO.new(
    id: data["id"],
    name: data["name"],
    email: data["email"],
    created_at: Time.parse(data["created_at"])
  )
end

DTO-style structs are helpful when you convert data from one shape to another, such as turning a JSON payload into a Ruby object with named fields. The object stays small, but the field names make the intent obvious at each call site.

Multiple instances

Card = Struct.new(:rank, :suit)

deck = Card.new("A", "♠️")
deck.rank  # => "A"
deck.suit  # => "♠️"

This is a good reminder that Struct instances are still full Ruby objects. You can create many of them, pass them around, and read their fields without any extra ceremony.

Enumerating struct members

You can iterate over struct members programmatically:

Config = Struct.new(:host, :port, :ssl)

config = Config.new("example.com", 443, true)

config.members  # => [:host, :port, :ssl]

config.each_pair do |name, value|
  puts "#{name}: #{value}"
end
# host: example.com
# port: 443
# ssl: true

Iteration support is one of the easiest ways to inspect or serialize a struct. You can treat the members like a small, ordered record instead of inventing custom access logic for every field.

When to use structs

Structs shine in these scenarios:

  • Configuration objects, group related settings
  • Return values, multiple values from a method
  • Data transfer, simple DTOs between layers
  • Immutable data, structs work well with frozen objects

Avoid structs when you need complex validation, inheritance, or behavior-heavy objects. In those cases, write a full class.

In other words, Struct is best when the object is mostly data and the rest of the code wants a predictable bag of named values. Once the object starts deciding things, enforcing business rules, or coordinating other services, a regular class usually reads better.

The next time you need to decide between a hash, an array, or a small object, Struct gives you a practical middle option. It is compact enough for simple data flow, but descriptive enough to keep call sites readable. If you want to see similar tradeoffs in another area, the Ruby hashes guide is a good companion read.

See Also