Diagnosticsimplementation

Error Recovery

Detect, report, synchronize, continue. A compiler that stops at the first error costs a round trip per typo — and one that recovers badly turns a single missing brace into forty messages, which is worse.

The question

How does a compiler keep going after a syntax error, and why does one missing brace produce forty messages?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A syntax tree containing error nodes: subtrees the parser could not build, marked as such, with the tokens it skipped attached. The tree is no longer a claim that the program is well-formed — it is a best-effort structure plus an explicit record of where the structure is unknown. That representation exists to answer a question a failed parse cannot otherwise answer: "what else is wrong with this file, and what can the IDE still offer completions for?"

What this phase may assume or do

Recovery may skip tokens and invent nodes, but it must never *silently* produce a tree that later phases mistake for a valid parse. Every recovery action either emits a diagnostic or marks a node as erroneous, and later phases are entitled to assume that a subtree containing no error marker really did parse. Break that and the type checker reports a cascade of errors about a construct the parser guessed at — which is the second-order failure mode, and the reason compilers suppress semantic analysis of subtrees containing parse errors.

Key points

  • Recovery is four steps: detect, report, synchronize, continue — and synchronize is the one with judgement in it.
  • Panic mode discards tokens until a synchronizing token: ;, }, or a statement-introducing keyword.
  • The parser must return an error node rather than throwing, so the enclosing construct keeps parsing and the tree stays well-shaped.
  • Error productions are grammar rules for mistakes that are not in the language, written so the compiler can recognise a specific error rather than guess.
  • Cascading errors happen because a missing delimiter is not visible as a missing delimiter — the rest of the file is parsed in the wrong context.
  • The four defences are suppressing nearby duplicates, capping the error count, skipping later phases for broken subtrees, and inferring the real cause from indentation.
  • Only the last of those improves the message; the others reduce noise.
  • An IDE needs a tree for every keystroke, which makes always-return-a-tree recovery a primary architectural requirement rather than a polish item.

Four steps, and the third is the hard one

Detect. The current token matches no applicable production. This part is free; it is what the parser already does.

Report. Emit a diagnostic naming what was expected and what was found, anchored at a span the user will recognise — usually where the construct *began*, not where the parser gave up. [[diagnostic-quality]] is about this step.

Synchronize. Discard tokens until reaching one from which parsing can plausibly resume. This is the step with judgement in it and the one that decides whether the remaining errors are useful or noise.

Continue. Insert an error node for what could not be parsed, and carry on as though the construct had succeeded. The tree stays well-shaped; the hole is explicit.

A compiler that stops after step two costs the user one edit-compile round trip per typo. On a slow build that is minutes per typo, and it is why every compiler written since about 1970 recovers.

Panic-mode recovery

The standard technique, and it is called panic mode because it does exactly one thing: throw tokens away until the ground looks solid. Solid means a synchronizing token — one that reliably marks a boundary between constructs, and where the parser can be confident about what comes next regardless of what it just skipped.

Which tokens qualify is a language-design question in disguise. In C-family languages ; and } are excellent: a semicolon almost always ends a statement, a closing brace almost always ends a block. Statement-introducing keywords — if, while, for, return, class, fn — are equally good, because seeing one means a new statement is starting no matter what preceded it.

In languages without statement terminators the synchronizing tokens are harder to find. Python synchronizes on NEWLINE and DEDENT tokens the lexer synthesises; Go's automatic semicolon insertion means it has semicolons after all, but only the lexer knows it. Languages designed to be error-recoverable put explicit, redundant delimiters in — which is one of the underrated arguments for mandatory braces and semicolons.

