Loweringtypical

Compiling Pattern Matching

An ordered list of match arms is semantics, not implementation. The compiler turns it into a decision tree that tests each discriminant once — which is why a match is not a chain of comparisons, and why the naive reading of it is quadratic in the wrong place.

The question

Does a match really test every arm in order until one fits, and if not, what does it actually generate?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Before: a *pattern matrix* — rows are arms, columns are the sub-values of the scrutinee being examined, cells are patterns. After: a decision tree whose internal nodes are single tests on one column (a tag switch, a literal comparison, a range check) and whose leaves are arm bodies. The matrix exists to make the choice of *which column to test next* an explicit, optimisable decision; the tree exists so that no value is examined twice on any path.

What this phase may assume or do

The tree must select the first arm, in source order, whose pattern accepts the value — that is the observable semantics and the tree is free in every other respect. It may test columns in any order, because testing a discriminant has no side effects. It may not reorder guard evaluation relative to the arms whose guards could run, since a guard is arbitrary code: an arm with a guard may only be entered after every earlier arm has been tried, and if the guard fails, matching must continue as though that arm had not matched. Tests on a column may only be hoisted above a test that could have failed if the value is known to be well-typed there — which is what the checker guaranteed.

Key points

  • Arm order is semantics; testing arms in order is not the implementation. A decision tree tests each discriminant once per path.
  • The compiler works over a pattern matrix — arms as rows, sub-value positions as columns — and recursively picks a column to test.
  • A wildcard row must be duplicated into every constructor branch, which is the principal source of tree growth.
  • Column-selection heuristics exist to minimise that duplication, and different compilers make measurably different choices.
  • Guards cannot be reordered or hoisted, so a match made mostly of guards compiles to roughly the if-chain it resembles.
  • Dense tag columns become jump tables; sparse ones become comparison trees or bit tests.
  • Implementations bound code growth by sharing subtrees and falling back to a backtracking automaton past a threshold.
  • The same matrix algorithm produces the exhaustiveness result, the reachability warning and the decision tree.

The naive reading, and why it is not what happens

Read a match literally and you get: try arm one; if it does not fit, try arm two; and so on. Compile it literally and you get a chain of tests in which the same discriminant is loaded and compared over and over. For a five-arm match on an enum, the last arm costs five loads and five comparisons, and the shape is linear in the number of arms.

That is not what any production compiler generates, and the reason is that testing a tag is *pure*. Nothing is observable about having compared a discriminant against a value, so the compiler is free to test it once and branch on the result — a jump table, or a balanced comparison tree, or a bit test, depending on density. What was linear becomes one test.

Nested patterns are where it gets interesting. Some(Ok(x)) versus Some(Err(e)) versus None looks like three independent tests of the whole value; a decision tree tests the outer Option tag once, and only inside the Some branch tests the inner Result tag. Each discriminant is examined exactly once on any path from root to leaf, which is the property the whole algorithm exists to establish.

Three arms, and the two ways to compile them
1match value {
2 Some(Ok(x)) => a(x),
3 Some(Err(e)) => b(e),
4 None => c(),
5}
6
7// Naive, arm by arm:
8// load value.tag; cmp Some; if ne -> arm2
9// load value.payload.tag; cmp Ok; if ne -> arm2
10// -> a(x)
11// arm2:
12// load value.tag; cmp Some; <-- SAME TEST AGAIN
13// load value.payload.tag; cmp Err; <-- and again
14// -> b(e)
15// arm3:
16// load value.tag; cmp None; <-- and a third time
17
18// Decision tree:
19// load value.tag
20// switch: None -> c()
21// Some -> load value.payload.tag
22// switch: Ok -> a(x)
23// Err -> b(e)

Count the loads of value.tag: three in the naive version and one in the tree. The tree also happens to be shorter, but the reason to build it is that no value is examined twice, which is what makes matching on deeply nested data affordable.

The matrix, and choosing a column

simplifiedThe real algorithm operates on a matrix whose width changes as constructors are expanded, handles or-patterns by row duplication, and carries a separate occurrence vector naming the sub-value each column refers to. This table shows one step of one column choice on three rows. What it does capture faithfully is the two rules that matter: a wildcard row must be copied into every branch, and selecting a constructor replaces its column with columns for its payload.

The standard algorithm — Maranget's, which is what most modern implementations use in some form — treats the arms as a matrix. Each row is an arm; each column is a position within the scrutinee. Compilation is recursive: pick a column, split the matrix into one sub-matrix per constructor that appears in it, recurse on each, and emit a test that branches between them.

Two rules make it terminate and stay correct. A row whose pattern in the chosen column is a wildcard belongs in *every* sub-matrix, because a wildcard accepts every constructor. And when a constructor is selected, the row expands: Some(p) in that column is replaced by p in a new column for the payload. The matrix therefore changes shape as the tree is built, which is why the algorithm is written over matrices rather than over the source arms.

