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

case/in

case value, then one or more in pattern branches and an optional else

The value of the chosen branch

NoMatchingPatternError, unless there is an else branch

Rightward pattern assignment

value => pattern

None: it is a statement, not an expression

NoMatchingPatternError (or its subclass NoMatchingPatternKeyError)

Boolean test

value in pattern

true or false

false

The pattern language has seven building blocks, which nest freely inside array, find and hash patterns.

Pattern Example Matches

Value

1..9, String, /\d+/, nil, :lent, ->(n) { n.odd? }

Any value v for which pattern === v is true

Variable

name

Anything, and binds it to the local variable name

Pin

^limit, ^(a + b)

A value equal (===) to an existing variable or to an expression

Array

[Integer, String => s, *rest]

An array (or an object with deconstruct) of exactly that shape

Find

[*, :lent, *]

An array that contains the middle elements somewhere

Hash

{ title: String, **rest }

A hash (or an object with deconstruct_keys) that has at least those symbol keys

Alternative

Integer | Float

Either side; no variables may be bound inside

Capture

Integer => n

The pattern on the left, and binds the matched value to n

Constant with contents

Point(x:, y:), Point[x, y]

An object for which Point === object, then its contents

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).

flowchart TD A[value and next branch] --> B{pattern kind} B -->|value, pin| C["pattern === value"] B -->|variable| D[bind and match] B -->|array or find| E{"Array or responds to deconstruct?"} B -->|hash| F{"Hash or responds to deconstruct_keys?"} E -->|yes| G[check length and each element] E -->|no| H[branch fails] F -->|yes| I["call deconstruct_keys(keys or nil), check each key"] F -->|no| H C --> J{matched?} G --> J I --> J J -->|yes| K{guard true?} J -->|no| H K -->|yes| L[run the branch body] K -->|no| H H --> M{another branch?} M -->|yes| A M -->|no| N{else branch?} N -->|yes| L N -->|no| O[raise NoMatchingPatternError]

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 ... end on one line does not work: Ruby reads value in pattern as the subject, a boolean test, and then looks for when or in branches that never come (see Syntax errors you will meet).

  • Each branch starts with in, then the pattern, then a newline, a semicolon or then. One-line branches use then (in Integer then "integer"). RuboCop’s Style/InPatternThen rejects the in pattern; body form and Style/MultilineInPatternThen rejects then on branches whose body is on the next line.

  • case is an expression: the value of the chosen branch is the value of the whole case, so it can be assigned, returned or passed on.

  • when and in branches cannot be mixed in one case.

  • Branches are tried top to bottom and the first match wins. Variables bound by that branch are ordinary local variables that outlive the case.

  • else is optional. Without it the case is exhaustive in the sense that a value matching no branch raises (see the next section); this is the deliberate difference from case/when, which silently evaluates to nil.

  • Unlike case/when, one in branch 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]

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

case with several in branches and no else

The value converted with to_s: sym for :sym

One in branch, or =>, a class or value pattern fails

:sym: Integer === :sym does not return true

Array pattern of the wrong length

[1, 2]: [1, 2] length mismatch (given 2, expected 1); with a splat []: [] length mismatch (given 0, expected 1+)

Hash pattern, key missing (NoMatchingPatternKeyError)

{a: 1}: key not found: :b

Hash pattern with **nil, extra keys

{a: 1, b: 2}: rest of {b: 2} is not empty

Find pattern

[1, 2, 3]: [1, 2, 3] does not match to find pattern

Value is not deconstructable

1: 1 does not respond to #deconstruct, nil: nil does not respond to #deconstruct_keys

A guard was false

1: guard clause does not return true

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

What === means depends on the receiver, which is the pattern:

Pattern === tests

A class or module (Integer, Shelf::Book, Comparable)

value.is_a?(pattern), see Module#===

A Range (1..9, 10.., ..-1, "a".."z")

pattern.cover?(value), so 1..9 === 2.5 is true, see Range#===

A Regexp

A String or Symbol that matches; any other class is simply false, see Regexp#===

A Proc or lambda

Calls it with the value and uses the truthiness of the result, see Proc#===

A Set

Membership (include?), see Set#===

Any other object (0, "x", :lent, nil, true)

==, see Object#===

An Array or Hash constant

==: :lent in %i[available lent] is false, not "is a member"

