Posted in Engineering

Parsing Explained: Recursive Descent vs Pratt Parsing for Hand-Written Parsers

Every compiler, interpreter, configuration-file reader, or query-language engine starts with the same job: turning a flat stream of tokens into a structured tree the rest of the program can walk. That job is parsing, and for hand-written parsers, two techniques dominate: recursive descent, which maps each grammar rule directly onto a function, and Pratt parsing, a specialized technique for expressions that handles operator precedence without a function explosion. Recursive descent is simple and works beautifully for most of a language’s grammar — statements, declarations, blocks — but it gets awkward exactly where arithmetic-style expressions live, because precedence and associativity don’t map cleanly onto “one function per rule.” Pratt parsing exists specifically to fix that. This explainer covers how each technique works, where recursive descent breaks down, and how Pratt parsing’s precedence-driven loop solves the same problem with far less code.

At a Glance

Recursive descentOne parsing function per grammar rule, calling each other top-down
Pratt parsingOne generic expression-parsing loop driven by per-token precedence (binding power)
Best suited forRecursive descent: statements/structure. Pratt: expressions with operators
Precedence handlingRecursive descent: a separate function per precedence level. Pratt: a precedence number per operator
Adding a new operatorRecursive descent: insert/renumber grammar functions. Pratt: register one new table entry
OriginPratt parsing comes from Vaughan Pratt’s 1973 paper “Top Down Operator Precedence”

What Problem This Solves

Parsing takes the token stream produced by a lexer (e.g. NUM(1), OP(+), NUM(2), OP(*), NUM(3) for the text 1 + 2 * 3) and builds an abstract syntax tree that reflects the language’s grammar rules — in particular, which operations bind more tightly than others. A parser that got this wrong would parse 1 + 2 * 3 as (1 + 2) * 3 instead of the mathematically correct 1 + (2 * 3). Both recursive descent and Pratt parsing are top-down parsing strategies — they start from the whole expression and work down into its parts — but they differ sharply in how they encode precedence, which is the crux of the problem this explainer is about.

How Recursive Descent Works

Recursive descent translates a grammar almost mechanically: each production rule becomes a function, and each function calls the functions for the rules it references. A grammar rule like statement -> if_statement | while_statement | expr_statement becomes a parse_statement() function that peeks at the next token and dispatches to parse_if(), parse_while(), or parse_expr_statement(). This is simple to write, simple to debug (a stack trace literally shows you the grammar path taken), and it maps cleanly onto most of a real language’s syntax — declarations, blocks, control flow. It struggles specifically with left-recursive rules (a rule that refers to itself as its own first symbol, which causes infinite recursion in a naive top-down implementation) and, most relevantly here, with operator precedence in expressions.

Why Operator Precedence Breaks Naive Recursive Descent

The standard recursive-descent fix for precedence is to write one function per precedence level, each calling the next tighter-binding level before trying to consume an operator at its own level. For a language with just +, -, *, and /, that’s already parse_additive() calling parse_multiplicative() calling parse_unary() calling parse_primary(). Real languages have far more precedence levels than that — in his widely cited article on the subject, developer Bob Nystrom points out that JavaScript’s expression grammar has roughly seventeen distinct precedence levels. Written the naive recursive-descent way, that means roughly seventeen near-identical functions, each differing only in which operator tokens it checks for and which function it calls next. Associativity (does a - b - c group as (a - b) - c or a - (b - c)?) has to be hand-coded into each of those functions separately, and adding a new operator at a new precedence level means inserting a new function into the chain and renumbering everything below it.

What Pratt Parsing Does Differently

Pratt parsing — sometimes called “top-down operator precedence parsing,” after Vaughan Pratt’s original 1973 title — replaces that chain of functions with a single loop and a table. Every token type that can start an expression (a number, a variable, a unary -, an opening parenthesis) gets a prefix handler. Every token type that can appear after an expression as an operator (+, *, etc.) gets an infix handler and a precedence number, sometimes called its “binding power.” The core function, often called parse_expression(min_precedence), works like this: parse one prefix expression to get a starting value, then repeatedly look at the next operator — as long as its precedence is at least as high as the minimum precedence the caller asked for, consume it, recursively parse its right-hand side (passing in that operator’s own precedence, which is what produces correct left-associativity), and fold the result into the running expression. Precedence is no longer encoded as “which function am I in” — it’s a plain number compared at one single point in the code, so adding a new operator means adding one entry to a table, not a new function in a chain.

Recursive Descent vs Pratt Parsing

