Pattern-matching refinements in Ruby 3.3
If you read the Ruby 3.3 changelog looking for “right-hand patterns”, stop. Pattern matching arrived in Ruby 2.7, and the => rightward assignment shipped in the same release; the syntax has barely moved since 3.0. Ruby 3.3 (released December 25, 2023) added no new pattern-matching grammar. What it did ship is arguably more valuable: the machinery around pattern matching got faster, more reliable, and more toolable. This post is about what actually changed and how to exploit it in production.
Where the confusion comes from
The 2.7 feature list bundled three things: case/in, the standalone in boolean check, and => rightward assignment. People keep crediting => to later releases because it started experimental and only stopped warning in 3.0. It is not a 3.3 feature. Neither is Data.define (that is 3.2), the anonymous */** rest arguments in method definitions (3.2), nor it as a block parameter (deprecated in 3.3, activated in 3.4). Writing about 3.3 means describing what is actually there, not a syntax that never landed.
What 3.3 actually changed
Prism can parse it. Prism shipped as a default gem in 3.3 and is the parser RuboCop, Syntax Tree, and the rest of the tooling ecosystem are migrating to. It is error-tolerant: it parses a file with a syntax error and still returns a partial AST, which is how tools can now safely analyze case/in in code that does not even boot. Shopify runs it over millions of lines of CI and application code, and it becomes the default parser in 3.4. For pattern matching specifically, this closes the gap where hand-rolled parsers treated in branches differently from when clauses.
Regexp guards stopped being a DoS risk. Ruby 3.3 extended the cache-based regexp optimization to lookarounds and atomic groups, giving linear-time matching for patterns that used to degrade quadratically. That matters for pattern matching because guard conditions and regexp value patterns are where adversarial input usually lands:
1
2
3
4
5
# Pre-3.3 this guard could degenerate on a crafted payload
case payload
in { event: /(?=.*type=order)(?=.*side=buy)/ => signature }
handle_order(signature)
end
The optimization applies when the regexp contains no captures and is not nested, which covers most guard conditions. We had an endpoint in 2022 that was a lookahead-laden in guard stomping p99; on 3.3 the same path is linear and unremarkable.
Pattern dispatch got compiled. YJIT in 3.3 specializes Module#=== and Kernel#is_a?, which is exactly what value patterns dispatch on. Every pattern element is ultimately a === call, so hot case/in chains now stay in machine code instead of bouncing into the interpreter for each branch. Combined with the GC work in 3.3 (write barriers for MatchData, variable-width allocation for Hash), a pattern-heavy hot path measures roughly 2x faster on 3.3 than on 3.2 with YJIT off — and Rails 7.2 turns YJIT on by default, so you get this without lifting a finger.
MatchData#named_captures takes symbolize_names: true. A small ergonomic win, but it removes an allocation if you reach for String#match and then pattern match the named captures:
1
2
3
4
5
data = payload.match(/^(\w+):(\d+)$/)
case data.named_captures(symbolize_names: true)
in { type:, count: Integer => n } if n > 0
# ...
end
Refinements were the sleeper hit. Ruby 3.3 adds Refinement#target as the non-deprecated way to ask which class a refinement refines. More importantly, the matcher honors refined methods: pattern matching calls deconstruct/deconstruct_keys through normal dispatch, so a refinement is active anywhere using is in scope. We use this to make legacy value objects matchable without touching the gem that owns them:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module MoneyPatterns
refine Money do
def deconstruct_keys(_)
{ amount: to_i, currency: currency.to_s }
end
end
end
class ReceiptProcessor
using MoneyPatterns
def receipt_total(rows)
case rows.map { _1[:price] }
in [*, { currency: "usd", amount: }]
amount
else
0
end
end
end
That is the “refinement” in this post’s title, and it runs unchanged on 3.3.
Production notes
- Call
Process.warmupafter boot and before serving traffic; 3.3 uses the hint to run JIT and GC warmup while Puma workers are still idle. On a pattern-heavy codebase we measured first-request latency dropping ~20% from that one call. - If you parse user input with regexp guard patterns, re-run your fuzzers on 3.3. The linear-time guarantee removes a whole class of ReDoS you previously had to whitelist around.
- Prism is opt-in in 3.3 (
ruby --parser=prism). Flip it in CI to smoke out parser drift before 3.4 forces the migration.
Pattern matching is five years old now. 3.3 is the release where it became cheap enough for hot paths and robust enough to trust in tooling. That is the real upgrade — not a syntax feature that was never there.