Introduction
Ruby’s Hash and Struct are both used to group related data, but they serve different purposes. Hashes are flexible key-value stores; Structs are lightweight, named data containers with a fixed set of fields. Knowing when to use each leads to cleaner, more expressive code.
Hash: Flexible Key-Value Storage
A Hash maps keys to values. Keys can be any object, but symbols are most common:
# Create a hash
person = { name: "Alice", age: 30, email: "[email protected]" }
# Access values
puts person[:name] # => "Alice"
puts person[:age] # => 30
# Add/update
person[:role] = "admin"
person[:age] = 31
# Delete
person.delete(:email)
# Iterate
person.each { |key, value| puts "#{key}: #{value}" }
# Check existence
person.key?(:name) # => true
person.key?(:phone) # => false
Hash Limitations
Hashes don’t give you method-style access:
h = Hash.new
h[:low] = 32.0
h[:low] # => 32.0
h.low # => NoMethodError: undefined method 'low' for Hash
You also get no type safety โ any key can be added at any time, and typos in key names fail silently:
config = { timeout: 30 }
config[:timout] # => nil (typo โ no error!)
Struct: Named, Typed Data Containers
Struct creates a class with named fields, accessor methods, and useful built-in methods. It’s ideal for structured data where you know the fields upfront.
# Define a Struct
Point = Struct.new(:x, :y)
p1 = Point.new(3, 4)
puts p1.x # => 3
puts p1.y # => 4
puts p1.to_a # => [3, 4]
puts p1.to_h # => {:x=>3, :y=>4}
# Structs support equality by value
p2 = Point.new(3, 4)
puts p1 == p2 # => true (value equality, not identity)
Struct with Methods
Structs accept a block for defining instance methods:
require 'date'
Reading = Struct.new(:date, :low, :high) do
def mean
(high + low) / 2.0
end
def range
high - low
end
def to_s
"#{date}: low=#{low}, high=#{high}, mean=#{mean.round(1)}"
end
end
r = Reading.new(Date.parse("2026-03-29"), 12.0, 24.0)
puts r.mean # => 18.0
puts r.range # => 12.0
puts r.to_s # => "2026-03-29: low=12.0, high=24.0, mean=18.0"
Struct as a Value Object
Money = Struct.new(:amount, :currency) do
def to_s
"#{currency} #{format('%.2f', amount)}"
end
def +(other)
raise ArgumentError, "Currency mismatch" unless currency == other.currency
Money.new(amount + other.amount, currency)
end
def *(factor)
Money.new(amount * factor, currency)
end
end
price = Money.new(10.00, "USD")
tax = Money.new(0.80, "USD")
total = price + tax
doubled = total * 2
puts price.to_s # => "USD 10.00"
puts total.to_s # => "USD 10.80"
puts doubled.to_s # => "USD 21.60"
puts price == Money.new(10.00, "USD") # => true
Struct vs Hash: Side-by-Side
# Hash approach
temperature_hash = { date: Date.today, low: 15.0, high: 28.0 }
puts temperature_hash[:low] # => 15.0
puts temperature_hash.low # => NoMethodError
# Struct approach
Temperature = Struct.new(:date, :low, :high)
temp = Temperature.new(Date.today, 15.0, 28.0)
puts temp.low # => 15.0 (method access)
puts temp[:low] # => 15.0 (also supports hash-style access)
puts temp.to_h # => {:date=>..., :low=>15.0, :high=>28.0}
Struct with keyword_init
Ruby 2.5+ supports keyword_init: true for named argument construction:
Person = Struct.new(:name, :age, :email, keyword_init: true)
alice = Person.new(name: "Alice", age: 30, email: "[email protected]")
puts alice.name # => "Alice"
# Order doesn't matter with keyword_init
bob = Person.new(email: "[email protected]", name: "Bob", age: 25)
puts bob.email # => "[email protected]"
# Missing fields default to nil
charlie = Person.new(name: "Charlie")
puts charlie.age # => nil
Struct vs Data (Ruby 3.2+)
Ruby 3.2 introduced Data โ an immutable value object similar to Struct but frozen:
# Data: immutable, no setters
Point = Data.define(:x, :y)
p = Point.new(x: 3, y: 4)
puts p.x # => 3
p.x = 5 # => NoMethodError: undefined method 'x='
# Struct: mutable by default
MutablePoint = Struct.new(:x, :y)
mp = MutablePoint.new(3, 4)
mp.x = 10 # OK
Use Data when you want immutable value objects (coordinates, money, dates). Use Struct when you need mutable fields.
Practical Use Cases
Struct for CSV/Tabular Data
require 'csv'
SalesRecord = Struct.new(:date, :product, :quantity, :revenue, keyword_init: true) do
def revenue_per_unit
revenue.to_f / quantity.to_i
end
end
records = CSV.foreach('sales.csv', headers: true).map do |row|
SalesRecord.new(
date: row['date'],
product: row['product'],
quantity: row['quantity'].to_i,
revenue: row['revenue'].to_f
)
end
total_revenue = records.sum(&:revenue)
top_product = records.max_by(&:revenue)
puts "Top product: #{top_product.product} (#{top_product.revenue})"
Hash for Dynamic/Unknown Keys
# Hash is better when keys are dynamic or unknown at design time
def parse_config(file)
YAML.load_file(file) # returns a Hash โ keys vary per config file
end
# Or when building a response object with variable fields
def api_response(status, **data)
{ status: status, timestamp: Time.now }.merge(data)
end
Struct for API Response Parsing
GithubRepo = Struct.new(:name, :full_name, :stars, :language, keyword_init: true) do
def popular?
stars > 1000
end
end
# Parse API response
data = JSON.parse(response.body)
repo = GithubRepo.new(
name: data['name'],
full_name: data['full_name'],
stars: data['stargazers_count'],
language: data['language']
)
puts repo.popular? ? "#{repo.name} is popular!" : "#{repo.name} is growing"
Comparison Table
| Feature | Hash | Struct |
|---|---|---|
| Field access | h[:key] |
s.field or s[:field] |
| Dynamic keys | Yes | No (fixed at definition) |
| Method access | No | Yes |
| Value equality | Yes | Yes |
| Iteration | each |
each_pair, members |
| Immutable version | .freeze |
Data (Ruby 3.2+) |
| Keyword init | N/A | keyword_init: true |
| Custom methods | No | Yes (via block) |
| Memory | Higher (flexible) | Lower (fixed layout) |
The Effective Ruby Recommendation
From Effective Ruby (Item 10): Prefer Struct over Hash for structured data. When you know the fields upfront, Struct gives you:
- Method-style access (more readable)
- Value equality out of the box
- A place to add methods
- Better documentation (field names are explicit)
- Slightly better performance
Comments