Panic-mode synchronization at statement level
1/**
2 * Discard tokens until we are plausibly at the start of a new statement.
3 * Called after reporting a parse error; returns with the parser positioned
4 * somewhere it can make progress.
5 */
6private synchronize(): void {
7 this.advance() // consume the offending token itself
8
9 while (!this.atEnd()) {
10 // A semicolon we just passed almost certainly ended a statement.
11 if (this.previous().kind === 'SEMICOLON') return
12
13 // A statement-introducing keyword almost certainly starts one.
14 switch (this.peek().kind) {
15 case 'CLASS': case 'FN': case 'LET':
16 case 'FOR': case 'IF': case 'WHILE':
17 case 'RETURN':
18 return
19 }
20
21 this.advance()
22 }
23}
24
25// At the call site, the parser reports, syncs, and returns an error node
26// rather than throwing — so the enclosing block keeps parsing.
27private parseStatement(): Ast {
28 try {
29 return this.parseStatementInner()
30 } catch (e) {
31 if (!(e instanceof ParseError)) throw e
32 this.report(e)
33 this.synchronize()
34 return { kind: 'ErrorStmt', span: e.span } // ← the hole, made explicit
35 }
36}

The shape here is the one from Crafting Interpreters, and it is what most hand-written parsers start with. Two things about it are worth criticising and are addressed in [[parser-synchronization]]: it knows nothing about nesting, so it will happily skip past a } that closes a block the parser is still inside; and the sync set is global rather than per-construct, so an error inside an argument list resynchronizes at statement level and discards the rest of the call.

Error productions: recovery written into the grammar

The second technique is to anticipate specific mistakes and give them their own grammar rules. An error production matches a construct that is *not* in the language, purely so the compiler can produce a good message about it instead of a generic one.

This is where the best diagnostics come from, because the compiler is no longer guessing — it recognised the mistake. Clang has productions for a missing semicolon after a class definition, for -> used where . was meant, for a C++ template with >> closing two argument lists. rustc has them for using = where == was meant in a condition, for a missing fn keyword, for Java-style or Python-style syntax in a Rust file. Every one of those produces a message that names the actual mistake and offers the actual fix.

The cost is that they are per-mistake. Each one is code, each one has to be anticipated, and a language can accumulate dozens. It is also the one recovery technique that scales with effort rather than with cleverness: teams that care about diagnostics accumulate error productions steadily over years, which is a large part of why some compilers' messages are so much better than others'.

The trap to avoid: an error production must never make the invalid construct *work*. It matches, diagnoses, and produces an error node. A production that silently accepts the mistake has extended the language by accident.

Recovery strategies, and what each coststypical
StrategyHow it worksCost
Stop at first errorReport and exitOne edit-compile round trip per mistake; unusable in an IDE
Panic modeSkip to a synchronizing tokenDiscards everything between; a badly chosen sync point loses a whole function
Phrase-levelInsert, delete or replace a single token and continueThe guess is often wrong, and a wrong guess cascades
Error productionsGrammar rules for anticipated mistakesOne per mistake, written by hand, forever
Nesting-aware syncTrack the delimiter stack; never skip past a delimiter you oweBookkeeping in the parser; still guesses which delimiter was intended
Global / least-costFind the minimum edit sequence making the input parseExpensive, and the minimum edit is frequently not the user's intent
Always return a tree (GLR)Keep every hypothesis alive; emit ERROR nodesMemory, and no opinion about what the user meant

Cascading errors: the forty messages

implementationSpecific limits and heuristics: Clang's -ferror-limit defaults to 20; GCC's -fmax-errors is unlimited by default; rustc caps duplicate-suppression by token distance and uses indentation heuristics to attribute unclosed delimiters. All three are tunable and all three change between versions. What generalises is the shape of the defence, not the numbers — and none of the three compilers claims its heuristic is right, only that it is better than forty messages.

One missing } and the compiler prints forty errors, thirty-nine of which are lies. This is the failure mode users actually experience, and it has a specific mechanism.

A missing closing brace does not look like a missing brace to the parser. It looks like the function body continuing, so the next function's fn keyword appears inside a block where a statement was expected. That is an error. Recovery syncs, the next construct is misinterpreted too, and every subsequent declaration in the file is parsed in the wrong context. The parser is not confused about one thing; it is confidently wrong about the entire remainder of the file.