AspectRecursive Descent (naive)Pratt Parsing
Functions needed for N precedence levelsN separate functions1 function + N table entries
Adding a new operatorInsert a function, renumber the chainRegister one prefix/infix handler with a precedence number
AssociativityHand-coded per level (loop vs recursion choice)Controlled by passing precedence vs precedence+1 to the recursive call
Readability of the grammar structureVery explicit — one rule, one functionLess explicit — precedence lives in a table, not the control flow
Best fitStatements, declarations, overall language structureExpressions with many operators and precedence levels

Code Example: A Minimal Pratt Parser in Python

The example below parses simple arithmetic with +, -, *, /, and parentheses into a nested tuple AST, using the precedence-climbing form of Pratt parsing (a restricted version that handles binary operators without a full prefix/infix table). Calling parse("1 + 2 * 3") correctly returns ('+', 1, ('*', 2, 3)), and parse("(1 + 2) * 3") returns ('*', ('+', 1, 2), 3) — multiplication binds tighter than addition in the first case, while the parentheses override that in the second.

import re

TOKEN_RE = re.compile(r"\s*(?:(\d+)|(.))")
PRECEDENCE = {"+": 1, "-": 1, "*": 2, "/": 2}

def tokenize(text):
    tokens = []
    for num, op in TOKEN_RE.findall(text):
        if num:
            tokens.append(("NUM", int(num)))
        elif op:
            tokens.append(("OP", op))
    return tokens

class Parser:
    def __init__(self, tokens):
        self.tokens = tokens
        self.pos = 0

    def peek(self):
        return self.tokens[self.pos] if self.pos < len(self.tokens) else (None, None)

    def advance(self):
        tok = self.peek()
        self.pos += 1
        return tok

    def parse_expression(self, min_precedence=0):
        kind, value = self.advance()
        if kind == "NUM":
            left = value
        elif kind == "OP" and value == "(":
            left = self.parse_expression(0)
            self.advance()  # consume the matching ")"
        else:
            raise SyntaxError(f"Unexpected token: {value}")

        while True:
            kind, op = self.peek()
            if kind != "OP" or op not in PRECEDENCE:
                break
            precedence = PRECEDENCE[op]
            if precedence < min_precedence:
                break
            self.advance()  # consume the operator
            # precedence + 1 on the right side gives left-associativity
            right = self.parse_expression(precedence + 1)
            left = (op, left, right)
        return left

def parse(text):
    return Parser(tokenize(text)).parse_expression()

print(parse("1 + 2 * 3"))     # ('+', 1, ('*', 2, 3))
print(parse("(1 + 2) * 3"))   # ('*', ('+', 1, 2), 3)

Trade-Offs

  • Pro (Pratt): Adding an operator or changing precedence is a one-line table edit, not a restructuring of the function chain.
  • Pro (Pratt): Naturally handles prefix, infix, postfix, and mixfix operators (like a ? b : c) within the same uniform mechanism.
  • Pro (Recursive descent): The control flow directly mirrors the grammar, which makes it easier for newcomers to read and debug, especially outside of pure expression parsing.
  • Con (Pratt): Precedence lives in a table rather than in the code’s structure, which can make the grammar less self-documenting to someone unfamiliar with the technique.
  • Con (Recursive descent): Scales poorly as the number of precedence levels grows — real language grammars (e.g. JavaScript’s ~17 levels) turn into long chains of near-duplicate functions.
  • Shared limitation: Both are typically hand-rolled for expressions; for very large or ambiguous grammars, parser-generator tools (LALR, PEG) may be a better fit than either.

Bottom Line

Recursive descent and Pratt parsing aren’t competitors so much as a division of labor: use recursive descent for the overall shape of a language — statements, declarations, blocks — and drop into a Pratt-style parse_expression(precedence) loop the moment you hit arithmetic-like expressions with multiple operators and precedence levels. Nearly every production hand-written parser for a language with infix operators — including real-world interpreters and compilers — uses exactly this hybrid, because it avoids both recursive descent’s function explosion and the complexity of a full parser-generator toolchain.

FAQ

Is Pratt parsing a replacement for recursive descent?
No — it’s typically combined with it. Recursive descent handles the overall grammar; a Pratt-style loop is dropped in specifically for expression parsing.

What does “binding power” mean?
It’s another name for an operator’s precedence value inside the Pratt algorithm — how strongly it “pulls” neighboring operands into its own sub-expression versus letting an outer operator claim them.

What are “nud” and “led”?
Terms from Vaughan Pratt’s original 1973 paper for a token’s “null denotation” (how it parses with nothing to its left — i.e. a prefix handler) and “left denotation” (how it parses with an already-parsed expression to its left — i.e. an infix handler). Modern explanations, including Bob Nystrom’s, often use the friendlier terms “prefix” and “infix” parselets instead.

