Grammarspec

Operator Precedence

Precedence is the answer to "which operator gets to be the parent". `1 + 2 * 3` builds as `+(1, *(2, 3))`, and every mechanism for arranging that — grammar layers, declaration tables, binding powers — is producing the same tree by a different route.

The question

Where does operator precedence actually live — the grammar, the parser, or a table — and how do I check what my language does?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

An expression tree in which the *lower*-precedence operator is nearer the root. That inversion is the entire content of precedence: binding tighter means being deeper. The representation exists to answer "in what order do these operations apply", a question the flat token sequence cannot express at all.

What this phase may assume or do

Any mechanism that implements precedence must produce, for every input, exactly the tree the language specification names — and must leave parenthesised subexpressions alone, since parentheses are the author's override and a precedence scheme that could reorder across them would change the meaning of correct programs.

Key points

  • Precedence decides which operator is the parent; binding tighter means sitting deeper in the tree.
  • 1 + 2 * 3 builds as +(1, *(2, 3)) and evaluates to 7 in every mainstream language.
  • Three mechanisms produce that tree: grammar layering, declared precedence tables, and run-time binding powers.
  • Precedence does not survive into the AST — a pretty-printer re-derives parentheses from the tree and the table.
  • Only *-over-+ is close to universal. Shift, bitwise-versus-comparison and unary-minus-versus-exponent all differ between C, Go and Python.
  • Parentheses are an override, not an operator, and are typically erased by the parser — which is why formatters need a concrete syntax tree.

Tighter means deeper

Precedence is usually stated as a table of levels, and that framing hides the mechanism. What precedence actually decides is which operator ends up at the *root* of the subtree. For 1 + 2 * 3, the multiplication binds tighter, which means it is applied first, which means it is *further from the root* — the addition is the outermost operation and therefore the parent.

Say it as a rule and it becomes checkable: higher precedence, deeper in the tree. Every time you are unsure what a precedence table means, build the tree and read it back. A tree is unambiguous in a way that a table of numbers, half of which count up and half of which count down depending on the document, is not.

The tree below is what every mainstream language builds for 1 + 2 * 3, and it evaluates to 7. The alternative tree — the multiplication at the root — evaluates to 9, and it is what a flat grammar with no precedence produces about half the time depending on the parsing algorithm. That is [[ambiguous-grammars]]; this lesson is about the machinery that prevents it.

The AST for 1 + 2 * 3: the looser operator is the parent
AST — only what later phases match on
Binary +“1 + 2 * 3”— The root, because `+` binds loosest and is therefore applied last.
├── Number 1“1”
└── Binary *“2 * 3”— Deeper, because `*` binds tighter and is therefore applied first.
├── Number 2“2”
└── Number 3“3”

Read it asRead it as +(1, *(2, 3)). Evaluation is a postorder walk, so * is evaluated before + — which is what "binds tighter" meant all along. Note that no node records a precedence number: precedence is consumed entirely by the parser and does not survive into the AST, which is why a pretty-printer has to re-derive it to know where to put parentheses.

Three mechanisms, one tree

implementationThe tool rows describe Bison 3.8 declarations and the Pratt parsers in rustc and in several JavaScript engines as of 2024. Which mechanism a given compiler uses is an implementation choice that does not change the language: a language specified with a layered grammar can be implemented with binding powers, and usually is, because the layered version produces long unary chains the implementation would rather not build.

There are three ways a real implementation arranges that shape, and it is worth being able to recognise all three because you will read all three.

Grammar layering. One nonterminal per level, each referring to the level below: expression handles + and -, term handles * and /, factor handles literals and parentheses. The tighter operator is in the deeper rule, so the tighter operator ends up deeper in the tree — the structure of the grammar *is* the precedence table. Unambiguous by construction; a new precedence level means a new nonterminal and edits to its neighbours.

Declared precedence. Keep one flat rule, expr : expr OP expr, and put the levels in declarations: Bison's %left '+' '-' then %left '*' '/', with later declarations binding tighter. The generator uses them to resolve the conflicts the flat rule creates. One line per operator; the grammar alone no longer specifies the language.

Binding powers. A Pratt or precedence-climbing parser stores a numeric binding power per token and runs a loop: parse a left operand, then while the next token binds at least as tightly as the current minimum, consume it and recurse with a raised minimum. The table is data at run time, so operators can even be added dynamically — which is how Haskell's user-defined infixl 6 operators work. See [[pratt-parsing]].

Where precedence lives, and what it costs to add an operator
MechanismPrecedence isAdding an operatorUsed by
Grammar layeringThe nesting of nonterminalsA new nonterminal plus edits to two neighboursMost published language specifications
Declared tableimplementation%left / %right lines beside a flat ruleOne declaration lineBison and yacc grammars
Binding powersimplementationTwo numbers per token, consulted at run timeOne table rowPratt parsers; rustc, many JS engines
NoneStrict right-to-left evaluationNothing to addAPL and J, deliberately

