Pattern Matching
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). Pattern matching is Ruby’s way of describing the
shape of a value and taking it apart in one step. A pattern states what the value must look like (its class, its length,
which keys it has, what those hold), and, if it does, binds the interesting parts to local variables. It replaces chains
of is_a?, [], dig, fetch and == calls with one declarative expression, and it fails loudly (an exception)
instead of quietly producing nil when the shape is not the one you expected.
The feature arrived in Ruby 2.7 as an experiment, case/in became stable in 3.0, the one-line forms in 3.1 and the
find pattern in 3.2 (see Version notes), so everything on this page works on any recent Ruby; the exceptions (the
it block parameter and the {a: 1} form of Hash#inspect in messages) need 3.4 and are called out where they appear. The page builds on
Control Expressions (case/when and truthiness),
Arrays, Hashes and
Assignment and Multiple Assignment (the
destructuring that patterns generalise). The value types most often matched are covered in
Struct, Data and Value Objects and
Regular Expressions; the equality protocol that value patterns
rely on is in Equality, Hashing and Comparison;
failures are exceptions as described in
Exceptions and Error Handling; and
Enumerable shows the iterator methods that combine well with the
one-line in form.
Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the
configuration of the running shelf gem (double-quoted strings, NewCops: enable and Style/Documentation off). The
output shown is the real output. The examples that need the gem are run with ruby -Ilib so that require "shelf" finds
it (see the shelf gem source). Two examples deliberately step outside
that configuration and say so.
Pattern matching at a glance
Ruby has one pattern language and three places to use it.
| Form | Syntax | Result | When nothing matches |
|---|---|---|---|
|
|
The value of the chosen branch |
|
Rightward pattern assignment |
|
None: it is a statement, not an expression |
|
Boolean test |
|
|
|
The pattern language has seven building blocks, which nest freely inside array, find and hash patterns.
| Pattern | Example | Matches |
|---|---|---|
Value |
|
Any value |
Variable |
|
Anything, and binds it to the local variable |
Pin |
|
A value equal ( |
Array |
|
An array (or an object with |
Find |
|
An array that contains the middle elements somewhere |
Hash |
|
A hash (or an object with |
Alternative |
|
Either side; no variables may be bound inside |
Capture |
|
The pattern on the left, and binds the matched value to |
Constant with contents |
|
An object for which |
The decision flow below is what Ruby does for each branch. It stops at the first branch whose pattern matches (and whose guard, if any, is true).
The three forms
# frozen_string_literal: true
config = { db: { user: "admin", host: "localhost", port: 5432 } }
# 1. case/in picks a branch and binds variables.
case config
in { db: { user:, host: } }
puts "connect #{user}@#{host}"
in { database: { username: } }
puts "legacy key, user #{username}"
else
puts "unrecognised shape"
end
# 2. => unpacks or raises when the shape is wrong.
config => { db: { port: } }
puts "port #{port}"
# 3. in answers true or false.
puts((config in { db: { port: Integer } }))
puts((config in { db: { port: String } }))
connect admin@localhost
port 5432
true
false
case/in is for choosing between shapes. => is for the case where only one shape is acceptable: it unpacks the parts
you want and raises if the data is not what the code assumes, which makes it a compact assertion at a boundary (a parsed
configuration file, a decoded message). in is a predicate: because it returns a boolean it fits in if, &&, select
and count.
Rules for case/in
-
The subject is followed by a newline or a semicolon.
case value in pattern then ... endon one line does not work: Ruby readsvalue in patternas the subject, a boolean test, and then looks forwhenorinbranches that never come (see Syntax errors you will meet). -
Each branch starts with
in, then the pattern, then a newline, a semicolon orthen. One-line branches usethen(in Integer then "integer"). RuboCop’sStyle/InPatternThenrejects thein pattern; bodyform andStyle/MultilineInPatternThenrejectsthenon branches whose body is on the next line. -
caseis an expression: the value of the chosen branch is the value of the wholecase, so it can be assigned, returned or passed on. -
whenandinbranches cannot be mixed in onecase. -
Branches are tried top to bottom and the first match wins. Variables bound by that branch are ordinary local variables that outlive the
case. -
elseis optional. Without it thecaseis exhaustive in the sense that a value matching no branch raises (see the next section); this is the deliberate difference fromcase/when, which silently evaluates tonil. -
Unlike
case/when, oneinbranch takes one pattern. A comma-separated list is not a list of alternatives: it is an array pattern without brackets (see Array patterns). Use|for alternatives.
Exhaustiveness: NoMatchingPatternError
# frozen_string_literal: true
def classify(value)
case value
in Integer then "integer"
in String then "string"
end
end
puts classify(1)
begin
classify(:sym)
rescue NoMatchingPatternError => e
puts "#{e.class}: #{e.message}"
end
def classify_total(value)
case value
in Integer then "integer"
else "other"
end
end
puts classify_total(:sym)
begin
{ title: "Dune" } => { title: String, year: }
puts year
rescue NoMatchingPatternKeyError => e
puts "#{e.class}: #{e.message}"
puts "key=#{e.key.inspect} matchee=#{e.matchee.inspect}"
end
p NoMatchingPatternKeyError.ancestors.take(3)
integer
NoMatchingPatternError: sym
other
NoMatchingPatternKeyError: {title: "Dune"}: key not found: :year
key=:year matchee={title: "Dune"}
[DidYouMean::Correctable, NoMatchingPatternKeyError, NoMatchingPatternError]
Source: NoMatchingPatternError and NoMatchingPatternKeyError
NoMatchingPatternError is a StandardError, so a bare rescue catches it. NoMatchingPatternKeyError is its subclass,
raised when the reason for the failure is a missing key in a hash pattern; it adds key and matchee and includes
DidYouMean::Correctable, so a near-miss key gets a suggestion: matching { nme: 1 } against { name: } reports
key not found: :name and then Did you mean? :nme in the detailed message. Rescue the parent when you do not care which of the two
happened.
The checking is dynamic: Ruby does not verify at load time that every case is covered. The exception at run time is the
safety net against forgetting one, and an else that raises a domain-specific error (as in State machines) turns it
into a better message.
The message format is a diagnostic aid, not an interface, and it varies with how the match was written. Observed on
Ruby 4.0 (the same on 3.3 and 3.4 except that 3.3 prints hashes in the old {:a=>1} form):
| Situation | Message |
|---|---|
|
The value converted with |
One |
|
Array pattern of the wrong length |
|
Hash pattern, key missing ( |
|
Hash pattern with |
|
Find pattern |
|
Value is not deconstructable |
|
A guard was false |
|
Do not parse these strings. If code needs to react to a specific failure, match the data with case/in and an else.
Value patterns
A value pattern is any literal, constant or expression-free object. Ruby tests it with the case equality operator,
pattern === value, the same operator case/when uses. That one rule explains every behaviour below.
# frozen_string_literal: true
ALLOWED = Set[:available, :lent].freeze
[0, 7, 42, -3, 2.5, "", "123", "abc", :overdue, :available, nil, [1], 1 / 3r].each do |value|
label =
case value
in 0 then "zero"
in 1..9 then "digit"
in 10.. then "large"
in ..-1 then "negative"
in "" then "empty string"
in /\A\d+\z/ then "numeric string"
in String then "string"
in :overdue then "overdue symbol"
in ALLOWED then "allowed state"
in nil then "nothing"
in ->(x) { x.respond_to?(:each) } then "iterable"
else "something else"
end
puts "#{value.inspect} -> #{label}"
end
0 -> zero
7 -> digit
42 -> large
-3 -> negative
2.5 -> digit
"" -> empty string
"123" -> numeric string
"abc" -> string
:overdue -> overdue symbol
:available -> allowed state
nil -> nothing
[1] -> iterable
(1/3) -> something else
Source: Pattern matching: Patterns
What === means depends on the receiver, which is the pattern:
| Pattern | === tests |
|---|---|
A class or module ( |
|
A |
|
A |
A |
A |
Calls it with the value and uses the truthiness of the result, see Proc#=== |
A |
Membership ( |
Any other object ( |
|
An |
|
Four consequences are worth remembering.
-
Order matters when patterns overlap. In the example
2.5is a "digit" because it falls inside1..9; theFloatbranch would have to come first (or the range be written withInteger-only checks) to treat it differently. -
Only values, constants and
^pins are allowed. A method call or arbitrary expression is a syntax error as a bare pattern; wrap it in^(...)(see The pin operator) or hoist it into a constant such as theALLOWEDset above. -
A lambda is a pattern.
in ->(x) { x.respond_to?(:each) }is the escape hatch when a condition cannot be expressed structurally. Prefer a guard (in x if x.respond_to?(:each)) when the condition needs the bound variables. -
Literals of every kind work: integers, floats, rationals (
3r), complex numbers (2i), strings, symbols, ranges, regexps,nil,true,falseand__FILE__. A string literal is compared withString#===, which is==.
Array patterns
An array pattern is a bracketed, comma-separated list of sub-patterns. It matches an Array, or any object that
responds to deconstruct (see deconstruct and deconstruct_keys), when it has exactly that many elements and each
element matches its sub-pattern. A splat (* or *name) absorbs the rest; only one is allowed per array pattern.
# frozen_string_literal: true
def describe(row)
case row
in [] then "empty row"
in [String => title] then "title only: #{title}"
in [String => title, Integer => year] then "#{title} (#{year})"
in [String => title, Integer => year, *tags] then "#{title} (#{year}) tagged #{tags.join(', ')}"
in [_, _, _, *] then "unexpected extra columns"
else "not a row"
end
end
puts describe([])
puts describe(["Dune"])
puts describe(["Dune", 1965])
puts describe(["Dune", 1965, "sf", "classic"])
puts describe([1965, "Dune"])
puts describe("Dune")
# Brackets are optional, and a leading element can be split from the rest.
case ["978-0-306-40615-7", "The Dispossessed", 1974]
in String => isbn, String => title, Integer => year
puts "#{isbn}: #{title}, #{year}"
end
first, *rest = [1, 2, 3]
[1, 2, 3] => head, *tail
p [first, rest, head, tail]
# The splat can sit anywhere, once.
case [1, 2, 3, 4, 5]
in [first_item, *middle, last_item]
p [first_item, middle, last_item]
end
empty row
title only: Dune
Dune (1965)
Dune (1965) tagged sf, classic
not a row
not a row
978-0-306-40615-7: The Dispossessed, 1974
[1, [2, 3], 1, [2, 3]]
[1, [2, 3, 4], 5]
Source: Pattern matching: Patterns
-
[]matches only the empty array;[String]matches only one-element arrays holding aString. -
*alone discards the rest;*restbinds it as anArray(possibly empty).[first, *]and[first, *_]are the same. -
A splat may sit at the front, the middle or the end:
[first, *middle, last]. -
The brackets can be dropped when the pattern is the whole
inclause (in String => isbn, String => title, Integer) and after=>/in([1, 2, 3] => head, *tail). Keep them when a pattern is nested, and writein [x, *]rather thanin x, *when the splat would end the line: Ruby then reads the first word of the next line as the name of the splat and the branch body is lost (usually a syntax error). -
The destructuring assignment
first, *rest = arrayis a different construct: it never fails, it pads withnil. The=>form checks the shape and raises. -
Const(...)andConst[...]are the same pattern with a prefix check; see deconstruct and deconstruct_keys.
Find patterns
A find pattern has a splat at both ends and one or more sub-patterns in between: [*pre, x, y, *post]. It scans the
array for the first position where the middle sub-patterns match consecutive elements, and binds what lies before and
after. It was added in Ruby 3.0 as an experiment and became stable in 3.2.
# frozen_string_literal: true
require "shelf"
author = Shelf::Author.new("Ursula K. Le Guin")
isbn = Shelf::Isbn.new("978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
events = [:created, :lent, "note: damaged cover", :returned, "note: repaired"]
case events
in [*before, String => note, *after]
puts "first note: #{note}"
puts "before: #{before.inspect}"
puts "after: #{after.inspect}"
end
case events
in [*, :lent, String => remark, *]
puts "#{book.title} was lent, then noted: #{remark}"
end
puts((events in [*, :overdue, *]))
case [1, 42, "x", 7, "y"]
in [*, Integer => n, String => s, *]
puts "first Integer followed by a String: #{n}, #{s}"
end
first note: note: damaged cover
before: [:created, :lent]
after: [:returned, "note: repaired"]
The Dispossessed was lent, then noted: note: damaged cover
false
first Integer followed by a String: 42, x
Source: Pattern matching: Patterns
-
The middle sub-patterns must match adjacent elements. Two separate searches (
[*, :lent, *, :returned, *]) are not a find pattern and are a syntax error. -
It tries each starting position in turn, so it is O(n times m) for n elements and m inner patterns. Fine for event lists and tokens, not for a hot loop over large arrays.
-
The first match wins and the outer splats are bound from that match only.
-
*preand*postare optional names:[*, x, *]just asks "is there an element that matchesx?".
Hash patterns
A hash pattern is { key: pattern, ... }. It matches a Hash, or an object responding to deconstruct_keys, that
contains every listed key with a value matching the sub-pattern.
# frozen_string_literal: true
entry = { title: "Dune", year: 1965, tags: %w[sf classic] }
case entry
in { title: String => title }
puts "extra keys are fine: #{title}"
end
case entry
in { title:, **nil }
puts "never reached"
in { title:, year:, tags:, **nil }
puts "closed pattern matched: #{title}, #{year}, #{tags}"
end
case entry
in { title:, **others }
puts "title #{title}, others #{others}"
end
puts((entry in {}))
puts(({} in {}))
# Only symbol keys are matched; a string key never matches.
puts(({ "title" => "Dune" } in { title: "Dune" }))
puts(({ "content-type": "text/plain" } in { "content-type": String }))
# Nested hashes and arrays compose.
case { name: "Ada", loans: [{ title: "Dune" }, { title: "Emma" }] }
in { name:, loans: [{ title: first_title }, *] }
puts "#{name} first borrowed #{first_title}"
end
extra keys are fine: Dune
closed pattern matched: Dune, 1965, ["sf", "classic"]
title Dune, others {year: 1965, tags: ["sf", "classic"]}
false
true
false
true
Ada first borrowed Dune
Source: Pattern matching: Patterns
-
Subset semantics. Extra keys are ignored. This is the opposite of array patterns, where the length must match.
-
{}is the exception. It matches only an empty hash. -
**nildemands that there are no other keys:{ title:, **nil }matches a hash whose only key is:title. -
**restbinds the remaining pairs as a newHash. -
Key-only form.
{ title: }has no sub-pattern; it matches when the key exists (its value may benil) and binds a local variable with the key’s name.{ title: String => t }binds to a different name. -
Braces are optional where a bare hash pattern is the whole clause:
in title:, year:andhash => title:. -
Only symbol keys.
{ "title" => ... }with a rocket is a syntax error, and a hash with string keys never matches a symbol-keyed pattern (convert first, for exampleJSON.parse(text, symbolize_names: true)). A key with characters that are not valid in a label is written as a quoted symbol label,{ "content-type": String }, which cannot contain interpolation. -
Missing key vs
nilvalue.{ a: }and{ a: _ }match{ a: nil }but not{};{ a: nil }matches only the former (see Pitfalls). -
A key may be listed only once per hash pattern.
Alternative patterns and binding
pattern1 | pattern2 matches if either side does, trying left to right. It is written between two value, array, hash or
nested patterns, and the alternation can be given a name afterwards with => name because the capture sits outside the
alternation.
Variable binding has three spellings:
-
A bare name as a pattern,
in [x, *rest], matches anything and binds it. It never compares:in [expected, *]does not check against an existingexpected, it overwrites it (use the pin operator for that). -
pattern => namematches the pattern first and binds the value only if it matched (Integer => year). -
A key-only hash entry (
{ title: }) binds the key’s name.
# frozen_string_literal: true
[1, 2.5, "3", :four, nil].each do |value|
case value
in Integer | Float => number
puts "number #{number}"
in String | Symbol => text
puts "text #{text}"
else
puts "neither: #{value.inspect}"
end
end
# A variable inside an alternative is a syntax error.
begin
RubyVM::InstructionSequence.compile("case 1\nin a | 2 then a\nend", "alternative.rb")
rescue SyntaxError => e
puts e.message
end
# Variables starting with an underscore are allowed there.
case [1, 2]
in [_first, _second] | [_, _, _]
puts "two or three items"
end
# Bare names bind; they never compare.
expected = 18
case [1, 2]
in [expected, *]
puts "matched, expected is now #{expected}"
end
number 1
number 2.5
text 3
text four
neither: nil
alternative.rb:2: syntax error found
1 | case 1
> 2 | in a | 2 then a
| ^ variable capture in alternative pattern
3 | end
two or three items
matched, expected is now 1
-
No variables inside an alternative, because Ruby could not know which one was set. The compiler rejects
in a | 2andin [Integer => a] | [Float => a]. Variables whose names start with_are the documented exception:_firstand_are allowed, but the value they hold is not meant to be used. -
Binding the alternation as a whole is fine:
Integer | Float => number. -
Bound variables are plain locals. They are visible after the
case, they are never in a new scope, and a branch that fails after binding some of them can leave those variables set. The documentation lists that as undefined behaviour: do not read a variable from a branch that did not run. -
A repeated name is an error (
in [a, a]). To require the same value in two places, pin the first (see the next section). -
_is a normal variable name with a convention. It can appear any number of times in one pattern. -
A key-only hash pattern shadows a method of the same name after the
case, like any local variable.
The pin operator
A bare name binds, so an existing variable cannot be used as a sub-pattern directly. The pin operator ^ says "use the
current value of this variable".
# frozen_string_literal: true
expected = 18
pair = [1, 2]
case pair
in [^expected, *]
puts "matched"
else
puts "not matched, expected is still #{expected}"
end
# rubocop:disable Style/GlobalVars, Style/ClassVars
$limit = 2
class Window
@size = 3
@@depth = 4
def self.check(values)
case values
in [^$limit, ^@size, ^@@depth]
"globals, ivars and class variables can be pinned"
else
"no match"
end
end
end
puts Window.check([2, 3, 4])
# rubocop:enable Style/GlobalVars, Style/ClassVars
a = 1
b = 2
total = 3
case total
in ^(a + b)
puts "an expression can be pinned"
end
# Pin makes two positions agree.
pairs = [%i[lent lent], %i[lent returned]]
pairs.each do |pair|
case pair
in [state, ^state] then puts "#{pair.inspect}: same state twice"
else puts "#{pair.inspect}: different states"
end
end
begin
RubyVM::InstructionSequence.compile("case 1\nin ^missing then 2\nend", "pin.rb")
rescue SyntaxError => e
puts e.message
end
not matched, expected is still 18
globals, ivars and class variables can be pinned
an expression can be pinned
[:lent, :lent]: same state twice
[:lent, :returned]: different states
pin.rb:2: syntax error found
1 | case 1
> 2 | in ^missing then 2
| ^~~~~~~ missing: no such local variable
3 | end
-
^localpins a local variable (Ruby 2.7 and later). It must already exist, otherwise:missing: no such local variable. -
^@ivar,^$globaland^@@cvarpin instance, global and class variables (Ruby 3.1). -
^(expression)pins the value of an arbitrary expression (Ruby 3.1):in ^(limit * 2),in ^(Time.now.year). The expression is evaluated when the branch is tried. -
Pinning compares with
===, as any value pattern does, so a pinned class or range keeps its meaning. -
The common use is equal in two places:
in [state, ^state]matches a pair of identical values, andin { school:, schools: [*, { level: ^school }] }ties a nested value to an outer one. -
Style/GlobalVarsandStyle/ClassVarsflag the demonstration of^$limitand^@@depth, so the example disables them for those lines only.
Guards
A case/in branch can end with if or unless and a condition that may use the variables the pattern bound. The
branch is chosen only when both the pattern matches and the guard is true.
# frozen_string_literal: true
[[:lent, 3], [:lent, 20], [:returned, 0], [:overdue, 40]].each do |pair|
label =
case pair
in [:lent, days] if days > 14 then "lent too long (#{days} days)"
in [:lent, days] then "lent for #{days} days"
in [state, days] unless days.zero? then "#{state}, #{days} days"
in [state, _] then state.to_s
end
puts label
end
# Guards belong to case/in only. After => or in, "if" is a plain modifier,
# evaluated first, so the pattern variables are still nil.
begin
pair = [1, 1]
result = (pair in [first, second]) if second == first * 2
p result
rescue NoMethodError => e
puts e.message
end
lent for 3 days
lent too long (20 days)
returned
overdue, 40 days
undefined method '*' for nil
Source: Pattern matching: Guard clauses
-
Guards exist only on
inclauses ofcase.=>andinas one-line expressions have no guard: a trailingifis an ordinary statement modifier that is evaluated first (the pattern variables are stillnil, as the example shows). -
A false guard falls through to the next branch, exactly like a failed pattern, so guards can be stacked (
in [:lent, days] if days > 14beforein [:lent, days]). -
Put cheap structural checks in the pattern and only the remaining logic in the guard. A pattern documents the shape; a guard hides it.
-
When a single-branch
casefails only because of its guard, the message isguard clause does not return true.
One-line forms
value => pattern and value in pattern are expressions in the grammar sense but with different roles. Both were
experimental in 3.0, where in also changed to return a boolean (in 2.7 it raised), and both are stable since 3.1, which
also made the parentheses around the pattern optional.
# frozen_string_literal: true
Loan = Struct.new(:title, :state, :days)
loans = [
Loan.new("Dune", :lent, 3),
Loan.new("Emma", :overdue, 40),
Loan.new("Kim", :lent, 20),
Loan.new("Ulysses", :returned, 0)
]
# in answers a yes/no question, so it works as a block predicate.
p(loans.count { it in { state: :lent } })
p loans.select { it in { state: :overdue | :lent, days: 15.. } }.map(&:title)
late, fine = loans.partition { it in { days: 15.. } }
p [late.map(&:title), fine.map(&:title)]
p(loans.filter_map { |loan| loan.title if loan in { state: :returned } })
# => unpacks, and doubles as an assertion about the shape.
loans.first => { title:, days: }
puts "#{title}: #{days} days"
# Parentheses around the pattern are optional.
[0, 1] => _, last
p last
{ y: 2 } => y:
p y
# in has low precedence: wrap it in parentheses before combining it with
# && or ||, or when its result is used as a value.
value = 7
big_integer = (value in Integer) && value > 5
p big_integer
captured = value in Integer # assignment binds first: captured is value itself
p captured
if (value in Integer => n) && n.odd?
puts "odd integer #{n}"
end
2
["Emma", "Kim"]
[["Emma", "Kim"], ["Dune", "Ulysses"]]
["Ulysses"]
Dune: 3 days
1
2
true
7
odd integer 7
-
=>yields no value:x = (5 => Integer)is rejected withunexpected void value expression. Use it as a statement. -
inhas low precedence.captured = value in Integerassignsvaluetocapturedand then tests the pattern (the result is thrown away); writecaptured = (value in Integer). In an argument list or when combined with&&or||it must be parenthesised too:putsvalue in Integer, notputs(value in Integer). -
Variables bound by
inare visible afterwards, whether or not the test succeeded (the ones it never reached arenil). -
Pattern matching in a block predicate is the idiom for filtering records:
records.select { it in { state: :lent } }. Theitparameter needs Ruby 3.4; use a named parameter on older versions.
deconstruct and deconstruct_keys
An array or find pattern asks the matched object for an array with deconstruct; a hash pattern asks for a hash with
deconstruct_keys(keys). Array and Hash have this built in; every other class has to provide it. A class implements
them to take part in pattern matching.
# frozen_string_literal: true
# A loan receipt that can be matched as a list or as a hash.
class Receipt
attr_reader :title, :state, :days
def initialize(title, state, days)
@title = title
@state = state
@days = days
end
def deconstruct
puts " deconstruct"
[title, state, days]
end
def deconstruct_keys(keys)
puts " deconstruct_keys(#{keys.inspect})"
{ title:, state:, days: }
end
end
LateReceipt = Class.new(Receipt) { def late? = true }
receipt = Receipt.new("Dune", :lent, 3)
case receipt
in [String => title, :lent, days]
puts "array pattern: #{title}, #{days}"
end
case receipt
in { state: :lent, days: }
puts "hash pattern: #{days}"
end
case receipt
in { state: :lent, **rest }
puts "with **rest the keys argument is nil; rest = #{rest}"
end
# The constant in front is checked with ===, then the contents are matched.
case receipt
in Receipt(title:)
puts "Receipt(...) form: #{title}"
end
case receipt
in LateReceipt[title:]
puts "never reached"
else
puts "a plain Receipt is not a LateReceipt"
end
# The methods must return the right type.
broken = Object.new
def broken.deconstruct = "not an array"
def broken.deconstruct_keys(_keys) = []
[-> { broken in [_] }, -> { broken in { a: _ } }].each do |attempt|
attempt.call
rescue TypeError => e
puts "#{e.class}: #{e.message}"
end
deconstruct
array pattern: Dune, 3
deconstruct_keys([:state, :days])
hash pattern: 3
deconstruct_keys(nil)
with **rest the keys argument is nil; rest = {title: "Dune", days: 3}
deconstruct_keys([:title])
Receipt(...) form: Dune
a plain Receipt is not a LateReceipt
TypeError: deconstruct must return Array
TypeError: deconstruct_keys must return Hash
The rules:
-
deconstructtakes no arguments and returns anArray;deconstruct_keys(keys)returns aHashwith symbol keys. Any other return type raisesTypeError(deconstruct must return Array,deconstruct_keys must return Hash). -
keysis anArrayof the symbols the pattern is going to look at, ornilwhen the pattern has**restand needs everything. It exists for optimisation: an object whose full hash is expensive to build may return only the requested keys. Returning more keys than requested is allowed; the extra keys are ignored. -
An object that lacks the method simply does not match (
1: 1 does not respond to #deconstruct); that is a normal failed branch, not aNoMethodError. -
Const(...)andConst[...]first testConst === object, then match the contents against the parentheses: arrays for positional contents, hashes for keyword contents. A subclass check is therefore a free type guard: aReceiptis not aLateReceipt. -
The number of calls is unspecified. Ruby may call
deconstructonce for all the branches of onecaseand may calldeconstruct_keysonce per branch, as the output above shows. Keep both methods free of side effects and cheap, and never rely on how often they run (the documentation lists this as undefined behaviour). -
Both methods must be public. A private
deconstructordeconstruct_keysis ignored and the branch fails as if the method did not exist.
What the core library deconstructs
| Class | deconstruct |
deconstruct_keys |
Since |
|---|---|---|---|
|
returns the array itself |
not defined |
2.7 |
|
not defined |
returns the hash itself |
2.7 |
|
the member values in order |
the members as symbol keys |
2.7 |
|
the member values in order |
the members as symbol keys |
3.2 (when |
|
the capture groups as strings |
the named groups as symbol keys |
3.2 |
|
not defined |
|
3.2 |
|
not defined |
|
3.2 |
|
not defined |
the |
3.2 |
# frozen_string_literal: true
require "date"
Pair = Struct.new(:left, :right)
Point = Data.define(:x, :y)
p Pair.new(1, 2).deconstruct
p Pair.new(1, 2).deconstruct_keys(nil)
p Pair.new(1, 2).deconstruct_keys(%i[left missing])
p Point.new(x: 1, y: 2).deconstruct
p Point.new(x: 1, y: 2).deconstruct_keys(%i[y])
case Point.new(x: 3, y: 4)
in { x:, y: } then puts "Data by key: #{x}, #{y}"
end
case Point.new(x: 3, y: 4)
in Point[x, y] then puts "Data by position: #{x}, #{y}"
end
match = /(?<year>\d+)-(?<month>\d+)-(?<day>\d+)/.match("2026-10-18")
case match
in { year:, month:, day: } then puts "MatchData by name: #{year}/#{month}/#{day}"
end
case "2026-10-18".match(/(\d+)-(\d+)-(\d+)/)
in [year, *] then puts "MatchData by position: #{year}"
end
moment = Time.new(2026, 10, 18, 9, 30, 0, "+00:00")
p moment.deconstruct_keys(%i[year month hour])
case moment
in { year: 2026.., month: 10, hour: 0..11 } then puts "a morning in October"
end
p Date.new(2026, 10, 18).deconstruct_keys(%i[year cwday])
case Date.new(2026, 10, 18)
in { wday: 0 } then puts "a Sunday"
end
[1, 2]
{left: 1, right: 2}
{left: 1}
[1, 2]
{y: 2}
Data by key: 3, 4
Data by position: 3, 4
MatchData by name: 2026/10/18
MatchData by position: 2026
{year: 2026, month: 10, hour: 9}
a morning in October
{year: 2026}
a Sunday
Source: Data#deconstruct_keys, Struct#deconstruct_keys, MatchData#deconstruct_keys, Time#deconstruct_keys and Date#deconstruct_keys
-
Struct#deconstruct_keysandData#deconstruct_keysstop at the first requested key that is not a member (%i[left missing]returns onlyleft), and return an empty hash when more keys are requested than there are members.Date#deconstruct_keyssilently omits keys it does not know (cwdayabove). A missing key then fails the pattern as a missing hash key does. -
Datais the natural carrier for pattern matching: the members are fixed, the object is immutable and both positional and keyword patterns work. See Struct, Data and Value Objects. -
MatchDatamakescase regex.match(text)a parser step: named groups for the hash form, positional groups for the array form. See Regular Expressions. -
DateandDateTimeneedrequire "date".TimeandDatesupport makes range patterns on calendar parts possible, such asin { month: 11..12 }. Remember thatTime#deconstruct_keysreports the time in the object’s own zone.
Matching the shelf domain
The shelf gem’s Book, Author and Isbn are Data classes, so nested patterns read like the object graph. The gem’s
own Loan#describe uses a hash pattern on a literal { state:, book: }, which keeps the case readable without
adding methods to the class.
# frozen_string_literal: true
require "shelf"
le_guin = Shelf::Author.new("Ursula K. Le Guin")
herbert = Shelf::Author.new("Frank Herbert")
books = [
Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "The Dispossessed", author: le_guin),
Shelf::Book.new(isbn: Shelf::Isbn.new("9780441172719"), title: "Dune", author: herbert)
]
# Data supports hash patterns, and positional patterns follow member order.
books.each do |book|
case book
in Shelf::Book[title:, author: Shelf::Author[name: /\AUrsula/]]
puts "#{title}: by Ursula"
in Shelf::Book[title:, isbn: { digits: /\A9780/ }]
puts "#{title}: ISBN starts with 9780"
end
end
# Unpack nested values with => and a single line.
books.first => { author: { name: }, isbn: { digits: } }
puts "#{name} / #{digits}"
# in as a filter; hash patterns on Data ignore the other members.
herbert_books = books.select { it in { author: { name: "Frank Herbert" } } }
puts herbert_books.map(&:title).inspect
# The domain object from the gem uses the same idea internally.
loan = Shelf::Loan.new(books.first, state: :overdue)
puts loan.describe
The Dispossessed: by Ursula
Dune: ISBN starts with 9780
Ursula K. Le Guin / 9780306406157
["Dune"]
The Dispossessed is overdue
Source: Data
-
Keyword and positional patterns are interchangeable on
Data; positional follows the order given toData.define. -
A regexp sub-pattern (
name: /\AUrsula/) tests a string member without a separatematch?call. -
In the second branch the isbn is a nested
Data, so the pattern reaches into it (isbn: { digits: ... }) with no accessor chain and nonilguards.
State machines
Array patterns over a [state, event] pair are a compact way to write a transition table. Each in is a row; alternatives
(|) merge rows that share an outcome, _ is a wildcard and else rejects anything not listed, so an unexpected event
is an explicit error rather than a silent no-op.
# frozen_string_literal: true
# Pure transition function: (state, event) -> next state.
def transition(state, event)
case [state, event]
in [:available, :lend] then :lent
in [:lent | :overdue, :expire] then :overdue
in [:lent | :overdue, :return] then :returned
in [:returned, :shelve] then :available
in [_, :tick] then state
else raise ArgumentError, "cannot #{event} while #{state}"
end
end
state = :available
%i[lend tick expire return shelve].each do |event|
following = transition(state, event)
puts "#{state.to_s.ljust(9)} --#{event}--> #{following}"
state = following
end
begin
transition(:available, :return)
rescue ArgumentError => e
puts "#{e.class}: #{e.message}"
end
available --lend--> lent
lent --tick--> lent
lent --expire--> overdue
overdue --return--> returned
returned --shelve--> available
ArgumentError: cannot return while available
The same transitions as a diagram (the tick event keeps any state unchanged and is not drawn for every state):
-
The function is pure (no mutation), which makes it trivial to test one row at a time.
-
Alternatives such as
[:lent | :overdue, :return]keep rows that share a target together. Order rows from specific to general; the wildcard row[_, :tick]must come after the specific rows. -
The
elsebranch raises, so an invalid transition is caught at the call site instead of leaving the loan in a wrong state.Shelf::Loanitself validates itsstate:argument againstSTATES. -
RuboCop’s
Lint/DuplicateMatchPatternflags two rows with the same pattern andLint/UnreachablePatternBranchflags a row hidden behind a catch-all.
Matching decoded payloads
Data decoded from JSON, YAML or a message queue is exactly the nested hash-and-array shape that patterns were designed
for. Parse with symbol keys, route on the discriminating field, bind what you need, and finish with an else for
anything that is not understood.
# frozen_string_literal: true
require "json"
def describe_loan(type, data)
case [type, data]
in ["loan.created", { isbn:, member: { id: Integer => member_id } }]
"lend #{isbn} to member #{member_id}"
in ["loan.returned", { isbn:, late_days: Integer => late }] if late.positive?
"return #{isbn}, #{late} days late"
in ["loan.returned", { isbn: }]
"return #{isbn} on time"
end
end
def describe(event)
case event
in { type: "loan.created" | "loan.returned" => type, data: } then describe_loan(type, data)
in { type: String => type } then "ignore unknown event #{type}"
else "reject payload without a type"
end
end
def handle(raw)
describe(JSON.parse(raw, symbolize_names: true))
rescue JSON::ParserError
"reject invalid JSON"
end
[
'{"type":"loan.created","data":{"isbn":"9780441172719","member":{"id":7}}}',
'{"type":"loan.returned","data":{"isbn":"9780441172719","late_days":3}}',
'{"type":"loan.returned","data":{"isbn":"9780441172719","late_days":0}}',
'{"type":"member.updated","data":{}}',
'{"data":{}}',
"[1, 2]",
"not json"
].each { |raw| puts handle(raw) }
lend 9780441172719 to member 7
return 9780441172719, 3 days late
return 9780441172719 on time
ignore unknown event member.updated
reject payload without a type
reject payload without a type
reject invalid JSON
-
JSON.parse(raw, symbolize_names: true)is what makes hash patterns usable. See Serialization: JSON, YAML, CSV and Marshal. -
The two-level dispatch keeps each
caseshort: the outer one picks the family of events ("loan.created" | "loan.returned" => type), the inner one the exact shape and the guard (late.positive?). -
The inner
casehas noelseon purpose: a family event with a malformed payload should raiseNoMatchingPatternErrorand be noticed, while an unknown event type is deliberately ignored by the outercase. -
Integer => member_idvalidates the type while binding. Anything finer (a valid ISBN, a positive number) is a domain rule that belongs in a constructor such asShelf::Isbn.new, not in the pattern. -
Never match on untrusted input and then
sendorevalsomething derived from it; the pattern verifies shape, not safety.
Pattern matching and its alternatives
| Task | Without patterns | With patterns |
|---|---|---|
Choose by class and size |
|
|
Read a nested value |
|
|
Check optional keys |
|
|
Split a list |
|
|
Choose a handler by value |
|
|
Is it this shape? |
A chain of |
|
Reject unknown keys |
|
|
-
case/whencompares one value against many values with===and returnsnilwhen nothing matches; use it for flat dispatch. Choosecase/inwhen the value has structure to unpack, or when failure should be an exception. -
digandfetchread one path; patterns validate and unpack several at once. -
Polymorphism (a method per class) remains better than a
caseon class names when you own the classes and the behaviour differs by type. Patterns suit data you do not control: parsed input, tuples, results of other libraries. -
Patterns do not replace
rescue: they select on data, not on raised exceptions.
Syntax errors you will meet
Prism, the default parser, reports the problems below at parse time. The example compiles each broken snippet without running it and prints the lines that point at the problem.
# frozen_string_literal: true
# The broken snippets also trigger unrelated void-context warnings; silence them.
$VERBOSE = nil
SNIPPETS = {
"when and in mixed" => "case 1\nwhen 1 then puts(1)\nin 2 then puts(2)\nend",
"variable in an alternative" => "case 1\nin a | 2 then puts(a)\nend",
"same name twice" => "case [1, 2]\nin [a, a] then puts(a)\nend",
"two splats, no anchor" => "case [1, 2]\nin [*a, *b] then puts(a, b)\nend",
"second find splat" => "case [1, 2]\nin [*, 1, *, 2, *] then puts(1)\nend",
"rest after **nil" => "case { a: 1 }\nin { a: 1, **nil, **r } then puts(r)\nend",
"hash rocket in a hash pattern" => "case { a: 1 }\nin { \"a\" => 1 } then puts(1)\nend",
"interpolated key" => "case { a: 1 }\nin { \"\#{1}a\": 1 } then puts(1)\nend",
"pin of an unknown name" => "case 1\nin ^missing then puts(2)\nend",
"subject and in on one line" => "case 1 in Integer then puts(1) end",
"instance variable as a pattern" => "case 1\nin @size then puts(2)\nend"
}.freeze
SNIPPETS.each do |name, source|
RubyVM::InstructionSequence.compile(source, "snippet.rb")
puts "#{name}: compiled"
rescue SyntaxError => e
puts "#{name}:"
e.message.lines.grep(/\A\s*\|\s*\^/).first(2).each { |line| puts " #{line.strip}" }
end
when and in mixed:
| ^~ unexpected 'in', ignoring it
| ^~~~ unexpected 'then', ignoring it
variable in an alternative:
| ^ variable capture in alternative pattern
same name twice:
| ^ duplicated variable name
two splats, no anchor:
| ^~~~~~ find patterns need at least one required inner pattern
second find splat:
| ^ unexpected rest pattern
rest after **nil:
| ^~~ unexpected pattern expression after the `**` expression
hash rocket in a hash pattern:
| ^ expected a `}` to close the pattern expression
| ^~~ expected a label as the key in the hash pattern
interpolated key:
| ^ expected a `}` to close the pattern expression
| ^~~~~~~~ symbol literal with interpolation is not allowed
pin of an unknown name:
| ^~~~~~~ missing: no such local variable
subject and in on one line:
| ^~~~ expected an `end` to close the `case` statement
| ^~~~ expected a `when` or `in` clause after `case`
instance variable as a pattern:
| ^ expected a delimiter after the patterns of an `in` clause
| ^~~~~ expected a pattern expression after the `in` keyword
-
whenandinmixed: onecaseuses one or the other. -
A variable in an alternative, or the same name twice: bind outside the
|(Integer | Float => number), or give the second occurrence a new name or a pin. -
Splats: an array pattern allows one
*; a find pattern is exactly[*, patterns, *]with at least one pattern between, so two independent searches cannot be written as one pattern. -
**niland**restmust come last in a hash pattern and cannot be combined. -
Hash rockets and interpolated keys are not patterns. Hash patterns accept only labels (
name:or"quoted-name":). -
^missing: the pin needs a local variable that already exists; a bare@sizeis not a pattern, pin it (^@size). -
Subject and
inon one line: writecase valueand the firstinon separate lines (or separated by;).
Pitfalls
# frozen_string_literal: true
def show(label, matched) = puts("#{label}: #{matched}")
# The extra parentheses around "x in y" are needed inside an argument list.
show "hash with an extra key", ({ a: 1, b: 2 } in { a: 1 })
show "array with an extra item", ([1, 2] in [1])
show "nil value, bare key", ({ a: nil } in { a: _ })
show "missing key, bare key", ({} in { a: _ })
show "nil value, nil pattern", ({ a: nil } in { a: nil })
show "missing key, nil pattern", ({} in { a: nil })
show "string key", ({ "a" => 1 } in { a: 1 })
show "Float in an Integer range", (2.5 in 1..9)
STATES = %i[available lent].freeze
show "Array constant", (:lent in STATES)
ALLOWED = Set.new(STATES).freeze
show "Set built from it", (:lent in ALLOWED)
# A bare name is a catch-all: it matches anything, so it must come last.
case 5
in n then puts "the first branch binds #{n} whatever the value is"
end
# A failed branch can still have assigned some of its variables.
case [1, "x"]
in [Integer => number, Integer]
puts "no"
in [Integer, String]
puts "second branch; number is #{number.inspect}"
end
hash with an extra key: true
array with an extra item: false
nil value, bare key: true
missing key, bare key: false
nil value, nil pattern: true
missing key, nil pattern: false
string key: false
Float in an Integer range: true
Array constant: false
Set built from it: true
the first branch binds 5 whatever the value is
second branch; number is 1
-
A bare name is a catch-all.
in valuematches anything and cannot be followed by useful branches. Writein Integerorin _to say what you mean; RuboCop’sLint/UnreachablePatternBranchfinds branches hidden behind one. -
A non-matching branch may still leak variables, as the last line of the output shows. Use fresh names per branch or rely on the variables only after a match.
-
Arrays are exact; hashes are open. A hash pattern accepting extra keys is useful for forward compatibility; add
**nilwhere an unknown key should be rejected. -
nilis a value, not "missing". Use{ a: nil }to require a key that is present and holdsnil, and{ a: _ }(or{ a: }when you need the value) to require only that the key is present. -
Strings are not symbols.
"a"keys never match; symbolise at the boundary. -
Floats in integer ranges. A range value pattern uses
cover?; add a class check (Integer => n if (1..9).cover?(n)) or order aFloatbranch first. -
Array and hash constants compare with
==, not membership; use aSetconstant or a range. -
The
inand=>precedence traps described in One-line forms. -
A binding that shadows a method. After
in { name: },nameis a local variable andnameno longer calls a method of that name inside the scope. -
Do not use patterns to hide a long conditional. If a
case/inhas branches whose patterns are almost identical and distinguished by guards, the logic probably belongs in a method or a polymorphic call. -
Hashdefault values are ignored.deconstruct_keyson aHashuses the keys actually stored, so a hash withHash.new(0)does not match{ missing: 0 }.
Tooling and warnings
ruby -w reports a pattern variable that is never used, the same warning as for any local variable:
# frozen_string_literal: true
config = { db: { user: "admin", port: 5432 } }
config => { db: { user:, port: } }
puts port
w.rb:4: warning: assigned but unused variable - user
5432
That run is the one deliberate exception to "no warnings" on this page: it shows the warning itself. Remove the unused
name from the pattern (port only), or give it a leading underscore (_user, or _). RuboCop 1.92 does not report an
unused pattern variable (this file is clean for it), so the interpreter warning is the check to keep. The cops that do
look at patterns are Lint/DuplicateMatchPattern, Lint/EmptyInPattern, Lint/UnreachablePatternBranch,
Style/InPatternThen and Style/MultilineInPatternThen. See
Style, Linting and Static Analysis.
Pattern matching is also visible to tools: Prism has nodes for each pattern kind (CaseMatchNode, InNode, ArrayPatternNode,
FindPatternNode, HashPatternNode, AlternationPatternNode, CapturePatternNode, PinnedVariableNode and
PinnedExpressionNode, MatchPredicateNode for in and MatchRequiredNode for =>), which is how editors and linters
analyse it (see
Reflection, Introspection and Tracing).
Diagnostics at a glance
| Message or class | Raised when | Typical fix |
|---|---|---|
|
No |
Add an |
|
A key listed in a hash pattern is missing ( |
Check |
|
A |
Return an |
|
A |
Return a |
|
A name is bound inside |
Bind outside the alternation or use |
|
Same name twice in one pattern |
Rename, or pin the first |
|
|
Assign it before the |
|
|
Use it as a statement, or |
|
A bound name is never read (with |
Drop the name or prefix it with |
Version notes
| Version | Change |
|---|---|
2.7 |
|
3.0 |
|
3.1 |
The pin operator takes an expression |
3.2 |
The find pattern is no longer experimental. |
3.4 |
Error messages print hashes as |
4.0 |
Nothing pattern-specific is listed in the release notes. The error messages shown on this page are the same as on 3.4, and Prism is the parser that reports the syntax errors. |
Where older books and articles are outdated
| Older statement | On Ruby 4.0 |
|---|---|
"Pattern matching was introduced in Ruby 2.7" and is experimental |
Experimental in 2.7; stable |
One-line |
It returns |
Pin works only on local variables |
Instance, class and global variables and any |
Hashes and |
|
|
|
Block parameters written |
|
References
-
Module#===, Range#===, Regexp#===, Proc#=== and Set#===