Four defences, all of them used in production compilers, none of them sufficient alone.

Suppress nearby duplicates. After reporting an error, suppress further syntax errors until the parser has consumed some minimum number of tokens successfully — the reasoning being that an error reported three tokens after another is probably the same mistake. rustc and Clang both do a version of this.

Cap the count. clang -ferror-limit=N defaults to 20 and then stops with "too many errors emitted". GCC has -fmax-errors=N, unlimited by default. The cap does not make the errors better; it stops the terminal scrolling and signals that the compiler has lost the plot.

Suppress downstream phases for broken subtrees. If a function body contains an error node, do not type-check it. This removes the second wave — the type errors caused by a bad parse — and it is why a file with one syntax error usually shows syntax errors only.

Guess the real cause and say so. rustc uses indentation to infer where a brace was probably intended and reports the *unclosed delimiter* at the point it was opened, with a note about the indentation mismatch. This is the only one of the four that improves the message rather than reducing the noise, and it is also the only one that requires the compiler to have an opinion about what the user meant.

The IDE changed the requirement

Batch compilation wants recovery so a user finds several errors per build. An editor wants something stronger: a usable tree for a file that is currently invalid, on every keystroke, because the moment a user is most likely to want completion is halfway through typing foo. — a syntactically incomplete expression.

That turns recovery from a nicety into the primary requirement. The parser must never throw to the top; it must always return a tree; error nodes must be structured enough that name resolution and type checking can still run over the parts that did parse. Roslyn was designed around this from the start, with missing-token nodes carrying zero-width spans and skipped-token trivia, so that a tree exists for literally any input. tree-sitter takes the other route: its GLR parser returns a tree containing ERROR and MISSING nodes for any input at all, because it never had to commit to one interpretation.

The consequence for compiler architecture is the one from [[ll-vs-lr]]: recovery quality is not a feature you add at the end, it is a property of where the parser is when it fails. A recursive-descent parser is inside a named construct and can make a targeted guess. A deterministic LR parser has popped its stack to an error production and has thrown away the context that would have made the guess good.

How it works

The steps, in the order the compiler takes them.

  • On a token that matches no production, the parser records a diagnostic with the span of the construct being parsed, not merely the offending token.
  • It then discards tokens until reaching one in its synchronization set — the FOLLOW set of the current construct, plus anchors contributed by enclosing constructs.
  • It constructs an error node covering the skipped range and returns it in place of the node it could not build, so the parent continues normally.
  • Nodes that were partially parsed keep whatever children were successfully built, so an incomplete expression still offers a tree to complete against.
  • Further syntax diagnostics are suppressed until some number of tokens have been consumed without error, on the assumption that a close-following error is the same mistake.
  • Semantic phases skip any subtree containing an error node, preventing a second wave of type errors caused by a guessed parse.
  • When an unclosed delimiter is detected at end of file, the compiler attributes it to the opening delimiter's span and may use indentation to guess where the closing one was intended.
  • If the diagnostic count exceeds a configured limit, the compiler stops and says so rather than continuing to produce derived noise.

How it breaks

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

  • One missing } produces forty errors, and the real one is the first, which has scrolled off the terminal.
  • The parser synchronizes at statement level after an error inside an argument list, discarding the rest of the enclosing function and reporting nothing about it.
  • The reported error is on the line *after* the mistake, because the span used was the unexpected token's rather than the construct's start.
  • Recovery inserts a token it guessed at, the parse continues plausibly, and the resulting type errors describe a program the user did not write.
  • A syntax error in one function suppresses type checking for the whole file, so the user fixes the syntax and immediately finds twelve type errors that were there all along.
  • The IDE shows no completions inside a function whose closing brace is not yet typed, because the parser threw instead of returning a tree.
  • The error limit truncates output before reaching a genuinely independent second error, and the user believes they have fixed everything.
  • An error production intended to diagnose a mistake accidentally accepts it, and the invalid syntax becomes a de facto feature that later has to be supported.

