Pattern Matching Changed How I Write Ruby
I was skeptical of pattern matching when it first showed up in Ruby 2.7 as an experimental feature. It felt borrowed, like Ruby was trying to be Elixir. But case/in stopped being experimental in Ruby 3.0, the one-line forms and the find pattern followed in 3.1, and after using it seriously since then I can’t go back. It changes how you think about decomposing data.
The basics: case/in vs case/when
Traditional case/when checks equality or class membership:
case status
when "active"
activate_user
when "suspended"
notify_admin
else
raise "Unknown status: #{status}"
end
case/in matches structure:
case response
in { status: 200, body: { users: [first, *rest] } }
puts "First user: #{first}, #{rest.size} more"
in { status: 404 }
puts "Not found"
in { status: (500..) }
puts "Server error"
end
That first branch destructures a nested hash, pulls the first element from an array, and captures the rest, all in one line. Try doing that with case/when.
Matching hashes
This is where pattern matching earns its keep in everyday Ruby. API responses, config objects, parsed JSON, they’re all hashes. Pattern matching lets you reach into them declaratively.
config = { database: { host: "localhost", port: 5432 }, redis: { url: "redis://localhost" } }
case config
in { database: { host: String => host, port: Integer => port } }
puts "Connecting to #{host}:#{port}"
end
You’re simultaneously checking structure, checking types, and binding variables. Without pattern matching, this would be three or four lines of manual hash access with is_a? checks sprinkled in.
Matching arrays
Arrays get the same treatment. You can match on length, position, and content:
case [1, 2, 3]
in [Integer => a, Integer => b, Integer => c] if a < b
puts "Three ascending integers starting with #{a}"
in [_, _, _]
puts "Three elements, don't care what"
in [first, *]
puts "At least one element: #{first}"
end
The * splat captures the rest. The _ wildcard ignores a position. And yes, you can combine these with guard clauses using if, which we’ll get to.
The find pattern
Ruby 3.1 added the find pattern, which matches elements within an array regardless of position:
case [1, 2, 3, "error", 4, 5]
in [*, String => error, *]
puts "Found a string: #{error}"
end
This is incredibly useful for log processing, event streams, or any scenario where you’re searching for a specific shape inside a collection. Before this, you’d use detect or find, but pattern matching lets you express the condition and the extraction in one shot.
Guard clauses
Any pattern can have an if guard appended to it:
case user
in { age: Integer => age, role: "admin" } if age >= 18
grant_admin_access
in { age: Integer => age } if age < 13
redirect_to_kids_version
in { role: "guest" }
show_limited_content
end
The guard runs after the structural match succeeds. This keeps the pattern focused on shape and the guard focused on business logic. Clean separation.
The in operator for single checks
You don’t always need a full case block. The in operator works standalone for boolean checks:
if user in { role: "admin", active: true }
show_admin_panel
end
Or with arrays:
if response in { data: [_, _, *] }
puts "Got at least two results"
end
This is great for guard clauses at the top of methods. It replaces a chain of && conditions with something that reads more like a specification.
Real-world example: parsing API responses
Here’s where it all comes together. Say you’re consuming a third-party API that returns different shapes depending on success or failure:
def handle_response(response)
case response
in { status: 200, data: { users: [{ id: Integer } => user, *] } }
process_user(user)
in { status: 200, data: { users: [] } }
log("No users found")
in { status: 401, error: { message: String => msg } }
raise AuthenticationError, msg
in { status: (400..499), error: { code: String => code } }
raise ClientError, "API error: #{code}"
in { status: (500..) }
raise ServerError, "Upstream failure"
else
raise "Unexpected response shape: #{response.inspect}"
end
end
Compare this to the procedural alternative with nested if statements and manual hash lookups. The pattern matching version is longer in character count but dramatically easier to read and maintain. Each branch is a self-contained description of what it handles.
Notice { id: Integer } => user in the first branch. A pattern followed by => name checks the shape and binds the whole matched value, so user is the full user hash, not just its id. One catch: this method only works if response has symbol keys. More on that below, because it is the mistake I see most.
The string keys trap
Here is the failure that cost me most of an afternoon. A webhook handler, a nicely written case/in, and every single payload falling through to the else branch:
payload = JSON.parse(request.body.read)
case payload
in { event: "invoice.paid", data: { id: String => id } }
mark_paid(id)
else
Rails.logger.warn("Unhandled webhook: #{payload.inspect}")
end
The log line showed exactly the shape I was matching against. The problem is that JSON.parse returns string keys, and hash patterns only match symbol keys. There is no way to write a string key in a hash pattern at all, in { "event" => "invoice.paid" } is a syntax error. So the pattern quietly fails, and with an else branch there is no exception to tell you why.
body = JSON.parse('{"status":200}')
body in { status: 200 } # => false
JSON.parse('{"status":200}', symbolize_names: true) in { status: 200 } # => true
The fix is to symbolize at the boundary: JSON.parse(raw, symbolize_names: true), or deep_symbolize_keys on Rails params and anything else that arrives with string keys. I covered why the two key types never compare equal in Symbol vs String Keys, and pattern matching is one more place where that difference bites. If a case/in that looks obviously correct never matches, print payload.keys.first.class before you question anything else.
Shorthand bindings, alternatives, and nil
A few pieces of syntax that make patterns much shorter once you know them.
A key with no value pattern binds a local variable of the same name:
case user
in { name:, email: }
send_welcome(name, email)
end
That is the same as { name: name, email: email }, and it is the form I use most in practice.
The | operator gives you alternatives:
case value
in Integer | Float => n
format_number(n)
in nil | ""
"N/A"
end
The restriction worth knowing: you cannot bind variables inside an alternative. in { a: Integer => x } | { b: Integer => x } is a SyntaxError (“illegal variable in alternative pattern”). Binding the whole alternative from outside, as in Integer | Float => n, is fine.
And nil is just a value pattern. { deleted_at: nil } matches a hash whose deleted_at key is present and nil. It does not match a hash missing the key entirely, which is a real difference when you are matching API payloads that omit null fields rather than sending them.
Pinning with ^
Sometimes you want to match against an existing variable’s value rather than binding a new one. The pin operator ^ does this:
expected_version = "2.0"
case config
in { version: ^expected_version }
puts "Correct version"
in { version: String => actual }
puts "Wrong version: #{actual}"
end
Without ^, Ruby would treat expected_version as a new binding. The pin says “match the value of this existing variable.”
Matching your own objects
Pattern matching is not limited to hashes and arrays. Any object can take part by defining two methods: deconstruct for array patterns and deconstruct_keys for hash patterns. Struct and Data already implement both, which is one of the quieter reasons I reach for Data.define for value objects:
Point = Data.define(:x, :y)
case Point.new(x: 0, y: 5)
in { x: 0, y: }
puts "On the y axis at #{y}"
in [x, y]
puts "Somewhere else: #{x}, #{y}"
end
For a plain class, deconstruct_keys receives the list of keys the pattern asks for, which lets you avoid computing expensive values nobody wants:
class Order
def initialize(record) = @record = record
def deconstruct_keys(keys)
attrs = { status: @record.status, total: @record.total_cents }
attrs[:line_items] = @record.line_items.to_a if keys.nil? || keys.include?(:line_items)
attrs
end
end
case Order.new(order)
in { status: "paid", total: (10_000..) }
flag_for_review(order)
end
keys is nil when the pattern uses **rest, because Ruby then needs everything. Treat nil as “return all keys” and you will not get surprised.
Pattern matching in tests
This is where pattern matching overlaps most with diffing hashes. Plenty of test failures come from asserting full equality on a hash that contains values you do not control, like generated ids and timestamps. A pattern lets you assert the shape and the values you care about while leaving the rest open:
# Minitest
assert_pattern do
json => { user: { id: Integer, email: "ann@example.com", created_at: String } }
end
# RSpec, no built-in matcher, but this works and fails with a useful error
expect { json => { user: { id: Integer, email: "ann@example.com" } } }.not_to raise_error
Both rely on rightward pattern matching with =>, which raises when the pattern fails instead of returning false. Since Ruby 3.1, a missing key raises NoMatchingPatternKeyError, a subclass of NoMatchingPatternError that names the key it could not find. That is a far more useful failure than a boolean assertion telling you false was not true.
My rule of thumb: use a pattern when the test is about the contract (“there is a user with this email and some integer id”) and use full equality when every value matters. When an equality assertion does fail on a big nested hash, that is when you want a proper diff rather than a pattern.
What to watch out for
Pattern matching is powerful, but a few things are worth knowing:
Non-exhaustive matching raises NoMatchingPatternError. Unlike case/when which returns nil on no match, case/in without an else will blow up. This is actually a good thing, it forces you to handle all cases, but it can surprise you.
Performance is fine. Early on, there were concerns about pattern matching being slower than manual conditional logic. In practice, the difference is negligible for application code. You’re not going to hit a bottleneck here.
Hash matching is partial by default. in { name: "Alice" } matches any hash that has a name key with value "Alice", regardless of other keys. This is usually what you want, but if you need an exact match, use in { name: "Alice", **nil }. The one exception is the empty pattern: in {} only matches an empty hash, not any hash, which reads backwards the first time you hit it.
Bindings are just local variables. A binding like in { name: } assigns name in the surrounding scope, and it will happily shadow a method called name for the rest of the method. In a model or presenter with an attribute of the same name, that is a confusing bug. Pick binding names that do not collide, or use ^ when you meant to compare.
The shift in thinking
Pattern matching isn’t just a different syntax for conditionals. It’s a different way of expressing what your code expects. Instead of imperatively checking conditions and extracting values step by step, you declare the shape you’re looking for and let Ruby do the work.
Once you start thinking in patterns, you’ll find yourself reaching for case/in in places you never expected. Configuration validation, event handling, state machines, API response parsing, anywhere you’re dealing with structured data that comes in multiple shapes.
It felt experimental for a while. It’s not anymore. Since Ruby 3.1, pattern matching is stable, optimized, and ready for production. If you haven’t tried it yet, start with API response handling. You’ll wonder how you lived without it.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.