Pattern Matching with case/in
Pattern matching lets you check the shape of a value — not just its class or its equality to something else — and pull the interesting pieces out of it in a single step. Ruby’s case/in extends the familiar case/when statement with the ability to destructure arrays, hashes, and objects, bind the parts you care about to local variables, and attach conditions to each branch. It became a stable, non-experimental part of the language in Ruby 3.0, and it shines anywhere you’d otherwise write a tangle of if value.is_a?(Hash) && value[:status] == 200 checks — parsing JSON-like data, routing API responses, or validating the shape of configuration.
Overview: How Pattern Matching Works
case/when compares a value against each when clause using === and runs the first branch that matches. case/in works differently: instead of asking “is this value equal to X?”, each in clause describes a shape, and Ruby tries to decompose the value to fit that shape. Literal values (numbers, strings, symbols, ranges, regular expressions, and classes) still match the way you’d expect via ===, but arrays and hashes can be destructured into pieces, and each piece can itself be a nested pattern.
Under the hood, array patterns call deconstruct on the matched object, and hash patterns call deconstruct_keys. Ruby’s built-in Array and Hash classes already implement these, and so does Struct (since Ruby 3.2) and the newer Data class, which is why instances of those classes can be matched with array or hash patterns even though they aren’t literally an Array or a Hash. If an object doesn’t implement the relevant method, that in clause simply fails to match and Ruby moves on to the next one — no error is raised for a single failed clause.
Every bare lowercase identifier inside a pattern — like x in in [x, y] — is a binding: Ruby assigns the corresponding piece of the matched value to a local variable with that name, and that variable stays visible after the case expression ends. This is the opposite of case/when, where nothing is extracted automatically. If you want to compare against the current value of an existing variable instead of creating a new binding, prefix it with a caret: ^existing_variable. This is called the pin operator, and forgetting it is one of the most common pattern-matching mistakes (see below).
One more crucial difference from case/when: if no in clause matches and there’s no else, Ruby raises NoMatchingPatternError instead of quietly returning nil. Pattern matching is meant to be exhaustive by default — you either handle every shape you expect, or you explicitly opt out with an else.
Because pattern matching just calls deconstruct and deconstruct_keys, any class that defines them participates automatically. Struct defines both for you:
Point = Struct.new(:x, :y)
point = Point.new(3, 4)
case point
in [x, y]
puts "Array pattern via deconstruct: (#{x}, #{y})"
end
case point
in { x:, y: }
puts "Hash pattern via deconstruct_keys: (#{x}, #{y})"
end
Output:
Array pattern via deconstruct: (3, 4)
Hash pattern via deconstruct_keys: (3, 4)
Both case statements match the very same point object — once as an array-shaped value and once as a hash-shaped value — because Struct implements both deconstruction methods.
Syntax
case expression
in pattern1
# runs if expression matches pattern1
in pattern2 => bound_variable
# runs if it matches pattern2; bound_variable holds the match
in pattern3 if guard_condition
# runs only if it matches pattern3 AND guard_condition is true
else
# runs if nothing above matched (omit this and Ruby raises
# NoMatchingPatternError instead)
end
- expression — the value being matched; can be anything, but arrays and hashes are the most common targets.
- pattern — describes a shape: a literal (
200,"ok",:admin), a class (Integer,String), a range (1..10), an array pattern ([a, b]), a hash pattern ({status:, body:}), or a combination nested inside each other. - => bound_variable — optional; names the entire matched value (or sub-value) so you can use it in the branch body.
- if guard_condition / unless guard_condition — optional; the clause only matches if the pattern matches and the guard is truthy.
- ^variable (pin operator) — matches against the current value of an existing local variable instead of creating a new binding.
- pattern1 | pattern2 — alternative patterns; matches if either side matches (variable bindings aren’t allowed inside alternatives, since it would be ambiguous which side bound them).
- [*, x, *] — a find pattern; matches an array that contains
xsomewhere, ignoring any number of elements before and after it.
One-line Pattern Matching: => and in
Ruby also supports pattern matching outside a full case statement using two operators built on the same machinery. value => pattern (rightward assignment) matches and binds variables, raising an error if it fails. value in pattern instead returns true or false and never raises, which makes it useful inside an if:
config = { env: "production", region: "us-east-1" }
config => { env:, region: }
puts "Running in #{env} (#{region})"
if config in { env: "production" }
puts "This is prod!"
end
Output:
Running in production (us-east-1)
This is prod!
Use => when you expect the match to always succeed and want an early, loud failure otherwise; use in when you’re just asking a yes/no question about a value’s shape.
Examples
Example 1: Array patterns with literals, bindings, and guards
def describe(point)
case point
in [0, 0]
"Origin"
in [x, 0]
"On the x-axis at #{x}"
in [0, y]
"On the y-axis at #{y}"
in [x, y] if x == y
"On the diagonal at (#{x}, #{y})"
in [Integer => x, Integer => y]
"Point at (#{x}, #{y})"
else
"Not a 2D point"
end
end
puts describe([0, 0])
puts describe([5, 0])
puts describe([3, 3])
puts describe([2, 7])
puts describe("not a point")
Output:
Origin
On the x-axis at 5
On the diagonal at (3, 3)
Point at (2, 7)
Not a 2D point
Each in clause is tried from top to bottom, just like when clauses. [0, 0] only matches an array whose two elements are both literally 0. [x, 0] matches any two-element array whose second element is 0, binding the first to x. The guard on [x, y] if x == y only fires once both elements are bound and the condition is true. The final array pattern combines a class check and a binding in one go: Integer => x means “this element must be an Integer; if so, bind it to x.” The last call passes a String, which doesn’t implement deconstruct, so every array pattern silently fails to match and control falls through to else.
Example 2: Hash patterns, shorthand keys, and the pin operator
users = [
{ name: "Ada", role: :admin, active: true },
{ name: "Grace", role: :editor, active: false },
{ name: "Alan", role: :admin, active: false }
]
target_role = :admin
users.each do |user|
case user
in { name:, role: ^target_role, active: true }
puts "#{name} is an active admin"
in { name:, role: ^target_role }
puts "#{name} is an inactive admin"
in { name:, active: false }
puts "#{name} is inactive"
in { name: }
puts "#{name} is active"
end
end
Output:
Ada is an active admin
Grace is inactive
Alan is an inactive admin
name: is shorthand for name: name — it binds the value at key :name to a local variable of the same name. role: ^target_role uses the pin operator to require the hash’s :role value to equal the current value of target_role (:admin), rather than binding a new variable. Hash patterns don’t require an exact match by default — extra keys the pattern doesn’t mention (like :active in the second clause) are simply ignored — which is why Alan, whose hash also has an :active key, still matches { name:, role: ^target_role } before Ruby ever reaches the :active-checking clauses.
Example 3: Realistic response handling with mixed patterns and a guard
def handle_response(response)
case response
in { status: 200, body: }
"Success: #{body}"
in { status: 404 }
"Not found"
in { status: Integer => code } if code >= 500
"Server error: #{code}"
in { status: }
"Unhandled status: #{status}"
end
end
puts handle_response({ status: 200, body: "OK data" })
puts handle_response({ status: 404 })
puts handle_response({ status: 503 })
puts handle_response({ status: 301 })
Output:
Success: OK data
Not found
Server error: 503
Unhandled status: 301
This is the pattern-matching idiom you’ll use most in real code: each clause describes both a shape and a condition. The third clause combines a class check, a binding, and a guard — code must be an Integer and be 500 or greater. The final clause, { status: }, is a catch-all for any hash that has a :status key at all, since it comes last and imposes no other constraint.
How It Works Step by Step
Trace what happens when Ruby evaluates handle_response({ status: 503 }) from Example 3:
- Ruby evaluates the
caseexpression once:responseis{ status: 503 }. - It tries the first
inclause,{ status: 200, body: }. The hash pattern callsdeconstruct_keyson the response and checks whether:statusequals200. It doesn’t (it’s503), so this clause fails and Ruby moves on without running its body. - It tries
{ status: 404 }.503 != 404, so this fails too. - It tries
{ status: Integer => code } if code >= 500. The hash has a:statuskey whose value,503, is anInteger— so the pattern itself matches, andcodeis bound to503. Ruby then evaluates the guard,code >= 500, which istrue. Because both the shape and the guard succeeded, this clause is selected. - Ruby runs the clause’s body,
"Server error: #{code}", and that string becomes the value of the wholecaseexpression (and therefore the return value ofhandle_response, since a method returns the value of its last evaluated expression). - No further
inclauses are tried once a match is found — pattern matching, likecase/when, stops at the first success.
If none of the clauses had matched and there were no else, Ruby would raise NoMatchingPatternError at that point instead of returning anything.
Common Mistakes
Mistake 1: Forgetting the pin operator when comparing against a variable
A bare lowercase identifier in a pattern is always a new binding, never a comparison — even if a variable with that name already exists. This code looks like it checks whether result equals expected, but it doesn’t:
expected = 200
result = 404
case result
in expected
puts "Matched: #{expected}"
else
puts "No match"
end
Output:
Matched: 404
The bare identifier expected in the pattern position always matches, and it silently reassigns the outer expected variable to 404 — the opposite of what was intended, and a value that now leaks into any code after the case. Prefix the variable with ^ to compare against its existing value instead of rebinding it:
expected = 200
result = 404
case result
in ^expected
puts "Matched: #{expected}"
else
puts "No match, got #{result} instead of #{expected}"
end
Output:
No match, got 404 instead of 200
With the pin operator, Ruby treats expected as a value to test against, not a name to bind, so expected keeps its original value of 200 throughout.
Mistake 2: No else clause on a case/in that isn’t exhaustive
case/when quietly returns nil when nothing matches, so it’s tempting to assume case/in behaves the same way. It doesn’t:
status = -5
case status
in Integer => n if n.positive?
puts "Positive: #{n}"
in String
puts "It's a string"
end
Output: nothing is printed — status is a negative Integer, so it fails the guard on the first clause, and it isn’t a String, so it fails the second. With no else, Ruby raises NoMatchingPatternError and the program stops before either puts can run.
Whenever the input isn’t fully under your control, add an else:
status = -5
case status
in Integer => n if n.positive?
puts "Positive: #{n}"
in String
puts "It's a string"
else
puts "Unhandled value: #{status.inspect}"
end
Output:
Unhandled value: -5
Best Practices
- Reach for
case/inwhen you’re checking a value’s shape — several keys or positions at once — not as a replacement for a singleifcondition. - Always add an
elseclause when the matched value isn’t fully under your control (user input, API responses, parsed JSON), since a missed match raisesNoMatchingPatternErrorinstead of returningnil. - Use the pin operator (
^) whenever you want to compare against an existing variable’s value — a bare identifier always creates a new binding, never a comparison. - Combine a class check and a binding in one step with
ClassName => variable(for example,Integer => count) instead of matching first and calling.is_a?afterward. - Implement
deconstructanddeconstruct_keyson your own classes — or useStructorData, which already provide them — so your domain objects work naturally with pattern matching. - Order
inclauses from most specific to least specific; likecase/when, the first match wins even if a later clause would also have matched. - Keep individual patterns readable — prefer a few specific
inclauses over one clause with a long, hard-to-read guard condition.
Practice Exercises
- Write a method
classify(value)that takes an array and usescase/into return"empty array"for[],"array of numbers"if every element is numeric,"array of strings"if every element is aString, or"mixed array"otherwise. Hint: an empty array pattern is written[], and a guard withall?can check element types. - Given a hash like
{ method: "GET", path: "/users", params: { id: 5 } }, write acase/inthat destructures nested hashes to print"Fetching user 5"for a GET to/userswith an:idparam, and"Unsupported request"for anything else. - Write a method
within_threshold?(value, threshold)that usescase/inwith^thresholdto returntrueifvalueequalsthresholdandfalseotherwise. Then try removing the^and predict — before running it — what changes.
Summary
case/inmatches a value’s shape, callingdeconstructfor array patterns anddeconstruct_keysfor hash patterns.- Bare identifiers in a pattern create new local variable bindings; use the pin operator (
^variable) to compare against an existing variable’s value instead. => variablenames an entire matched value, andif/unlessguards add extra conditions to a clause.- Unlike
case/when, acase/inwith no matching clause and noelseraisesNoMatchingPatternErrorinstead of returningnil. - The one-line forms
value => pattern(rightward assignment) andvalue in pattern(boolean test) let you pattern-match outside a fullcasestatement. Struct,Data, and any class that definesdeconstruct/deconstruct_keyscan be matched just like arrays and hashes.