When it helps

  • Any compiler with human users, where the cost of stopping at the first error is a round trip per typo.
  • Language servers and linters, where the input is invalid most of the time by definition.
  • Large generated or machine-edited files, where finding all the errors in one pass is the difference between one fix cycle and hundreds.
  • Migration tooling, where a file is expected to be broken and the useful output is a complete list of what needs changing.

When it hurts

  • Where a wrong guess is more expensive than stopping. A configuration parser that recovers from a malformed entry and continues may apply a partial configuration, which is worse than refusing to start — see [[input-validation]] in Security.
  • When recovery quality is measured by error count. A compiler that reports more errors is not recovering better; a compiler whose second error is genuinely independent of the first is.

What it costs

Every one of these is paid by something.

  • Recovering at all buys many errors per compile and pays with the possibility that later errors are derived from a wrong guess — so the user must learn that the first error is trustworthy and the rest may not be.
  • Aggressive synchronization buys resilience and pays coverage: every token skipped is a region in which no error will be reported, so a big skip hides real problems.
  • Conservative synchronization buys coverage and pays noise: staying close to the failure means reporting more errors, most of which are the same mistake seen again.
  • Error productions buy the best messages available and pay per-mistake implementation effort forever, plus the risk that a production accidentally accepts what it was meant to diagnose.
  • Suppressing semantic analysis of broken subtrees buys a clean, syntax-only error list and pays a second round trip: the user fixes the syntax and only then discovers the type errors that were always there.
  • Always returning a tree buys IDE features and pays a parser in which every function must handle failure as a return value rather than an exception — a pervasive change to its shape.

What else you could do

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

  • Stop at the first error. Simple, honest, and unusable for anything interactive. Some assemblers and small tools still do it defensibly.
  • Phrase-level repair: try inserting, deleting or replacing one token and see whether the parse continues. Cheap and it works surprisingly often for missing semicolons; when the guess is wrong it produces confidently misleading downstream errors.
  • Least-cost or Burke–Fisher repair: search for the minimum edit sequence that makes the input parse. Theoretically elegant, expensive, and the minimum edit is frequently not what the user meant.
  • GLR with error nodes, as in tree-sitter: never commit, always return a tree, and let the consumer decide what the ERROR nodes mean. Excellent for editors, and it declines to have an opinion about the user's intent.
  • Recover in the lexer instead, by inserting synthetic tokens — automatic semicolon insertion in JavaScript and Go is exactly this, and it is instructive that both languages' communities regard the JavaScript version as a design mistake.

See it for yourself

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

  • Delete a } from the middle of a large C++ file and compile with clang -ferror-limit=0. Count the errors, then find the first one — that is the cascade, measured.
  • Compare the same broken file across compilers: rustc on a Rust file with an unclosed brace reports the *opening* delimiter with an indentation note; most compilers report end-of-file. The difference is a heuristic, not effort.
  • clang -ferror-limit=N and gcc -fmax-errors=N both let you see how the output changes with the cap; setting it to 1 shows what a non-recovering compiler would have given you.
  • tree-sitter parse a file with a deliberate error and observe the (ERROR ...) and (MISSING ...) nodes in an otherwise complete tree — the always-return-a-tree model made visible.
  • In an editor, type foo. inside an unclosed block and see whether completion still works. Whether it does tells you which recovery model the language server is built on.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "More errors reported means better recovery." Reporting forty errors for one mistake is worse recovery, not better. The metric is whether the *second* error is independent of the first.
  • "The compiler is confused after the first error." It is not confused; it is confidently parsing in the wrong context. That distinction matters, because it explains why the later errors are specific and wrong rather than vague.
  • "Error recovery is a parser concern." It determines whether semantic phases run, whether an IDE can offer completions, and how the whole frontend handles failure. It is an architectural concern that shows up first in the parser.
  • "Panic mode is a bad technique because it discards tokens." Every technique discards or invents something. Panic mode is the one whose guess is explicit and bounded, which is why it is still the default fifty years on.
  • "If the parser recovers, later phases can just proceed." They must not. Type-checking a guessed parse is where the second wave of nonsense errors comes from.

