Compilers

Pratt Parsing: Operator-Precedence Parsing with Binding Power

In about 40 lines of code, a Pratt parser can handle prefix, infix, postfix, ternary, and function-call operators with arbitrary precedence and associativity — something that would take a sprawling, mutually-recursive grammar of a dozen functions in classic recursive descent. Pratt parsing (also called top-down operator-precedence parsing) is a technique for parsing expressions in which each token carries its own parsing behavior and a numeric binding power that decides how tightly it grabs the operands to its left and right.

Invented by Vaughan Pratt and published in his 1973 POPL paper "Top Down Operator Precedence," the method replaces per-precedence-level grammar rules with a single loop driven by binding powers. It runs in O(n) time on a token stream of length n, is trivially extensible to new operators at runtime, and is the parsing core inside tools like Douglas Crockford's JSLint, the Go language's early expression parser, and JetBrains' language plugins.

  • TypeTop-down expression parsing algorithm
  • InventedVaughan Pratt, 1973 (POPL)
  • Time complexityO(n) in tokens
  • SpaceO(d) stack, d = max nesting depth
  • Key ideaPer-token binding power + nud/led handlers
  • Used inJSLint, Go, Clang-ish frontends, IDE parsers

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

The problem Pratt parsing solves

Parsing arithmetic like 1 + 2 * 3 - 4 requires respecting precedence (* binds tighter than +) and associativity (- is left-associative, ^ usually right-associative). The textbook recursive-descent answer is to write one function per precedence level — parseExpr → parseTerm → parseFactor → parsePrimary — chained by calls. Every new precedence level adds another mutually recursive function, and prefix (-x), postfix (x!), ternary (a ? b : c), and mixfix (a[b], f(args)) operators each need special-casing.

Pratt's insight was to attach the parsing logic to the tokens rather than to grammar productions. Each token type gets:

  • a nud (null denotation): what to do when the token appears with nothing to its left — literals, prefix operators, parentheses;
  • a led (left denotation): what to do when it appears with an expression to its left — infix and postfix operators;
  • a binding power (a.k.a. left binding power, lbp): how strongly it pulls on the expression to its left.

Precedence becomes data — a number — not control flow. One loop replaces the ladder of functions.

How it works: the binding-power loop

The heart is a single function, conventionally parseExpression(minBp), that takes the minimum binding power it is willing to accept. It first consumes one token as a prefix (its nud), then loops: while the next token's left binding power exceeds minBp, it consumes that operator and lets its led combine the left subtree with a recursively parsed right operand.

fn parseExpr(minBp):
    left = nud(next_token())        # literal or prefix op
    loop:
        op = peek()
        if lbp(op) <= minBp: break
        advance()
        left = led(op, left)         # led recurses with op's rbp
    return left

# For a binary op with precedence p:
#   led(op, left): right = parseExpr(rbp); return (op left right)
#   left-assoc  -> rbp = p        (equal-prec op won't re-enter)
#   right-assoc -> rbp = p - 1    (equal-prec op re-enters)

Associativity falls out of the right binding power. For a left-associative operator, set its right binding power equal to its precedence so that another operator of equal precedence fails the lbp > minBp test and is left for the outer loop. For a right-associative operator (like ^), lower the right binding power by one so the recursion re-enters and nests to the right. Prefix operators simply call parseExpr with their own high binding power in their nud.

Complexity and a worked step trace

Time: every token is consumed exactly once as either a nud or a led, and each does O(1) work plus at most one recursive call, so the whole parse is O(n) in the number of tokens. Space: the only extra memory is the call stack, bounded by the maximum operator-nesting depth d, giving O(d) worst case (O(n) for pathological deeply-nested input like ((((…))))).

Trace 1 + 2 * 3 with binding powers + = 10, * = 20, starting parseExpr(0):

parseExpr(0):
  left = 1                     # nud(1)
  peek '+', lbp 10 > 0  ->  led('+')
     right = parseExpr(10):    # + is left-assoc, rbp = 10
        left = 2
        peek '*', lbp 20 > 10 -> led('*')
           right = parseExpr(20):
              left = 3
              peek EOF, lbp 0 -> stop; return 3
           left = (* 2 3)
        peek EOF -> stop; return (* 2 3)
     left = (+ 1 (* 2 3))
  peek EOF -> stop
result: (+ 1 (* 2 3))

The * node captured the 3 because parseExpr(10) allowed operators with lbp > 10, but the outer parseExpr(0) would not have let a second + steal the 2 — precedence correctly realized as a numeric comparison.

Where it's used in real systems

Pratt parsing is the go-to when you control the grammar and want a hand-written, debuggable, fast expression parser:

  • JSLint / JSHint — Douglas Crockford popularized the technique in modern practice; his JavaScript tooling parses expressions with a Pratt core, and his essay "Top Down Operator Precedence" reintroduced it to a generation of engineers.
  • The Go compiler — early gc and later hand-written frontends use precedence-based expression parsing closely related to Pratt for the operator grammar.
  • IDEs and language servers — JetBrains' plugin framework and many Language Server Protocol implementations favor Pratt-style parsers because error recovery and incremental reparsing are easier in hand-written top-down code.
  • Interpreters and DSLsCrafting Interpreters (Nystrom) builds the Lox expression parser with Pratt; countless calculator, query, and template engines embed one. Databases parsing SQL WHERE expressions and spreadsheet formula engines use the same pattern.

