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 after using it seriously since Ruby 3.1 made it stable, 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: [User => 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.
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.”
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 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.
Debugging pattern matches on complex hashes? Try RubyHash to see a clean diff of what you expected versus what you got.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.