Array and Hash Patterns
Pattern matching with case/in lets you check the *shape* of an array or hash and pull values out of it in one step, instead of manually indexing into it or calling fetch repeatedly. It reads like a description of the data you expect (“an array of exactly two elements” or “a hash with a :status key”) and Ruby either binds the pieces you asked for or moves on to the next branch. This lesson covers array and hash patterns specifically: how they destructure data, how splats and hash shorthand work, and the mechanism Ruby uses under the hood to make your own objects patternable.
Overview: How Array and Hash Pattern Matching Works
Ruby’s pattern matching was introduced experimentally in Ruby 2.7 and stabilized in Ruby 3.0. It is built on two protocol methods: deconstruct, which converts an object into an array for array patterns, and deconstruct_keys, which converts an object into a hash for hash patterns. Array and Hash already implement these (an array simply deconstructs to itself, and a hash to itself), which is why you can match directly against array and hash literals without any setup. Any object you define — a Struct, a plain class, a value object — can participate in pattern matching too, as long as it implements one or both of these methods.
A case/in expression tries each in branch from top to bottom and uses the first one whose pattern matches, exactly like case/when uses the first matching when. The crucial difference is what happens when nothing matches: case/when just evaluates to nil, but case/in raises NoMatchingPatternError unless you supply an else branch. This is intentional — pattern matching is meant to be exhaustive, so a silently-ignored case is treated as a bug rather than a quiet no-op.
Array patterns match by position and, by default, by exact length. Hash patterns match by key and, by default, ignore any extra keys the hash has beyond the ones you named — a hash pattern is really asking “does this hash contain *at least* these keys with these values,” not “does this hash equal exactly this.” Keep that asymmetry in mind: array patterns are strict about size, hash patterns are lenient about extra keys, unless you explicitly ask for exactness with **nil.
Syntax
The general form nests inside a case expression, with one or more in clauses:
case value
in [a, b] # exact 2-element array
in [a, *rest] # first element bound, rest collected into array
in [Integer, String] # match element classes
in [*, x, *] # find pattern — x found anywhere in the array
in { name: } # hash pattern shorthand for name: name
in { name: "Ada" } # hash pattern with an explicit value
in { name:, **nil } # exact hash match — no other keys allowed
in ^expected # pin operator — match against an existing variable's value
in :ok | :error # alternative pattern (either matches)
in [a, b] if a < b # pattern plus a guard clause
end
| Form | Meaning |
|---|---|
[a, b] |
Array of exactly 2 elements, bound to a and b |
[a, *rest] |
First element bound to a, remaining elements collected into array rest |
{ key: } |
Hash containing :key, value bound to a local variable named key (shorthand for key: key) |
{ key:, **nil } |
Hash containing exactly :key and nothing else |
^name |
Pin operator — compares against the current value of name instead of binding a new variable |
pattern if cond |
Guard clause — pattern must match *and* the condition must be truthy |
pat1 | pat2 |
Alternative pattern — matches if either side matches |
Examples
Example 1: Matching a simple array
point = [1, 2]
case point
in [x, y]
puts "Point at x=#{x}, y=#{y}"
else
puts "Not a point"
end
Output:
Point at x=1, y=2
The array [1, 2] has exactly two elements, so it matches [x, y], binding x to 1 and y to 2 as new local variables.
Example 2: Splat to capture the rest of an array
readings = [:ok, 72, 68, 75]
case readings
in [:ok, *temperatures]
puts "Status OK, temperatures: #{temperatures.join(", ")}"
in [:error, message]
puts "Error: #{message}"
end
Output:
Status OK, temperatures: 72, 68, 75
The first element must literally be the symbol :ok. Once that matches, *temperatures greedily collects every remaining element into a new array, regardless of how many there are.
Example 3: Hash pattern with a guard, applied to real records
orders = [
{ id: 1, status: :shipped, total: 42.50 },
{ id: 2, status: :pending, total: 15.00 },
{ id: 3, status: :shipped, total: 120.00 }
]
orders.each do |order|
case order
in { status: :shipped, total: } if total > 50
puts "Order #{order[:id]}: big shipped order ($#{total})"
in { status: :shipped, total: }
puts "Order #{order[:id]}: shipped ($#{total})"
in { status: :pending }
puts "Order #{order[:id]}: still pending"
end
end
Output:
Order 1: shipped ($42.5)
Order 2: still pending
Order 3: big shipped order ($120.0)
Each order is tested top-to-bottom. Order 1 matches the :shipped hash pattern but fails the total > 50 guard, so it falls to the plain shipped branch. Order 2 only matches the pending branch. Order 3 matches the first branch because its total clears the guard.
How It Works Step by Step
When Ruby evaluates case object in pattern, it does not use == the way case/when does. For an array pattern, it calls object.deconstruct to get an array, then checks the array’s length (for a fixed pattern) and tests each element position. For a hash pattern, it calls object.deconstruct_keys(keys) — passing the list of keys the pattern mentions, so an object can compute only what’s needed — and checks that each named key is present with a matching value. This is exactly the same protocol Array and Hash use internally, which is why your own objects can opt in by defining these methods.
Point = Struct.new(:x, :y)
point = Point.new(3, 4)
case point
in [x, y]
puts "Array-style: x=#{x}, y=#{y}"
end
case point
in { x:, y: }
puts "Hash-style: x=#{x}, y=#{y}"
end
Output:
Array-style: x=3, y=4
Hash-style: x=3, y=4
Struct implements both deconstruct and deconstruct_keys automatically, so a Struct instance can be matched either way. A plain class has to define deconstruct_keys itself:
class Money
attr_reader :amount, :currency
def initialize(amount:, currency:)
@amount = amount
@currency = currency
end
def deconstruct_keys(keys)
{ amount: amount, currency: currency }
end
end
price = Money.new(amount: 19.99, currency: "USD")
case price
in { currency: "USD", amount: }
puts "$#{amount}"
in { currency: "EUR", amount: }
puts "€#{amount}"
end
Output:
$19.99
Sometimes you want to compare against a value you already have, rather than bind a new variable that would just shadow it. That’s what the pin operator ^ is for:
expected_status = :shipped
order = { status: :shipped, id: 7 }
case order
in { status: ^expected_status, id: }
puts "Order #{id} matches expected status"
else
puts "no match"
end
Output:
Order 7 matches expected status
Without the ^, writing status: expected_status would rebind expected_status to whatever order[:status] happens to be, which is almost never what you want when you’re comparing against a known value.
Common Mistakes
Mistake 1: Assuming array patterns match “close enough”
Array patterns check length exactly unless you use a splat. Matching a 3-element array against a 2-element pattern raises NoMatchingPatternError if there’s no else:
coords = [1, 2, 3]
begin
case coords
in [x, y]
puts "2D point: #{x}, #{y}"
end
rescue NoMatchingPatternError => e
puts "Pattern match failed: #{e.class}"
end
Output:
Pattern match failed: NoMatchingPatternError
The fix is to either add a fallback branch or, if extra elements are expected and fine to ignore, use a splat so the pattern matches arrays of any length at or above the fixed part:
coords = [1, 2, 3]
case coords
in [x, y, *rest]
puts "Point: #{x}, #{y}, extra: #{rest}"
end
Output:
Point: 1, 2, extra: [3]
Mistake 2: Expecting hash patterns to reject unexpected keys
Unlike array patterns, hash patterns are non-strict by default — they only check that the keys you name are present with matching values, and silently allow any other keys to exist:
config = { name: "worker-1", role: :admin, secret: "unexpected" }
case config
in { name:, role: :admin }
puts "Admin config for #{name}, but 'secret' key was silently ignored: #{config.key?(:secret)}"
end
Output:
Admin config for worker-1, but 'secret' key was silently ignored: true
If you actually need to reject hashes that carry extra keys — for example, validating that a config hash contains *only* the fields you expect — add **nil to the pattern to require an exact key set:
config = { name: "worker-1", role: :admin, secret: "unexpected" }
case config
in { name:, role: :admin, **nil }
puts "Exact match admin config for #{name}"
else
puts "Hash has unexpected extra keys, no match"
end
Output:
Hash has unexpected extra keys, no match
Best Practices
- Always add an
elsebranch (or wrap inbegin/rescue) when the input isn’t guaranteed to match one of your patterns — lettingNoMatchingPatternErrorpropagate unhandled is rarely the experience you want for end users. - Prefer the hash shorthand (
{ status:, total: }) over repeating the key name ({ status: status, total: total }) — it’s the idiomatic, less noisy form. - Reach for
case/inwhen you’re destructuring structured, nested data (API responses, parsed JSON, records); keep plaincase/whenfor simple value or class checks where you don’t need to pull anything apart. - Use
**nilsparingly and deliberately — it turns a normally-lenient hash pattern into a strict one, which is useful for validation but easy to overuse where leniency was actually desired. - Use the pin operator
^whenever you’re comparing against an existing variable inside a pattern, not just binding a new one — forgetting it is a subtle source of bugs since the code will still “work,” just not the way you intended. - Implement
deconstructanddeconstruct_keyson your own value objects so they can be matched naturally instead of manually unpacked first.
Practice Exercises
- Write a method that accepts an array representing an RGB color (e.g.
[255, 0, 128]) and uses an array pattern to print each channel by name:"R=255, G=0, B=128". Add anelsebranch for arrays that aren’t exactly 3 elements. - Given an array of hashes like
[{ type: :circle, radius: 4 }, { type: :square, side: 3 }], usecase/inwith hash patterns and a guard to print the area of each shape, handling both:circleand:squaretypes. - Define a small class
Temperaturewith acelsiusattribute and adeconstruct_keysmethod returning{ celsius: celsius, fahrenheit: celsius * 9.0 / 5 + 32 }. Then pattern-match an instance against{ fahrenheit: }and print the Fahrenheit value.
Summary
- Array patterns (
[a, b]) match by position and require an exact length unless a splat (*rest) is used. - Hash patterns (
{ key: }) match by key and are lenient about extra keys by default; add**nilto require an exact key set. - Under the hood, array patterns call
deconstructand hash patterns calldeconstruct_keys— any object can implement these to become matchable. - A
case/inwith no matching branch and noelseraisesNoMatchingPatternError, unlikecase/whenwhich just evaluates tonil. - Guards (
if/unless) add extra conditions, the pin operator (^) matches against an existing value instead of binding a new one, and|lets a single branch accept multiple alternative patterns.