Its appeal in production is pragmatic: adding a new operator is a one-line handler registration, and because the parser is ordinary code you can breakpoint it, emit precise diagnostics, and recover from syntax errors far more gracefully than a generated LR table permits.

Comparison to alternatives and the tradeoff

Pratt parsing occupies a sweet spot between hand-written recursive descent and table-driven generators. Precedence climbing (Clarke, Richards) is essentially the binary-operator subset of Pratt: the minBp argument is the same idea, and for pure infix grammars the two are equivalent — precedence climbing is arguably even more compact but doesn't cleanly generalize to prefix/postfix/mixfix the way nud/led do.

Shunting-yard (Dijkstra, 1961) achieves the same O(n) result iteratively with two explicit stacks, avoiding recursion, but it is awkward for unary and mixfix operators and produces RPN/postfix rather than a tree directly. LR parsers (yacc/bison) handle vastly larger grammar classes and resolve precedence via declarations, but shift/reduce conflicts are opaque and error recovery is notoriously hard to customize.

The core tradeoff: Pratt trades the formal grammar guarantees of a generator for readability, extensibility, and control. You get O(n) parsing and effortless operator addition, but you must reason about binding powers by hand and you don't get an automated proof that your grammar is unambiguous.

Pitfalls, failure modes, and significance

Common ways Pratt parsers go wrong:

  • Off-by-one associativity bugs — forgetting to subtract 1 from the right binding power of a right-associative operator (or accidentally doing so for a left one) silently flips 2^3^2 from 2^(3^2) to (2^3)^2. This passes many tests because both sides look plausible.
  • Binding-power collisions — two unrelated operators sharing a level can produce surprising parses; keeping a single ordered enum of precedences prevents drift.
  • Unary vs. binary minus- needs both a nud (prefix negation, high bp) and a led (subtraction, lower bp); conflating them mis-parses a - -b.
  • Deep recursion / stack overflow — adversarial input with thousands of nested parentheses or unary operators can blow the native stack; production parsers add a depth limit.

Its significance is durability: a 1973 algorithm still ships in modern compilers, linters, and IDEs precisely because it hits the practical trifecta of linear time, tiny code, and painless extension. Pratt himself remarked that operator precedence deserved to be understood as data about tokens rather than a special grammar theory — a perspective that quietly underlies how most real expression parsers are written today.

Pratt parsing vs. common expression-parsing alternatives
TechniqueTimeHow precedence is expressedExtensibility / notes
Pratt / TDOPO(n)Numeric binding power per token (nud/led)Add an operator = add a handler; runtime-extensible
Recursive descent (per-level)O(n)One grammar function per precedence levelN levels = N mutually recursive functions; verbose
Precedence climbingO(n)min-precedence argument threaded through recursionEssentially Pratt for binary operators; very compact
Shunting-yard (Dijkstra)O(n)Explicit operator + output stacksIterative, no recursion; awkward for prefix/mixfix
LR / yacc / bisonO(n)Precedence declarations resolve shift/reduceTable-driven, powerful, but opaque conflicts
Packrat (PEG)O(n) w/ memoOrdered choice, no left recursion nativelyO(n) memory; precedence must be encoded by hand

Frequently asked questions

What is binding power in a Pratt parser?

Binding power is a number assigned to each token that measures how strongly it attracts operands. The left binding power (lbp) decides whether an operator grabs the expression to its left; the right binding power (rbp), used when recursing for the right operand, controls associativity. Higher binding power means higher precedence, so precedence is expressed as numeric comparison rather than grammar structure.

What do nud and led mean?

They are the two handlers a token can have. 'nud' (null denotation) fires when the token has nothing to its left — literals, prefix operators like -x, and opening parentheses. 'led' (left denotation) fires when the token has an already-parsed expression to its left — infix operators like + and postfix operators like x!. Together they let one token type behave differently by position.

How does Pratt parsing handle associativity?

Through the right binding power passed into the recursive parseExpression call inside led. For a left-associative operator, set its right binding power equal to its precedence, so an equal-precedence operator won't re-enter the recursion and is handled by the outer loop. For a right-associative operator like exponentiation, set right binding power to precedence minus one, so equal-precedence operators nest to the right.

Is Pratt parsing the same as precedence climbing?

They are very closely related and produce the same results for binary-operator grammars — the min-precedence argument in precedence climbing is the same mechanism as Pratt's minBp. The difference is generality: Pratt's nud/led framework naturally extends to prefix, postfix, ternary, and mixfix operators, while classic precedence climbing is framed mainly for infix binary operators.

What is the time and space complexity of Pratt parsing?

Time is O(n) in the number of tokens: each token is consumed exactly once as a nud or led with O(1) local work. Space is O(d) where d is the maximum operator-nesting depth, because the only extra memory is the recursion stack. Pathologically nested input (deeply parenthesized expressions) makes d approach n, so worst-case space is O(n).

When should I use Pratt parsing over yacc/bison or a PEG?

Use Pratt when you hand-write a compiler, interpreter, linter, or DSL and want a fast, readable, easily extended expression parser with good error messages. Prefer LR generators (yacc/bison) when you need to parse a large, formally specified grammar and want conflict detection. PEG/packrat suits ordered-choice grammars but needs O(n) memoization memory and doesn't express precedence as cleanly.