The precedence table is not universal

Everyone learns that * binds tighter than +, and that much is stable across essentially every language with infix arithmetic. Almost nothing else is. The interesting bugs come from carrying a precedence intuition from one language to another, and there are three collisions common enough to be worth memorising.

The first is C's bitwise operators. &, | and ^ bind *looser* than the comparison operators, a decision Dennis Ritchie later called a mistake and which persists for compatibility. So x & mask == 0 parses as x & (mask == 0), which is almost never what the author meant, and both GCC and Clang warn about it under -Wparentheses.

The second is shift. In C, + binds tighter than <<, so a + b << c means (a + b) << c. In Go, the shift operators sit in the *same* precedence group as * and /, above +, so the same source means a + (b << c). Identical text, different tree, no diagnostic.

The third is unary minus against exponentiation. In Python, ** binds tighter than a unary minus on its left, so -2 ** 2 is -(2 ** 2) and evaluates to -4. JavaScript found this confusing enough that ES2016 made -2 ** 2 a syntax error outright, requiring the author to write the parentheses.

Same source, different treesspec
SourceC / C++GoPython
a + b * ca + (b * c)a + (b * c)a + (b * c)
a + b << cspec(a + b) << ca + (b << c)a + (b << c)
x & mask == 0specx & (mask == 0)(x & mask) == 0x & (mask == 0)
-2 ** 2specno ** operatorno ** operator-(2 ** 2) = -4
a == b == cspec(a == b) == ccompile error: mismatched typeschained: a == b and b == c

Parentheses, and what survives

Parentheses are not an operator. They are the author's instruction to the parser to build a particular subtree regardless of the table, and in most languages they leave no trace at all in the AST — (1 + 2) * 3 and a hypothetical AST written directly as *(+(1,2), 3) are indistinguishable after parsing. That is usually what you want, and it is occasionally a problem: a formatter or a lint rule that wants to preserve redundant parentheses has to keep them explicitly, which is one of the reasons a [[concrete-syntax-tree]] exists as a separate artefact from the AST.

The corollary is the one to remember when reading generated code or a decompiler's output: parentheses in the *output* are re-derived from the tree and the precedence table, not recovered from the input. A printer that gets the table wrong emits parentheses that are correct-but-noisy, or — worse — omits ones that were needed, producing text that no longer round-trips to the same tree.

How it works

The steps, in the order the compiler takes them.

  • Assign each infix operator a precedence level, with tighter-binding operators at the higher level.
  • For grammar layering: write one nonterminal per level whose right-hand side refers to the next tighter level, and put literals and parenthesised expressions at the tightest level of all.
  • For a declared table: write one flat binary rule and list the operators in increasing precedence order, letting the generator resolve the resulting conflicts from the declarations.
  • For binding powers: give each token a left and a right binding power, parse a prefix, then loop while the next token's left power exceeds the current minimum, recursing with the right power as the new minimum.
  • Give parentheses the tightest level, so a parenthesised expression is atomic to every operator around it.
  • Verify by building the tree for one input per adjacent level pair and evaluating it, rather than by reading the table back.

How it breaks

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

  • A hand-written parser gets one level wrong and a - b * c computes (a - b) * c. Nothing errors; a report arrives weeks later that a calculation is off, and the arithmetic in the source looks correct.
  • C code written by someone whose intuition came from Python writes if (flags & MASK == 0) and the condition is effectively always false, because MASK == 0 is 0 and flags & 0 is 0.
  • A Go port of a C bit-manipulation routine keeps a + b << c and silently computes a different value, because the two languages place shift on opposite sides of +.
  • A code generator emits an expression without parentheses because it re-derived them from a precedence table that does not match the target language, and the emitted program compiles and computes something else.

When it helps

  • Designing an expression language: fixing the precedence table before writing the parser makes the grammar or the binding-power table fall out mechanically.
  • Reading unfamiliar code in a language you do not use daily — knowing which three collisions exist tells you where to add parentheses defensively.
  • Debugging a wrong-answer bug in an interpreter: dumping the AST for the offending expression settles precedence questions in seconds.

When it hurts

  • A deep precedence table is itself a usability problem. C has fifteen levels and essentially nobody can recite them, which is why style guides mandate parentheses that the language does not require.
  • Allowing user-defined operators with user-defined precedence, as Haskell and Scala do, makes expressions unparseable until imports are resolved — the parser now depends on the module system.

What it costs

Every one of these is paid by something.

  • Encoding precedence in the grammar buys a specification that is unambiguous on its own and costs a nonterminal per level, deep unary chains in the parse tree, and a multi-rule edit for every new operator.
  • A declared or binding-power table buys single-line extensibility and costs the self-containedness of the grammar — the table becomes part of the specification and nothing mechanically checks that the two agree.
  • More precedence levels buy expressions that need fewer parentheses and cost reader confidence: every level past about five is a level someone will get wrong, and the resulting bugs are silent.