Does Pratt parsing handle right-associative operators like exponentiation?
Yes — passing the operator’s own precedence (rather than precedence + 1) to the recursive call on the right-hand side produces right-associativity instead of left.

Why doesn’t the code example handle left recursion directly?
It doesn’t need to — the while loop inside parse_expression handles left-leaning chains of operators (like a - b - c) iteratively, which is precisely how Pratt parsing avoids the infinite-recursion problem that a naive left-recursive grammar rule would cause in straight recursive descent.

Sources

Posted in Engineering

Parsing Explained: Recursive Descent, Parser Combinators, and PEG Grammars

Every compiler, interpreter, linter, config-file loader, and query engine starts with the same problem: turning a flat stream of characters or tokens into a structured representation a program can act on. That’s parsing. Three approaches dominate how working engineers solve it today: hand-written recursive descent, parser combinators, and PEG (Parsing Expression Grammar) tooling. Each trades off control, declarativeness, and performance differently, and picking the wrong one for a project tends to show up later as unreadable code or incomprehensible error messages.

At a Glance

ApproachStrengthsBest For
Recursive DescentFull control over control flow and diagnostics; no dependencies; fast; easy to debugProduction compilers/interpreters that need custom, high-quality error messages
Parser CombinatorsComposable and declarative; parsers are ordinary values you can test in isolation; stays inside the host languageRapid prototyping, embedded DSLs, binary/text protocol parsers (nom, Parsec-style libraries)
PEG GrammarsGrammar lives in a separate, declarative file; unambiguous by construction; generated code is typically fastConfig languages, DSLs, and projects that want a formal grammar artifact (Pest, PEG.js)

What Parsing Actually Does

A parser takes a sequence of tokens (produced by a lexer/tokenizer) and builds a tree-shaped representation, usually an Abstract Syntax Tree (AST), that reflects the grammar’s structure and operator precedence. The three approaches below differ mainly in how that grammar gets expressed: as imperative code, as composed function values, or as a separate declarative grammar file.

Recursive Descent Parsing

Recursive descent is, as Robert Nystrom puts it in Crafting Interpreters, “the simplest way to build a parser.” It’s a top-down, predictive technique: each grammar rule becomes a function, terminals become code that matches and consumes a token, alternatives (|) become if/switch statements, and repetition (*, +) becomes a loop. When a rule refers to itself, that becomes a recursive function call — hence the name: the parser “walks down” the grammar. Nystrom notes that recursive descent parsers are “fast, robust, and can support sophisticated error handling,” and that production compilers including GCC, V8, and Roslyn use it.

