Ruby's case/in: Pattern Matching for Messy API Payloads
Ruby 3.0's case/in pattern matching replaces nested conditionals with declarative data deconstruction. A worked webhook example, guards, pins, and the trade-offs.
19 Dec 2025, 22:41 UTC

Every Rubyist who has consumed a third-party API knows the shape of the problem: a webhook arrives, and the payload is a nested hash whose structure depends on the event type. You end up with a staircase of if/elsif blocks, manual key lookups, and defensive &. chains just to pull out the three values you actually need. Ruby 3.0 shipped a real answer to this: pattern matching with case...in. It lets you describe the shape you expect and bind the values you care about in a single declarative step.
What case/in actually does
A case...in expression tests a value against a series of patterns. A pattern can check structure (is this a hash with these keys? an array of this length?), check types, and bind matching pieces to local variables — all at once. The first arm that matches wins. If nothing matches, Ruby raises NoMatchingPatternError, which makes exhaustive handling explicit rather than accidental.
This is different from a regular case...when, which compares with ===. Patterns match structure, not just value equality.
A worked example: normalizing webhook events
Say a payment provider sends events with inconsistent shapes. Successful charges nest the amount under data, refunds put it under refund, and heartbeat pings carry no payload at all. The classic version looks like this:
def handle(event)
if event["type"] == "charge.succeeded"
amount = event.dig("data", "amount")
currency = event.dig("data", "currency")
"charged #{amount} #{currency}"
elsif event["type"] == "charge.refunded"
amount = event.dig("refund", "amount")
"refunded #{amount}"
elsif event["type"] == "ping"
"pong"
else
"ignored"
end
endHere is the same logic with pattern matching (requires Ruby 3.0+; check with ruby -v):
def handle(event)
case event
in { type: "charge.succeeded", data: { amount:, currency: } }
"charged #{amount} #{currency}"
in { type: "charge.refunded", refund: { amount: } }
"refunded #{amount}"
in { type: "ping" }
"pong"
else
"ignored"
end
endTwo things are happening. First, { type: "charge.succeeded", ... } asserts both that event is a hash (or hash-like) and that :type equals that string. Second, amount: is shorthand that binds the value at key :amount to a local variable named amount. Note that hash patterns match symbol keys by default — if your JSON parser produces string keys, call event.symbolize_keys (Rails) or JSON.parse(body, symbolize_names: true) first.
Try it in irb:
handle({ type: "charge.succeeded", data: { amount: 2000, currency: "usd" } })
# => "charged 2000 usd"
handle({ type: "ping" })
# => "pong"Guards, pins, and custom objects
Three features cover most real-world filtering needs:
- Guards add a condition to an arm:
in { amount: } if amount > 10_000only matches large charges. - The pin operator
^matches against an already-bound variable instead of rebinding:in { status: ^expected_status }. - Custom classes opt in by defining
deconstruct(for array patterns) ordeconstruct_keys(for hash patterns). AMoneyclass that returns{ cents:, currency: }fromdeconstruct_keyscan be matched directly:in Money[cents:, currency:].
Trade-offs worth knowing
Pattern matching is not free. A few honest caveats:
- Exhaustiveness is on you. A
case/inwith no matching arm raisesNoMatchingPatternErrorat runtime. Always include anelseor wildcard arm (in _) for shapes you don't recognize — verify by feeding your handler an unexpected payload and confirming it hits the fallback rather than raising. - Binding shadows locals. Patterns create local variables, so reusing a name that matters outside the
casewill silently shadow it. Prefer short, scoped names. - Volatile schemas hurt. If the upstream payload changes weekly and is undocumented, a strict pattern can be more brittle than a tolerant
dig. Patterns shine when shapes are few and known. - Version semantics shifted. One-line pattern matching changed around Ruby 3.0:
value in patternreturns a boolean, whilevalue => patternbinds or raises. Older blog examples may use the experimental 2.7 syntax, which also emits warnings. Confirm behavior in your target Ruby before copying snippets.
Where to start
Pick one place in your codebase where you destructure a nested hash with three or more conditionals — a webhook handler, a JSON-RPC dispatcher, a config parser — and rewrite it as case/in. Add a wildcard arm, then run the handler against a recorded sample of real payloads, including one malformed one. If the malformed input lands in your fallback instead of raising, you have the pattern right. Keep the patterns shallow and readable; the moment a single arm needs its own comment to parse, it has gone too far.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.