Nested Data Structures
Real-world data rarely fits into a single flat list — a classroom’s grades, a JSON API response, or a configuration file all nest arrays inside hashes inside arrays. Ruby handles this naturally because arrays and hashes can hold any object, including other arrays and hashes. This lesson covers how to build, read, and modify nested data structures, how to traverse them safely with dig and pattern matching, and the mutation and shallow-copy traps that catch even experienced Rubyists.
Overview / How it works
An Array is an ordered list of objects and a Hash maps keys to values. Since ‘any object’ includes arrays and hashes themselves, you can nest them arbitrarily deep: an array of hashes, a hash of arrays, a hash of hashes of arrays, and so on. There’s nothing magical going on under the hood — a nested structure is just an outer container whose elements happen to be references to other container objects.
When you write grid[1][2], Ruby evaluates grid[1] first, which returns whatever object lives at index 1 (say, another Array), and then calls [] on that result with the argument 2. Each bracket pair is an ordinary method call chained onto the return value of the previous one — there is no special ‘nested array’ syntax, just objects returning objects that you keep indexing into.
That ‘reference’ detail matters in practice. Variables in Ruby hold references to objects, not copies of them. If the same array or hash object ends up placed into multiple slots of an outer structure — which happens more easily than you’d expect — mutating it through one slot changes what you see through every slot. This is the root cause of one of the most common nested-structure bugs in Ruby, covered below in Common Mistakes.
Because chained bracket access raises NoMethodError the instant an intermediate step is nil (since nil has no [] method), Ruby gives you Hash#dig and Array#dig. They walk a chain of keys or indices and return nil the moment a step comes up empty, instead of blowing up. Ruby’s case/in pattern matching goes further, letting you destructure nested arrays and hashes directly inside a case expression, binding variables to just the parts you care about in one readable statement. Remember, too, that in Ruby only nil and false are falsy — an empty array [] or empty hash {} found while traversing a structure is still truthy, so don’t mistake ’empty’ for ‘missing’.
Syntax
Nested literals are just literals inside literals. The general shapes look like this:
nested_array = [[1, 2], [3, 4]]
array_of_hashes = [{ name: "x" }, { name: "y" }]
hash_of_arrays = { key: [1, 2, 3] }
nested_hash = { a: { b: 1 } }
hash.dig(:a, :b, :c) # walk hash keys safely
array.dig(0, 1) # walk array indices safely
| Form | Example | Notes |
|---|---|---|
| Array of hashes | [{ id: 1 }, { id: 2 }] |
Common shape for records/rows |
| Hash of arrays | { ruby: [1, 2], go: [3] } |
Common shape for grouping |
| Hash of hashes | { user: { name: "x" } } |
Common shape for config/JSON |
| Safe read | obj.dig(k1, k2, k3) |
Returns nil instead of raising on a missing step |
Examples
Example 1: a hash of arrays. Each student’s name maps to an array of scores.
classroom = {
"Alice" => [90, 85, 92],
"Bob" => [70, 75, 80]
}
classroom.each do |name, scores|
average = scores.sum / scores.size.to_f
puts "#{name}: #{scores.join(", ")} (avg #{average.round(1)})"
end
Alice: 90, 85, 92 (avg 89.0)
Bob: 70, 75, 80 (avg 75.0)
Iterating a hash yields [key, value] pairs, and here the value is itself an array, so scores is a real Array with all the usual methods (sum, size, join) available on it.
Example 2: an array of hashes with nested hashes, read safely with dig.
users = [
{ name: "Priya", address: { city: "Mumbai", zip: "400001" } },
{ name: "Sam", address: { city: "Austin", zip: "73301" } }
]
users.each do |user|
city = user.dig(:address, :city)
puts "#{user[:name]} lives in #{city}"
end
puts users.first.dig(:address, :zip)
Priya lives in Mumbai
Sam lives in Austin
400001
dig(:address, :city) is equivalent to user[:address][:city] here, but it would return nil instead of raising if a user hash had no :address key at all — a common situation with real API data.
Example 3: destructuring a nested structure with pattern matching.
inventory = {
fruits: [
{ name: "apple", qty: 10 },
{ name: "banana", qty: 0 }
],
vegetables: [
{ name: "carrot", qty: 5 }
]
}
inventory.each do |category, items|
items.each do |item|
case item
in { name:, qty: 0 }
puts "#{category}/#{name}: OUT OF STOCK"
in { name:, qty: }
puts "#{category}/#{name}: #{qty} left"
end
end
end
fruits/apple: 10 left
fruits/banana: OUT OF STOCK
vegetables/carrot: 5 left
Each in clause is a pattern. { name:, qty: 0 } matches only hashes where :qty is exactly 0, binding name to whatever :name holds; the more general { name:, qty: } matches any hash with those keys and binds both. Patterns are tried top to bottom, so the specific qty: 0 case must come before the general one.
How it works step by step
Take users.first.dig(:address, :zip) from Example 2. Ruby evaluates it left to right: users.first returns the first hash, { name: "Priya", address: { city: "Mumbai", zip: "400001" } }. Then dig(:address, :zip) runs its own internal loop — look up :address in the current object (getting the inner hash), then look up :zip in that result (getting "400001"). If either lookup had returned nil, dig would stop immediately and return nil rather than trying to call [] on nil.
Building a nested structure works the same way in reverse: an expression like { key: [1, 2, 3] } first evaluates the array literal [1, 2, 3], producing an Array object, and then stores a reference to that object as the hash value under :key. Nothing is copied — the hash just holds a pointer to the array, which is exactly why mutating that array later (hash[:key] << 4) is visible anywhere else that same array object is referenced.
Common Mistakes
1. Building a nested array with a shared inner object. Array.new(n, default) evaluates default once and reuses the same object for every slot:
grid = Array.new(3, Array.new(3, 0))
grid[0][0] = 1
p grid
[[1, 0, 0], [1, 0, 0], [1, 0, 0]]
All three rows are the same array object, so changing one changes all of them. Fix it by passing a block, which runs once per slot and produces a fresh array each time:
grid = Array.new(3) { Array.new(3, 0) }
grid[0][0] = 1
p grid
[[1, 0, 0], [0, 0, 0], [0, 0, 0]]
2. Chaining [] through a possibly-missing key. Plain bracket access raises the moment an intermediate step is missing:
data = { user: { name: "Kai" } }
puts data[:user][:profile][:bio]
NoMethodError: undefined method '[]' for nil (data[:user][:profile] is nil, so calling [:bio] on it fails)
data[:user][:profile] is nil because there’s no :profile key, and nil doesn’t respond to []. Use dig to traverse safely instead:
data = { user: { name: "Kai" } }
puts data.dig(:user, :profile, :bio).inspect
nil
3. Assuming dup makes a deep copy. dup (and clone) only copy the outer object — nested arrays and hashes inside it are still shared:
original = { tags: ["ruby", "rails"] }
copy = original.dup
copy[:tags] << "gems"
p original[:tags]
["ruby", "rails", "gems"]
Mutating copy[:tags] also changed original[:tags], because both hashes point at the same inner array. Duplicate the nested value explicitly if you need independent copies:
original = { tags: ["ruby", "rails"] }
copy = { tags: original[:tags].dup }
copy[:tags] << "gems"
p original[:tags]
p copy[:tags]
["ruby", "rails"]
["ruby", "rails", "gems"]
Best Practices
- Use
digwhenever a key or index might be missing partway through a nested structure — it avoids rescue-driven code for a completely normal situation. - Build nested arrays with
Array.new(n) { ... }(block form), neverArray.new(n, some_object), unless you deliberately want every slot to share the same object. - Remember that
dup/cloneare shallow; duplicate nested collections individually (or useMarshal.load(Marshal.dump(obj))for a true deep copy of plain data) when you need full independence. - Prefer symbol keys (
:name) over string keys for internal data you construct yourself — they’re immutable and cheaper to compare. - Reach for
case/inpattern matching when you need to both check the shape of nested data and pull values out of it in one step. - If a structure nests more than two or three levels deep, consider modeling it with a
Structor a small class instead — deeply nested hashes get hard to read and easy to typo. - When building a nested result from scratch,
each_with_object({})oreach_with_object([])is usually clearer than mutating a variable declared outside the block.
Practice Exercises
Exercise 1: Given departments = { engineering: [{ name: "Ana", salary: 90_000 }, { name: "Leo", salary: 85_000 }], sales: [{ name: "Max", salary: 60_000 }] }, write code that prints the total salary per department.
Exercise 2: Write a method safe_fetch(data, *keys) that returns the value found by following keys through data one step at a time, or "not found" if any step is missing. (Hint: this is what dig already does — try implementing it both with and without dig.)
Exercise 3: Given an array of coordinate pairs like [[2, 3], [-1, 4], [-2, -5], [3, -1]], use case/in pattern matching on each [x, y] pair to print which quadrant it falls in (assume no pair lands exactly on an axis).
Summary
- Arrays and hashes can hold any object, including other arrays and hashes, so nesting is unlimited and requires no special syntax.
- Indexing chains like
grid[1][2]are just method calls chained on each returned object — no magic beyond ordinary[]calls. - Nested containers are stored by reference, so the same inner array or hash object can accidentally end up shared across multiple slots — a frequent source of bugs.
Hash#digandArray#digtraverse a chain of keys/indices and returnnilon the first missing step instead of raising.case/inpattern matching destructures nested arrays and hashes and binds variables in one expression — use it overcase/whenwhen you need to pull structured data apart, not just compare a single value.dupandcloneare shallow copies; nested collections still need explicit duplication for true independence.