Here is a minimal, correct recursive descent parser for arithmetic expressions with standard precedence (*// binds tighter than +/-), written in JavaScript and verified to run:

// Grammar:
// expr   -> term (("+" | "-") term)*
// term   -> factor (("*" | "/") factor)*
// factor -> NUMBER | "(" expr ")"

function parse(tokens) {
  let pos = 0;
  const peek = () => tokens[pos];
  const next = () => tokens[pos++];

  function parseExpr() {
    let left = parseTerm();
    while (peek() === "+" || peek() === "-") {
      const op = next();
      const right = parseTerm();
      left = { type: op === "+" ? "Add" : "Sub", left, right };
    }
    return left;
  }

  function parseTerm() {
    let left = parseFactor();
    while (peek() === "*" || peek() === "/") {
      const op = next();
      const right = parseFactor();
      left = { type: op === "*" ? "Mul" : "Div", left, right };
    }
    return left;
  }

  function parseFactor() {
    const token = next();
    if (token === "(") {
      const expr = parseExpr();
      next(); // consume ")"
      return expr;
    }
    return { type: "Num", value: Number(token) };
  }

  return parseExpr();
}

const tokens = ["2", "+", "3", "*", "(", "4", "-", "1", ")"];
console.log(parse(tokens));
// Evaluating the resulting tree gives 11, matching 2 + 3 * (4 - 1)

Notice the loop in parseTerm and parseExpr instead of a recursive call back into themselves at the start of the rule — that’s the standard fix for left recursion in hand-written recursive descent, and it’s exactly the technique Crafting Interpreters uses for the same reason: a rule that calls itself as its very first action would recurse forever and overflow the stack.

Parser Combinators

Parser combinators express a grammar as ordinary functions (or values) that you compose with higher-order combinators like sequence, choice, and many. Instead of writing a function per grammar rule by hand, you build a parser for “digit” and combine it with “many” to get “number,” then combine that with “plus” and “number” to get “sum.” This style originates in functional programming (Haskell’s Parsec is the canonical example) and is popular in Rust via the nom crate, which documents itself as a parser-combinator library built around byte- and bit-level parsers. The appeal is composability and testability: each small parser is a first-class value you can unit-test on its own and reuse across grammars.

The trade-off is that stack traces and error messages come from the combinator library’s generic machinery rather than code you wrote for that specific rule, so by default errors tend to be less tailored than a hand-rolled recursive descent parser’s — libraries like nom mitigate this with dedicated error-context combinators, but it’s extra work layered on top of the base approach.

PEG Grammars

A Parsing Expression Grammar looks like a context-free grammar (BNF-style rules) but is interpreted differently: alternatives are tried in order and the first one that matches wins — “ordered choice” — which makes PEGs unambiguous by construction, unlike classic CFGs where multiple derivations can be possible. Pest, a widely used PEG parser generator for Rust, describes PEGs on its own documentation site as “eager, ordered, and unambiguous,” and compiles a separate .pest grammar file into a Rust parsing function at build time. PEG.js and similar tools apply the same idea to JavaScript. A toy version of the arithmetic grammar above, written in Pest’s grammar syntax, looks like this:

expr   = { term ~ (("+" | "-") ~ term)* }
term   = { factor ~ (("*" | "/") ~ factor)* }
factor = { number | "(" ~ expr ~ ")" }
number = { ASCII_DIGIT+ }

The grammar is now a declarative artifact, separate from the code that walks the resulting parse tree. The catch is left recursion: standard PEGs cannot express a rule like expr = expr ~ "+" ~ term | term directly, because the rule would try to match itself at the same input position before consuming anything and loop forever. Academic work on this problem — notably Warth, Douglass, and Millstein’s “Left Recursion in Parsing Expression Grammars” — proposes algorithms to support it, and some modern PEG tools implement variants of those algorithms, but plain PEG tooling generally expects you to rewrite left recursion as repetition, exactly as in the recursive descent example above.

Comparison Table

DimensionRecursive DescentParser CombinatorsPEG Grammars
PerformanceFast; no abstraction overheadGood, but combinator/closure overhead can add up without optimizationFast; generated code is comparable to hand-written parsers
ReadabilityReads like the grammar, but grammar and code are interleavedReads like the grammar if combinators are named well; can get denseGrammar is separate and declarative, often the clearest artifact
Left recursionMust be manually rewritten as loops/repetitionMust be manually rewritten; combinators don’t solve it for youNot supported by default; needs a rewrite or an extended algorithm
Error messagesBest in class — you write exactly the diagnostic you wantDepends on the library; often needs extra “context” combinatorsGenerated; quality depends on the tool (Pest reports expected rules)

Trade-offs

  • Recursive descent costs the most up-front code but gives the most control, which is why production compilers favor it when error-message quality matters.
  • Parser combinators minimize boilerplate and maximize reuse, at the cost of needing to learn a combinator vocabulary and sometimes fighting generic error output.
  • PEG grammar tools give you a formal, swappable grammar file and strong tooling (syntax highlighting, grammar linting), but you depend on the generator’s runtime and its specific left-recursion and error-reporting behavior.

Bottom Line

If you’re building a language or tool where error messages are a feature — a compiler users will stare at all day — hand-written recursive descent is still the pragmatic default, which is exactly why GCC, V8, and Roslyn use it. If you’re parsing something smaller and want composability and quick iteration inside an existing codebase, reach for a parser-combinator library like nom. If you’d rather keep the grammar as a separate, formal artifact and let a generator produce the parser, PEG tools like Pest or PEG.js are the right fit — just plan for the left-recursion rewrite up front.

FAQ

Is a lexer the same thing as a parser? No. A lexer (tokenizer) turns raw characters into a flat stream of tokens; a parser turns that token stream into a tree. All three approaches in this article assume a token stream already exists.

Do any of these three handle left recursion automatically? Not by default. Hand-written recursive descent, basic parser combinators, and standard PEG tools all require you to rewrite left-recursive rules as repetition. Specialized algorithms exist for left-recursive PEGs, but they’re an extension, not the baseline behavior.

Why do PEGs guarantee no ambiguity while classic context-free grammars don’t? Because PEG alternation (|) is ordered choice — the first alternative that matches wins, full stop — rather than a set of possibilities a general CFG parser must disambiguate.

Which approach is fastest? Hand-written recursive descent and generated PEG parsers are typically the fastest, since both compile down to direct, specialized code. Parser combinators can match that speed but depend on how well the library inlines and avoids allocation.

Can I mix approaches? Yes — it’s common to hand-write the top-level recursive descent structure for a language’s statements while using a small PEG or combinator-based parser for an embedded sub-language like a format string or query expression.

Sources