Pattern Matching in Ruby 3
Pattern matching landed in Ruby 2.7 as experimental and graduated to stable in Ruby 3.0. Most Ruby developers have heard of it; far fewer use it confidently in production code. This post cuts through the noise — pattern matching isn’t just cleaner case syntax, it’s a fundamentally different way to express conditional logic that makes complex data transformations dramatically easier to read and maintain. By the end, you’ll have a toolset you can reach for immediately.
What Pattern Matching Actually Is
Forget what you know from case/when. Pattern matching is about structure, not equality. You’re not asking “is this value X?” — you’re asking “does this data have the shape I expect?”
Example:
case response
in { status: 200, body: { user: { id: Integer => id } } }
puts "Got user #{id}"
in { status: 404 }
puts "Not found"
in { status: (500..) }
puts "Server error"
end
That single case block does what previously needed a chain of guard clauses, .dig calls, and nil checks. That’s the real value proposition — not fewer lines, but code where the shape of your intent is visible at a glance.
The Four Pattern Types Worth Knowing
1. Value Patterns
Match against a literal, a class, or a range. The => name syntax captures the matched value into a variable.
Example:
case status_code
in 200
:ok
in 404
:not_found
in Integer => code if code >= 500
:server_error
end
2. Array Patterns
Match against positional structure. Splat works as you’d expect.
Example:
case [event_type, payload]
in ["user.created", { email: String => email }]
send_welcome_email(email)
in ["order.completed", { total: (100..) => total }]
flag_high_value_order(total)
in [String => unknown, *]
Rails.logger.warn("Unhandled event: #{unknown}")
end
3. Hash Patterns
Most useful in API and service layer work. Hash patterns do partial matching by default — extra keys are ignored unless you add **nil to enforce exact shape.
Example:
case api_response
in { error: String => message }
handle_error(message)
in { data: { items: Array => items } } if items.any?
process_items(items)
in { data: { items: [] } }
:empty_result
end
4. Find Pattern
Searches through an array for a matching element using [*, pattern, *]. Rare in application code, invaluable when processing event streams or log entries.
Example:
case audit_log
in [*, { level: :error, code: Integer => code, message: String => msg }, *]
notify_ops("Error #{code}: #{msg}")
end
The Pin Operator: When You Need Equality, Not Binding
Variable binding is powerful, but sometimes you want to match against an existing variable’s value — not rebind it. That’s what ^ (pin) does.
Example:
expected_id = current_user.id
case record
in { user_id: ^expected_id }
:authorized
in { user_id: Integer }
:unauthorized
end
Without the pin, expected_id would be rebound to record[:user_id] inside the match — almost certainly not what you want.
One-Line Pattern Matching in Ruby 3.x
Ruby 3.0 introduced => and in as standalone expressions for single-pattern assertions.
Deconstruct into locals:
{ name: "Rajan", role: :admin } => { name:, role: }
puts name # => "Rajan"
puts role # => :admin
Boolean shape check:
if response in { status: 200, body: Hash }
proceed_with_response(response)
end
This is particularly useful in RSpec and conditional service logic where you want to assert shape without a full case block.
Production Patterns Worth Stealing
Service Response Normalisation
Pattern matching shines when normalising responses from inconsistent external APIs — the kind where sometimes error is a string, sometimes it’s a nested object, and sometimes the whole key is absent.
Example:
def parse_payment_response(response)
case response
in { success: true, transaction_id: String => txn_id }
{ ok: true, txn_id: }
in { success: false, error: { code: Integer => code, message: String => msg } }
{ ok: false, error: "#{code}: #{msg}" }
in { timeout: true }
{ ok: false, error: "Request timed out" }
in { success: false }
{ ok: false, error: "Unknown payment failure" }
end
end
Every branch is readable. No rescue chains. No defensive .dig gymnastics.
Event-Driven Dispatching
In systems using domain events or message queues, pattern matching provides a declarative dispatcher that scales gracefully as event types grow.
Example:
def handle_event(event)
case event
in { type: "UserRegistered", payload: { id: Integer => id, email: String } }
UserMailer.welcome(id).deliver_later
in { type: "SubscriptionExpired", payload: { user_id: Integer => uid } }
User.find(uid).downgrade_to_free!
in { type: "InvoicePaid", payload: { amount: (1000..) => amount } }
flag_high_value_invoice(amount)
in { type: String => unknown_type }
Rails.logger.warn("Unhandled event: #{unknown_type}")
end
end
Pro-Tip: Deploy pattern matching at system boundaries — API parsing, event handling, external service responses. Inside tight domain logic, plain Ruby conditionals often remain clearer. Pattern matching earns its weight when incoming data shape is uncertain or variable.
Custom Classes and deconstruct
Pattern matching works with any Ruby class that implements deconstruct (for array patterns) or deconstruct_keys (for hash patterns).
Example:
class Money
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
end
def deconstruct_keys(keys)
{ amount:, currency: }
end
end
case payment_amount
in Money[amount: (0..), currency: "USD"]
process_usd_payment(payment_amount)
in Money[currency: String => cur]
raise "Unsupported currency: #{cur}"
end
This is particularly powerful in domain models where you want the pattern match to feel like querying the domain rather than poking at internal state.
Conclusion
Pattern matching is one of those features that takes an afternoon to learn but fundamentally changes how you model conditional logic. It’s not about fewer lines — it’s about code where the structure of your intent is visible at a glance. Start at your API boundaries and event handlers, where data shape is uncertain. Once you see it eliminate defensive nil-checking and brittle .dig chains in real code, the instinct to reach for it will become natural.
FAQs
Q1: Is Ruby pattern matching stable enough for production?
Yes. It’s been fully stable since Ruby 3.0 and widely adopted. The find pattern stabilised in Ruby 3.2.
Q2: Does pattern matching work with custom classes?
Yes. Implement deconstruct for array patterns and deconstruct_keys for hash patterns on any Ruby class to make it pattern-matchable.
Q3: What happens when no pattern matches?
Ruby raises NoMatchingPatternError. Add else or in _ as a catch-all to handle unexpected shapes gracefully.
Q4: How does pattern matching perform compared to traditional conditionals?
Benchmarks show negligible overhead for typical application code. The readability gains far outweigh any micro-performance cost in service layers or API handling.
Q5: Can I use pattern matching inside RSpec?
Yes. The in expression form is useful for asserting response shape in request specs without coupling tests to exact values — particularly when testing external API integrations.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