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.
Where does operator precedence actually live — the grammar, the parser, or a table — and how do I check what my language does?
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.
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 * 3builds 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.
1 + 2 * 3: the looser operator is the parentRead 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
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]].
| Mechanism | Precedence is | Adding an operator | Used by |
|---|---|---|---|
| Grammar layering | The nesting of nonterminals | A new nonterminal plus edits to two neighbours | Most published language specifications |
| Declared tableimplementation | %left / %right lines beside a flat rule | One declaration line | Bison and yacc grammars |
| Binding powersimplementation | Two numbers per token, consulted at run time | One table row | Pratt parsers; rustc, many JS engines |
| None | Strict right-to-left evaluation | Nothing to add | APL 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.
| Source | C / C++ | Go | Python |
|---|---|---|---|
a + b * c | a + (b * c) | a + (b * c) | a + (b * c) |
a + b << cspec | (a + b) << c | a + (b << c) | a + (b << c) |
x & mask == 0spec | x & (mask == 0) | (x & mask) == 0 | x & (mask == 0) |
-2 ** 2spec | no ** operator | no ** operator | -(2 ** 2) = -4 |
a == b == cspec | (a == b) == c | compile error: mismatched types | chained: 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 * ccomputes(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, becauseMASK == 0is 0 andflags & 0is 0. - A Go port of a C bit-manipulation routine keeps
a + b << cand 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')))"printsBinOp(left=Constant(1), op=Add(), right=BinOp(left=Constant(2), op=Mult(), right=Constant(3))).clang -Xclang -ast-dump -fsyntax-only x.cshows the tree C actually built — try it onx & mask == 0and read which operator is the outer one.gcc -Wparenthesesandclang -Wparentheseswarn 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 -vprints the resolved precedence for each conflicting state, so you can confirm a%leftdeclaration 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.
a() and b() are the operands of one +; in C and C++ nothing says which call happens first.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.
& 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.If you were asked this in an interview
- Draw the AST for
1 + 2 * 3and say which operator is the root and why. - In C, what does
flags & MASK == 0parse as, and what does the author almost certainly mean? - You are implementing a language with user-defined operators and user-defined precedence. What breaks?