Which column to pick first is the whole art. Picking left to right is correct and often bad. The standard heuristics prefer a column with no wildcards — because a wildcard row must be duplicated into every branch, and duplication is how the tree explodes — and among those, the column with the fewest distinct constructors, or the one whose test is cheapest. Different compilers use different heuristic stacks and get measurably different code from the same source.

This is also where the second implementation strategy appears. A *backtracking automaton* keeps the source order literally and re-tests on failure: smaller code, potentially repeated tests. A decision tree tests each column once and can duplicate rows: faster code, potentially much larger. Neither dominates, and some compilers switch between them based on the size of the result.

The pattern matrix for the three arms above, before any column is chosensimplified
ArmColumn 1 (the scrutinee)After choosing column 1 = SomeBody
1Some(Ok(x))row survives, becomes Ok(x) on the payload columna(x)
2Some(Err(e))row survives, becomes Err(e) on the payload columnb(e)
3Nonerow does not survive — it goes to the None branch insteadc()

The tree, drawn

The tree below is what the three arms compile to. Read it as a program: each internal node is one test on one sub-value, each edge is an outcome of that test, and each leaf is an arm body. Every path from root to leaf tests value.tag exactly once and tests value.payload.tag at most once.

Notice that the tree has no notion of arm order. Order is baked in during construction — when two rows could both accept a value, the earlier one wins and the later one is simply absent from that leaf — so the generated code contains no evidence of the source ordering at all. That is why the semantic rule ("first matching arm") and the implementation ("test each discriminant once") are not in tension: the ordering is resolved at compile time.

And notice what the leaves cost: nothing. Once a leaf is reached, the arm body runs with its bindings already computed as projections of the scrutinee — x is value.payload.payload, known statically at that leaf. No search, no re-test, no dispatch.

The decision tree for the three-arm match
AST — only what later phases match on
test value.tag— Loaded once. A jump table if the tag is dense, a comparison otherwise.
├── tag = None -> leaf— No further test needed: None has no payload.
│ └── arm 3: c()— Leaf. Nothing left to test.
└── tag = Some: test value.payload.tag— Reached only inside the Some branch, so the outer tag is never re-examined.
├── inner = Ok -> arm 1: a(x)— x is value.payload.payload, a static projection.
└── inner = Err -> arm 2: b(e)— e is value.payload.payload, at a different type.

Read it asThree leaves, two tests, and no path examines a discriminant twice. Compare this with the naive listing in the previous section, where value.tag was loaded three times. The exhaustiveness question — is there a value with no leaf? — is answered by the same matrix machinery during checking, which is why [[exhaustiveness-checking]] and this compilation share an algorithm and are nonetheless separate concerns: one produces a witness, the other produces code.

Guards, or-patterns, and the ways the tree gets big

A guard is the one thing that cannot be moved. It is arbitrary code with arbitrary effects, so it may only run once every earlier arm has been ruled out, and if it evaluates false, matching must continue exactly as if the arm had not matched. In tree terms a guarded leaf is not a leaf: it is a test whose false edge must rejoin the search at the right place, which usually means duplicating the remaining decision tree or falling back to a shared failure block. This is why a match whose arms are all guards compiles to something very close to the if-chain it resembles — the compiler has no freedom left.

Or-patterns and wildcards are the other source of growth. A row with a wildcard in the chosen column belongs in every sub-matrix, so it is duplicated once per constructor; do that at several levels and the tree size can grow exponentially in the depth of the patterns. Real implementations bound this: they share identical subtrees (the tree becomes a DAG), they fall back to a backtracking automaton past a size threshold, and their column heuristics exist largely to avoid the duplication in the first place.

The practical consequence is that match compilation has a *size* failure mode as well as a speed one, and the two pull against each other. A compiler that always built the ideal decision tree would occasionally emit enormous functions; one that always backtracked would emit small, slow code. Which you get for a given match is a heuristic outcome, which is why it is worth looking at the generated code for a match that sits in a hot loop rather than assuming.

  • A guard forces the tree to preserve a failure path back into the remaining search, removing most of the compiler's freedom.
  • A wildcard row is duplicated into every constructor branch, which is the main driver of tree growth.
  • Or-patterns duplicate rows; identical subtrees are usually shared so the tree becomes a DAG rather than a tree.
  • Dense integer or tag columns compile to a jump table; sparse ones to a balanced comparison tree or a bit test.
  • Past a size threshold implementations fall back to a backtracking automaton, trading speed for code size.

Why this is a separate lesson from the language feature

The surface half of matching — what patterns can express, what a guard is, why guards do not count toward coverage — is [[pattern-matching]], and the proof that no case is missing is [[exhaustiveness-checking]]. Both are frontend concerns and both are about what the checker can conclude.