What else you could do

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

  • No precedence at all. APL and J evaluate strictly right to left with every function at equal precedence, which is genuinely simpler to specify and requires readers to learn a different habit rather than a table.
  • Uniform prefix or postfix notation — Lisp's s-expressions, Forth, and every stack machine — where the tree is written explicitly and precedence is not a question that can be asked. The cost is verbosity in exactly the arithmetic that infix notation is good at.
  • Requiring parentheses whenever two different operators meet, which some configuration and policy languages do. It removes the table entirely and is unpopular for exactly the reason you would expect.

See it for yourself

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

  • python3 -c "import ast; print(ast.dump(ast.parse('1 + 2 * 3')))" prints BinOp(left=Constant(1), op=Add(), right=BinOp(left=Constant(2), op=Mult(), right=Constant(3))).
  • clang -Xclang -ast-dump -fsyntax-only x.c shows the tree C actually built — try it on x & mask == 0 and read which operator is the outer one.
  • gcc -Wparentheses and clang -Wparentheses warn precisely on the C precedence collisions this lesson names; both are in -Wall.
  • astexplorer.net renders the same expression under many parsers side by side, which is the fastest way to compare two languages' tables.
  • bison -v prints the resolved precedence for each conflicting state, so you can confirm a %left declaration did what you intended.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Higher precedence means closer to the root." The opposite. Binding tighter means being applied first, which means being deeper.
  • "Precedence and evaluation order are the same thing." Precedence decides the tree shape. Evaluation order decides which operand of one node is computed first, and in C and C++ that is largely unspecified — see [[observable-behaviour]].
  • "Every language uses the same table." Only for * over +. Shift, bitwise operators and exponentiation-versus-unary-minus all differ across mainstream languages.
  • "The AST records the precedence." It records the shape precedence produced. The numbers are gone, which is why re-printing the source needs the table again.

Misconceptions

The claim, and what is actually true.

Precedence is a property of the parser.
It is a property of the language, defined in its specification. The parser implements it, and three different implementations can produce the same trees.
If precedence is right, evaluation order is determined.
They are separate. The tree says a() and b() are the operands of one +; in C and C++ nothing says which call happens first.
Redundant parentheses have no effect.
They have no effect on the AST in most languages, which means they also cannot be recovered — a formatter that reprints from the AST may delete parentheses the author added for readability.

Go deeper

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

overview

Precedence decides which operator becomes the parent when two of them compete for the same operand. Tighter-binding operators are applied first and therefore sit deeper in the tree, so 1 + 2 * 3 is an addition whose right child is a multiplication, and it evaluates to 7.

practical

When an expression computes the wrong number and the arithmetic looks right, dump the tree. clang -Xclang -ast-dump or ast.dump in Python will answer the question in one command, and it beats reading a fifteen-level table. The three collisions worth knowing by heart: C puts & below ==, Go puts << beside * while C puts it below +, and Python's ** binds tighter than a unary minus on its left.

advanced

The mechanisms are not interchangeable once extensibility enters. Grammar layering fixes the operator set at grammar-writing time, which is fine for a fixed language and impossible for one with user-defined operators. Haskell's infixl 6 <+> declarations mean the precedence of an operator depends on which modules are in scope, so the parser cannot build a tree until imports are resolved — the parse of a module genuinely depends on other modules. Scala has the same property with a different rule, deriving precedence from the operator's first character. Both buy real expressiveness and both mean that syntax highlighting, formatting and incremental reparsing of a single file are no longer possible without a full compilation environment, which is a substantial tooling bill for a syntactic convenience.

How much this depends on

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

specThe cross-language table rows are normative: C's placement of & below == is in the C standard's grammar, Go's five-level precedence with shift beside * is in the Go specification, and Python's **-above-unary-minus rule is in the language reference. They are stable facts about those languages, not implementation behaviour, and they genuinely differ.
implementationWhich of the three mechanisms a compiler uses is an implementation detail invisible from the language. rustc and several JavaScript engines use Pratt-style binding powers while their specifications present layered grammars; the trees agree, and only the parser source differs.
typicalThe claim that parentheses leave no trace in the AST holds for mainstream compiler ASTs, where a parenthesised expression produces the same node as an unparenthesised one. It is false for concrete syntax trees used by formatters and language servers — rust-analyzer, Roslyn and tree-sitter all retain parenthesis nodes deliberately.

If you were asked this in an interview

  • Draw the AST for 1 + 2 * 3 and say which operator is the root and why.
  • In C, what does flags & MASK == 0 parse as, and what does the author almost certainly mean?
  • You are implementing a language with user-defined operators and user-defined precedence. What breaks?

Connections