Array and Hash Patterns

Published by Programming Line in Ruby. Last updated .

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 else branch (or wrap in begin/rescue) when the input isn’t guaranteed to match one of your patterns — letting NoMatchingPatternError propagate 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/in when you’re destructuring structured, nested data (API responses, parsed JSON, records); keep plain case/when for simple value or class checks where you don’t need to pull anything apart.
  • Use **nil sparingly 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 deconstruct and deconstruct_keys on 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 an else branch for arrays that aren’t exactly 3 elements.
  • Given an array of hashes like [{ type: :circle, radius: 4 }, { type: :square, side: 3 }], use case/in with hash patterns and a guard to print the area of each shape, handling both :circle and :square types.
  • Define a small class Temperature with a celsius attribute and a deconstruct_keys method 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 **nil to require an exact key set.
  • Under the hood, array patterns call deconstruct and hash patterns call deconstruct_keys — any object can implement these to become matchable.
  • A case/in with no matching branch and no else raises NoMatchingPatternError, unlike case/when which just evaluates to nil.
  • 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.