This lesson is the point where that ordered list of arms stops being a specification and becomes code, and it belongs with closures and coroutines because it is the same kind of move: a construct the source treats as primitive is rewritten into tests and branches the back end already knows. The interesting content is entirely in what the rewrite is free to change (test order, sharing, representation) and what it must not (arm order, guard order, evaluation of guards).

It also shares an algorithm with its neighbour, which is the tidiest fact in this part of the domain. The usefulness analysis that decides whether an arm is reachable, and the exhaustiveness check that decides whether a case is missing, are the same matrix computation run for a different purpose. One compiler component, three outputs: a warning about an unreachable arm, an error naming an uncovered value, and a decision tree.

How it works

The steps, in the order the compiler takes them.

  • Build a matrix whose rows are arms and whose columns are positions within the scrutinee, together with an occurrence vector naming which sub-value each column refers to.
  • If the first row is all wildcards, emit its body as a leaf — every value reaching here matches it.
  • Otherwise choose a column using a heuristic: prefer columns with no wildcards, then fewest distinct constructors, then cheapest test.
  • Partition the rows by the constructor appearing in that column, adding every wildcard row to every partition.
  • Within a partition, replace the chosen column with one column per field of that constructor, extending the occurrence vector with the corresponding projections.
  • Recurse on each partition, and emit a test on the chosen column that branches to the resulting subtrees; add a default edge for constructors with no rows, which is where a non-exhaustive match reports its failure.
  • Emit a guarded arm as a leaf that evaluates the guard and, on failure, branches into the subtree that would have been taken had the arm not matched.
  • Share structurally identical subtrees so the result is a DAG, and abandon the tree for a backtracking automaton if the node count passes the implementation's threshold.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • Compile time and binary size blow up on a match with deeply nested patterns and several wildcards, because row duplication grew the tree exponentially and no sharing applied.
  • A guard with a side effect runs a different number of times after a compiler upgrade changed the column heuristic, and a counter or a log line drifts with no source change.
  • A match that looks like a jump table generates a chain of comparisons instead, because the tag values were sparse and the compiler declined the table — the symptom is a branch-heavy profile in code that reads as a switch.
  • A hot match is slower than the equivalent hand-written switch, because the patterns forced a backtracking fallback that the source gives no hint of.
  • An arm is silently unreachable — subsumed by an earlier one — and the code in it never runs; without the reachability warning enabled, nothing indicates this.
  • A non-exhaustive match reaches its default edge at run time and panics in production on an input shape that no test produced.

When it helps

  • Dispatch over a closed sum type, which is the core loop of every interpreter, parser, protocol handler and state machine.
  • Deeply nested data, where a decision tree is dramatically better than repeated independent tests of the same value.
  • Any place a hand-written chain of type tests exists — the tree is what that chain should have compiled to, without the repeated loads.
  • Making all cases visible in one construct while still generating the dense dispatch a hand-written switch would give.

When it hurts

  • When most arms carry guards, since guards remove the freedom the algorithm depends on and the result is close to an if-chain.
  • When patterns nest deeply and mix wildcards at several levels, where row duplication makes the tree large enough to hurt instruction cache and compile time.
  • When the scrutinee's tags are sparse, so no jump table applies and the dispatch becomes a comparison tree with unpredictable branches.
  • When the match is over an open set of types rather than a closed sum, where the tree degenerates into a sequence of type tests and virtual dispatch would have been better.

What it costs

Every one of these is paid by something.

  • A decision tree buys at most one test per discriminant on any path, and pays in code size through row duplication — the growth is worst-case exponential in pattern depth and is bounded only by heuristics and sharing.
  • A backtracking automaton buys compact code proportional to the source and pays repeated tests of the same value at run time, which is exactly the cost the tree exists to remove.
  • Better column heuristics buy smaller and faster trees and pay in compile time plus a real loss of predictability: the generated code for an unchanged match can change between compiler versions.
  • Sharing identical subtrees buys back much of the duplication and pays in debug information, since one block of code now corresponds to several source arms and a debugger cannot attribute it to one.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Compile arms literally in order, testing each in turn. Simple, obviously correct, and it re-tests the same discriminant once per arm — which is what a hand-written type-test chain does and what the tree exists to beat.
  • Virtual dispatch: put a method on each variant. One indirect call, no tests at all, no code growth — and the operation is spread across the type hierarchy instead of visible in one place, and it does not handle nested patterns at all.
  • A hash or jump table on the tag alone, ignoring nesting, with the remaining pattern tested inside each branch. Simple and effective for shallow matches, which are the majority.
  • A backtracking automaton throughout, which several implementations use as the fallback and some use as the default when code size is the binding constraint.