Misconceptions

The claim, and what is actually true.

The compiler should keep going and report everything it finds.
After a syntax error the parser is working from a guess, so "everything it finds" includes consequences of the guess. Good compilers deliberately report less.
Cascading errors mean the compiler is badly written.
They mean a missing delimiter is genuinely indistinguishable from a differently-shaped program. The best compilers reduce the cascade with heuristics; none eliminates it.
Recovery is about being forgiving.
It is about producing more information per compile without producing false information. A recovering parser is not more permissive — it still rejects the program.

Go deeper

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

overview

When a parser hits something it cannot parse, it reports the problem, throws away tokens until it reaches something recognisable like a semicolon or a closing brace, marks the gap in the tree, and keeps going. That is how you get several errors from one compile instead of one. The catch is that after the first mistake the parser is often reading the rest of the file in the wrong context, which is why one missing brace can produce dozens of messages that are all really the same mistake.

practical

Trust the first error and be suspicious of the rest — fix the first one and recompile before reading further, especially when the errors are about delimiters. If you are writing a parser: never throw to the top, always return an error node, and pick sync tokens that reliably start a new construct. Add duplicate suppression early, because without it the noise makes it impossible to evaluate whether your recovery is any good. And skip semantic analysis of subtrees that contain error nodes, unless you are writing a language server, in which case do the opposite.

advanced

The deepest problem is that recovery requires a model of what the user meant, and the parser has no such model — it has a grammar. Every technique substitutes something for intent: panic mode substitutes "the next construct boundary", phrase-level repair substitutes "the smallest edit", error productions substitute "a mistake somebody anticipated", and GLR substitutes "all of them, you decide". The reason rustc's indentation heuristic feels qualitatively better is that indentation is the one place users encode their intent redundantly, so reading it is closer to knowing what they meant than any grammar-derived guess. That suggests a general direction — recovery improves by finding more redundant signal in the source, not by better search over edits — and it explains why languages with more redundancy in their syntax are easier to write good recovery for, which is a consideration language designers weigh far too late.

How much this depends on

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

implementationClang defaults -ferror-limit to 20; GCC leaves -fmax-errors unlimited; rustc suppresses duplicate syntax errors within a token-distance window and uses indentation to attribute unclosed delimiters. Every one of those is version-specific and tunable, and none of the three compilers presents its heuristic as correct — only as better than the alternative.
typicalThat semantic analysis is suppressed for subtrees containing parse errors describes mainstream compilers, and it is exactly what language servers deliberately do *not* do: an IDE runs name resolution and type inference over the parts that parsed, because completion inside a broken function is the most common request there is. The same design decision goes the opposite way in the two settings.
simplifiedThe synchronize function shown is statement-level and nesting-unaware, which is where most parsers start and not where they end. A production parser threads a per-construct sync set down the call chain and tracks open delimiters so recovery never skips past a brace it owes — see [[parser-synchronization]].

If you were asked this in an interview

  • Walk me through what a parser does after it hits a token it did not expect.
  • One missing brace, forty errors. Explain the mechanism, then give me two defences.
  • What changes about error recovery when the consumer is an editor rather than a build?

Connections

API Designerror-taxonomy
Domains that do not exist yet
  • Testing & Reliability Engineering — Fault injection into a known-good input
    The standard way to evaluate recovery is to take valid files, delete or corrupt one token, and measure whether the second reported error is independent of the first. That is fault injection, owned there; the compiler-specific version appears in [[compiler-testing]].