Four consequences are worth remembering.

  • Order matters when patterns overlap. In the example 2.5 is a "digit" because it falls inside 1..9; the Float branch would have to come first (or the range be written with Integer-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 the ALLOWED set 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, false and __FILE__. A string literal is compared with String#===, 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]
  • [] matches only the empty array; [String] matches only one-element arrays holding a String.

  • * alone discards the rest; *rest binds it as an Array (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 in clause (in String => isbn, String => title, Integer) and after =>/in ([1, 2, 3] => head, *tail). Keep them when a pattern is nested, and write in [x, *] rather than in 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 = array is a different construct: it never fails, it pads with nil. The => form checks the shape and raises.

  • Const(...) and Const[...] 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
  • 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.

  • *pre and *post are optional names: [*, x, *] just asks "is there an element that matches x?".

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
  • 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.

  • **nil demands that there are no other keys: { title:, **nil } matches a hash whose only key is :title.

  • **rest binds the remaining pairs as a new Hash.

  • Key-only form. { title: } has no sub-pattern; it matches when the key exists (its value may be nil) 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: and hash => 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 example JSON.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 nil value. { 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 existing expected, it overwrites it (use the pin operator for that).

  • pattern => name matches 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 | 2 and in [Integer => a] | [Float => a]. Variables whose names start with _ are the documented exception: _first and _ 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
  • ^local pins a local variable (Ruby 2.7 and later). It must already exist, otherwise: missing: no such local variable.

  • ^@ivar, ^$global and ^@@cvar pin 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, and in { school:, schools: [*, { level: ^school }] } ties a nested value to an outer one.

  • Style/GlobalVars and Style/ClassVars flag the demonstration of ^$limit and ^@@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
  • Guards exist only on in clauses of case. => and in as one-line expressions have no guard: a trailing if is an ordinary statement modifier that is evaluated first (the pattern variables are still nil, 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 > 14 before in [: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 case fails only because of its guard, the message is guard 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 with unexpected void value expression. Use it as a statement.

  • in has low precedence. captured = value in Integer assigns value to captured and then tests the pattern (the result is thrown away); write captured = (value in Integer). In an argument list or when combined with && or || it must be parenthesised too: putsvalue in Integer, not puts(value in Integer).

  • Variables bound by in are visible afterwards, whether or not the test succeeded (the ones it never reached are nil).

  • Pattern matching in a block predicate is the idiom for filtering records: records.select { it in { state: :lent } }. The it parameter 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:

  • deconstruct takes no arguments and returns an Array; deconstruct_keys(keys) returns a Hash with symbol keys. Any other return type raises TypeError (deconstruct must return Array, deconstruct_keys must return Hash).

  • keys is an Array of the symbols the pattern is going to look at, or nil when the pattern has **rest and 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 a NoMethodError.

  • Const(...) and Const[...] first test Const === 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: a Receipt is not a LateReceipt.

  • The number of calls is unspecified. Ruby may call deconstruct once for all the branches of one case and may call deconstruct_keys once 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 deconstruct or deconstruct_keys is ignored and the branch fails as if the method did not exist.

What the core library deconstructs

Class deconstruct deconstruct_keys Since

Array

returns the array itself

not defined

2.7

Hash

not defined

returns the hash itself

2.7

Struct

the member values in order

the members as symbol keys

2.7

Data

the member values in order

the members as symbol keys

3.2 (when Data arrived)

MatchData

the capture groups as strings

the named groups as symbol keys

3.2

Time

not defined

year, month, day, yday, wday, hour, min, sec, subsec, dst, zone

3.2

Date

not defined

year, month, day, yday, wday

3.2

DateTime

not defined

the Date keys plus hour, min, sec, sec_fraction, zone

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
  • Struct#deconstruct_keys and Data#deconstruct_keys stop at the first requested key that is not a member (%i[left missing] returns only left), and return an empty hash when more keys are requested than there are members. Date#deconstruct_keys silently omits keys it does not know (cwday above). A missing key then fails the pattern as a missing hash key does.

  • Data is 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.

  • MatchData makes case regex.match(text) a parser step: named groups for the hash form, positional groups for the array form. See Regular Expressions.

  • Date and DateTime need require "date". Time and Date support makes range patterns on calendar parts possible, such as in { month: 11..12 }. Remember that Time#deconstruct_keys reports 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 to Data.define.

  • A regexp sub-pattern (name: /\AUrsula/) tests a string member without a separate match? call.

  • In the second branch the isbn is a nested Data, so the pattern reaches into it (isbn: { digits: ... }) with no accessor chain and no nil guards.

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):

stateDiagram-v2 [*] --> available available --> lent : lend lent --> lent : tick lent --> overdue : expire overdue --> overdue : expire lent --> returned : return overdue --> returned : return returned --> available : shelve
  • 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 else branch raises, so an invalid transition is caught at the call site instead of leaving the loan in a wrong state. Shelf::Loan itself validates its state: argument against STATES.

  • RuboCop’s Lint/DuplicateMatchPattern flags two rows with the same pattern and Lint/UnreachablePatternBranch flags 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 case short: 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 case has no else on purpose: a family event with a malformed payload should raise NoMatchingPatternError and be noticed, while an unknown event type is deliberately ignored by the outer case.

  • Integer => member_id validates the type while binding. Anything finer (a valid ISBN, a positive number) is a domain rule that belongs in a constructor such as Shelf::Isbn.new, not in the pattern.

  • Never match on untrusted input and then send or eval something derived from it; the pattern verifies shape, not safety.

Pattern matching and its alternatives

Task Without patterns With patterns

Choose by class and size

if value.is_a?(Array) && value.size == 2

in [a, b]

Read a nested value

config.dig(:db, :user) (returns nil on a wrong shape)

config => { db: { user: } } (raises)

Check optional keys

hash.key?(:a) && hash[:a].is_a?(String)

in { a: String }

Split a list

first, *rest = list (no check)

in [first, *rest]

Choose a handler by value

case x when 1..9 then ...

case x in 1..9 then ...

Is it this shape?

A chain of &&

value in pattern

Reject unknown keys

(hash.keys - allowed).empty?

in { a:, b:, **nil }

  • case/when compares one value against many values with === and returns nil when nothing matches; use it for flat dispatch. Choose case/in when the value has structure to unpack, or when failure should be an exception.

  • dig and fetch read one path; patterns validate and unpack several at once.

  • Polymorphism (a method per class) remains better than a case on 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
  • when and in mixed: one case uses 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.

  • **nil and **rest must 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 @size is not a pattern, pin it (^@size).

  • Subject and in on one line: write case value and the first in on 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 value matches anything and cannot be followed by useful branches. Write in Integer or in _ to say what you mean; RuboCop’s Lint/UnreachablePatternBranch finds 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 **nil where an unknown key should be rejected.

  • nil is a value, not "missing". Use { a: nil } to require a key that is present and holds nil, 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 a Float branch first.

  • Array and hash constants compare with ==, not membership; use a Set constant or a range.

  • The in and => precedence traps described in One-line forms.

  • A binding that shadows a method. After in { name: }, name is a local variable and name no longer calls a method of that name inside the scope.

  • Do not use patterns to hide a long conditional. If a case/in has branches whose patterns are almost identical and distinguished by guards, the logic probably belongs in a method or a polymorphic call.

  • Hash default values are ignored. deconstruct_keys on a Hash uses the keys actually stored, so a hash with Hash.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

NoMatchingPatternError

No in branch matched and there is no else, or => failed

Add an else, or fix the data or the pattern

NoMatchingPatternKeyError

A key listed in a hash pattern is missing (=> or a single-branch case)

Check error.key, handle the optional key with a separate branch

TypeError: deconstruct must return Array

A deconstruct returned something else

Return an Array

TypeError: deconstruct_keys must return Hash

A deconstruct_keys returned something else

Return a Hash with symbol keys

SyntaxError: variable capture in alternative pattern

A name is bound inside a | b

Bind outside the alternation or use _name

SyntaxError: duplicated variable name

Same name twice in one pattern

Rename, or pin the first

SyntaxError: no such local variable

^name for an unknown local

Assign it before the case

SyntaxError: unexpected void value expression

value => pattern used where a value is needed

Use it as a statement, or in

warning: assigned but unused variable

A bound name is never read (with -w)

Drop the name or prefix it with _

Version notes

Version Change

2.7

case/in, array, hash, alternative, capture and pin of local variables are introduced as an experimental feature. Struct#deconstruct and deconstruct_keys.

3.0

case/in is no longer experimental. => is added (experimental). in changes from raising to returning true or false. The find pattern is added (experimental).

3.1

The pin operator takes an expression ^(...) and works on instance, class and global variables. The one-line forms are no longer experimental. Parentheses are optional in one-line patterns.

3.2

The find pattern is no longer experimental. MatchData#deconstruct and #deconstruct_keys, Time#deconstruct_keys and Date#deconstruct_keys are added. Data with deconstruct and deconstruct_keys arrives.

3.4

Error messages print hashes as {a: 1} instead of {:a=>1}. The it block parameter makes select { it in pattern } possible.

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 case/in in 3.0, stable one-line forms and the expression pin in 3.1, stable find pattern in 3.2. No experimental warning on 4.0

One-line in raises when nothing matches

It returns true or false since 3.0; => is the raising form

Pin works only on local variables

Instance, class and global variables and any ^(expression) since 3.1

Hashes and Data printed as {:name=>"x"}

{name: "x"} since 3.4, including in NoMatchingPatternError messages

Struct is the way to get pattern-matchable value objects

Data.define (3.2) is immutable and supports both array and hash patterns

Block parameters written _1

it since 3.4