See it for yourself

The flag, dump or tool that shows you this directly.

  • Rust: rustc --emit=mir shows the match as SwitchInt terminators over discriminants — count them and compare with the number of arms. -Z print-mono-items and --emit=llvm-ir show what the tag switch became.
  • Haskell: ghc -ddump-simpl shows nested case expressions, which are the decision tree written in Core; the nesting is the tree structure directly.
  • OCaml: ocamlopt -dlambda prints the lambda form after match compilation, where the switch structure and any duplicated subtrees are visible.
  • Any target: compile a match on a dense enum with -O2 and look for a jump table in the assembly (jmp *%rax off a table on x86-64); then make the tag values sparse and watch it become a comparison chain.
  • Our decision tree viewer at /compilers/lowering builds the tree from a pattern matrix step by step and shows which column each heuristic would pick.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "A match tests every arm until one matches." That is the semantics. The generated code tests each discriminant once and the arm ordering is resolved at compile time.
  • "Matching is slower than a switch." A match on a dense tag compiles to the same jump table a switch does. It is slower when guards or sparse tags remove the compiler's options.
  • "Adding an arm adds a test." It usually adds a case to an existing switch. Adding a *wildcard* arm at a nested position can add a whole duplicated subtree.
  • "The compiler could just evaluate guards early and get a better tree." It cannot: a guard is arbitrary code, and running it before an earlier arm has been ruled out changes the program.
  • "Exhaustiveness checking is what makes match compilation work." They share an algorithm and answer different questions. A language can compile matches perfectly well with no exhaustiveness check at all, which is what Python does.

Misconceptions

The claim, and what is actually true.

The decision tree is an optimization the compiler might skip.
It is the compilation strategy. The alternative is a backtracking automaton, also a real strategy; testing arms literally in order is what neither of them does.
Deeper patterns cost more at run time.
Deeper patterns cost more at compile time and in code size. At run time a path through the tree tests each discriminant it needs exactly once, however deep.
Exhaustiveness checking and match compilation are the same pass.
They share the matrix algorithm and run for different reasons — one produces a witness for a diagnostic, the other produces branches.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

A match reads as "try each case in order", but the compiler does something better. It notices that checking which variant a value is has no side effects, so it can check once and jump straight to the right case — like a switch, but working through nested layers too. The result is a small tree of tests where no part of the value is examined twice.

practical

Two things follow for real code. Guards are the expensive thing, not the patterns: a match whose arms are mostly guarded compiles to roughly the if-chain it looks like, because guards are arbitrary code the compiler cannot move. And if a match is in a hot loop and looks slower than it should, check the generated code before theorising — the two common causes are sparse tag values that prevented a jump table and pattern shapes that pushed the compiler into its compact-but-slow fallback. Neither is visible in the source.

advanced

The genuinely interesting property is that this algorithm is doing constructive proof search, and it produces three artifacts from one computation. Run the matrix recursion for code and you get a decision tree. Run the same usefulness relation asking whether a row accepts anything no earlier row accepts, and you get reachability warnings. Run it asking whether any value is accepted by no row, and you get an exhaustiveness counterexample — a concrete witness value the compiler can print. That unification is why languages with good exhaustiveness diagnostics also tend to compile matches well: the investment is in one algorithm. The tension inside it is a familiar one — the tree is optimal in tests and worst-case exponential in size, so every implementation is really a size-versus-speed heuristic wearing an algorithm's name, and the honest summary is that the quality of a match compiler is the quality of its heuristics rather than of its algorithm.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

typicalMainstream implementations of match compilation derive from Maranget's algorithm and share the matrix formulation, but the column-selection heuristics, the subtree-sharing strategy and the threshold at which they abandon the tree for a backtracking automaton are all implementation choices. rustc, GHC and the OCaml compiler produce measurably different code for the same patterns, and the same compiler produces different code across versions, so a measured instruction count for a match is a measurement of one build.
targetWhether a tag switch becomes a jump table depends on tag density and on the target: a jump table costs an indirect branch, which is cheap where the branch predictor handles indirect targets well and expensive where it does not. Compilers apply a density threshold — a sparse enum becomes a comparison tree instead — and the threshold and the resulting instruction sequence differ between x86-64 and AArch64.
specThat arms are selected in source order, and that a failed guard resumes matching as though the arm had not matched, is specified in Rust, OCaml, Haskell and Scala alike. What is not specified anywhere is the order in which the underlying discriminants are tested, which is precisely what gives the compiler its freedom — and precisely why a side-effecting guard is unreliable.

If you were asked this in an interview

  • Does a match test its arms in order? Explain what the compiler actually generates.
  • Why can the compiler reorder discriminant tests but not guard evaluations?
  • What makes the generated code for a match grow, and what do implementations do about it?

Connections