Tokens, Parse Tree, AST
Before a query can be planned it must become a tree. The lexer cuts text into tokens, a recursive-descent parser builds the tree by the grammar, and semantic analysis resolves every name against the catalog — the same front end every compiler has.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
The engine receives
SELECT name FROM users WHERE id = 42;as 42 characters. It needs to know which table, which column, which predicate — and it needs to rejectSELEKT nmae FORM.↓ - Naive solution
Pattern-match the string: find the word after FROM, split on WHERE, split the rest on
=.↓ - Why it breaks
Nested subqueries,
AND/ORwith precedence, parentheses, string literals that contain the word FROM, aliases, joins. String hacking has no notion of structure, and every new clause breaks the last hack.↓ - Better idea
Define the language as a grammar and build a tree that mirrors it. A tree knows that
a = 1 AND b = 2 OR c = 3groups as(a=1 AND b=2) OR c=3because the grammar said so, once.↓ - Internal mechanism
A lexer produces tokens (keyword, identifier, literal, operator). A recursive-descent parser has one function per grammar rule;
parseSelectcallsparseFrom, which callsparseExpr, which climbs precedence levels. The result is an AST of tagged nodes. A binder then walks the AST and replaces names with catalog objects and types.↓ - Trade-offs
Hand-written parsers are fast and give good error messages but are large (PostgreSQL’s grammar is ~ 18,000 lines of yacc). The AST must be designed for what comes next — the planner — not for the text.
↓ - Real database
PostgreSQL uses flex + bison (
scan.l,gram.y) producing a raw parse tree, thenparse_analyze()produces a boundQuery. This platform’s engine is a hand-written recursive-descent parser insrc/db/sql/parser.tsover a lexer inlexer.ts.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
A query is a sentence in a small formal language. The parser is the part of the engine that reads that sentence and builds a diagram of it: SELECT at the top, the projection and the source table underneath, the predicate as a small subtree of its own.
Everything downstream — planning, optimization, execution — works on that diagram, never on the text again.
Tokens
The lexer reads the input one character at a time and emits tokens: the smallest units that mean something. SELECT name FROM users WHERE id = 42; becomes nine of them. Identifiers and keywords are the same token kind (ident) distinguished by one set lookup — select is in the keyword set, name is not — which is why keywords are reserved: the parser would otherwise have to guess whether select names a column. String literals keep their quotes stripped and their '' escapes resolved; numbers are recognised by shape; operators are matched longest-first so <= is one token, not two.
The lexer knows nothing about grammar. FROM WHERE SELECT 42 42 42 tokenises without complaint. Its only errors are characters it cannot classify (Unexpected character "@") and unterminated strings.
SELECT name FROM users WHERE id = 42; kind value pos ident select 0 ← keyword (in KEYWORDS) ident name 7 ident from 12 ← keyword ident users 17 ident where 23 ← keyword ident id 29 op = 32 number 42 34 punct ; 36 eof 37
Recursive descent and precedence
A recursive-descent parser has one function per rule of the grammar, and each function consumes the tokens its rule describes, calling other rule functions for the parts it does not handle itself. parseStatement looks at the first token and dispatches to parseSelect; parseSelect expects SELECT, calls parseSelectList, then if it sees FROM calls parseFrom, then if it sees WHERE calls parseExpr. The call stack *is* the parse: when parseExpr is three levels deep inside parseFrom inside parseSelect, that is exactly where the expression sits in the tree.
Precedence is what makes expressions the interesting part. id = 42 AND active OR deleted must group as (id = 42 AND active) OR deleted, and price * 2 + tax as (price * 2) + tax. The parser encodes this as a ladder: parseOr parses parseAnd terms separated by OR; parseAnd parses parseNot terms separated by AND; below that comparison, then + -, then * / %, then unary minus, then primaries (literals, columns, parenthesised expressions, function calls, subqueries). Each level binds tighter than the one above it, so the tree comes out grouped correctly with no special-casing — the grammar was written once and the recursion does the rest.
1class Parser {2 private t: Token[]; private p = 03 constructor(sql: string) { this.t = tokenize(sql) }4 5 private at(value: string, kind = 'ident') { const tk = this.t[this.p]; return tk.kind === kind && tk.value === value }6 private eat(value: string, kind = 'ident') { if (this.at(value, kind)) { this.p++; return true } return false }7 private expect(value: string, kind = 'ident') { if (!this.at(value, kind)) this.fail(`expected "${value}"`); return this.t[this.p++] }8 9 parseStatement(): Statement {10 if (this.at('select')) return this.parseSelect()11 if (this.at('explain')) { this.p++; return { k: 'explain', analyze: this.eat('analyze'), stmt: this.parseStatement() } }12 this.fail('expected SELECT, INSERT, UPDATE, DELETE, CREATE INDEX or EXPLAIN')13 }14 15 parseSelect(): SelectStmt {16 this.expect('select')17 const columns = this.parseSelectList()18 const from = this.eat('from') ? this.parseFrom() : undefined19 const where = this.eat('where') ? this.parseExpr() : undefined20 // GROUP BY, HAVING, ORDER BY, LIMIT follow the same pattern21 return { k: 'select', core: { distinct: false, columns, from, where, groupBy: [] }, setOps: [], orderBy: [] }22 }23 24 // precedence ladder: OR < AND < NOT < comparison < + - < * / < unary < primary25 parseExpr(): Expr { return this.parseOr() }26 parseOr(): Expr { let l = this.parseAnd(); while (this.eat('or')) l = { k: 'bin', op: 'or', l, r: this.parseAnd() }; return l }27 parseAnd(): Expr { let l = this.parseNot(); while (this.eat('and')) l = { k: 'bin', op: 'and', l, r: this.parseNot() }; return l }28 parseComparison(): Expr {29 const l = this.parseAdditive()30 for (const op of ['=', '<>', '<=', '>=', '<', '>'] as const) if (this.eat(op, 'op')) return { k: 'bin', op, l, r: this.parseAdditive() }31 return l32 }33 parsePrimary(): Expr {34 const tk = this.t[this.p++]35 if (tk.kind === 'number') return { k: 'lit', v: Number(tk.value) }36 if (tk.kind === 'string') return { k: 'lit', v: tk.value }37 if (tk.kind === 'ident') return { k: 'col', name: tk.value } // a.b handled with one more token of lookahead38 this.fail('expected an expression')39 }40}The AST
The output is an abstract syntax tree: abstract because it drops what the grammar needed but the meaning does not — parentheses, commas, the keyword FROM itself. What remains is structure. The root is a select node; its core holds the projection list, the source, and the predicate; the predicate is a bin node with operator = and two children, a column reference and a literal.
Every node in this platform is a tagged union with a k discriminator (src/db/sql/ast.ts), which is what lets the planner ask questions like "is this predicate col = lit on a column I have an index for?" with a two-line pattern match. The same tree, drawn the way the spec draws it:
SELECT
├── projection
│ └── name
├── table
│ └── users
└── predicate
└── =
├── id
└── 42
-- as this platform's parser produces it (parseOne):
select
└── core
├── columns[1]
│ └── expr: col name="name"
├── from: table name="users"
└── where: bin op="="
├── l: col name="id"
└── r: lit v=42Semantic analysis: the binder
The parser has produced a tree with names in it, and names are only text. Semantic analysis — the analyzer in PostgreSQL, the binder in SQL Server and most textbooks — walks the tree with the catalog and gives every name a meaning. users is looked up and becomes a table object: OID, column list, row count, indexes. name and id are looked up in that table’s columns and become attribute numbers with types. id = 42 is type-checked: int = int exists; int = text does not, and PostgreSQL says so rather than guessing (operator does not exist: integer = text).
The binder is where most "SQL errors" actually come from: relation "user" does not exist, column "nmae" does not exist, column reference "id" is ambiguous (two tables in FROM both have one), missing FROM-clause entry for table "o". Each is a lookup that failed. The binder also handles scope: a correlated subquery may reference columns of the outer query, so scopes form a chain and lookup walks outward, innermost first.
After binding, the tree is no longer about text. Every expression has a type; every column is a number; every table is an object with statistics attached. That is the input the planner needs — The Planner: Enumerating Ways to Answer starts from here.
scope (FROM): u → users (oid 16391, 900 rows, 8 columns)
o → orders (oid 16402, 3,200 rows, 6 columns)
u.name → users.name attnum 3 text
o.total → orders.total attnum 4 numeric
o.user_id → orders.user_id attnum 2 int
u.id → users.id attnum 1 int
u.id = 42 → int = int ✓ (operator oid 96)
errors this stage would raise:
o.totl column "o.totl" does not exist
x.y missing FROM-clause entry for table "x"
id (bare) column reference "id" is ambiguousKey points
- Lexer, parser, binder: three passes with three concerns — characters, grammar, meaning. Each catches only the errors its artefact can express.
- Recursive descent maps grammar rules to functions; the call stack is the parse. Precedence is a ladder of functions, one per level, so grouping comes out right without special cases.
- The AST drops syntax and keeps structure. Tagged nodes (
k: 'bin',k: 'col') let the planner pattern-match predicates instead of parsing text. - The binder turns names into catalog objects and types. "Column does not exist" is a binder error, not a parse error — the text was grammatical.
- This is the same front end every compiler has; the only difference is the target: a plan tree instead of machine code.
Tokens, parse tree, AST
The lexer reads characters and emits tokens: keywords and identifiers (both lower-cased, one set-membership test apart), string and number literals, operators, punctuation. It knows nothing about grammar — FROM WHERE SELECT tokenises without complaint.
Try it in the playground
SELECT name FROM users WHERE id = 42;
EXPLAIN SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id WHERE u.id = 42;
When to use — and when not
- A hand-written recursive-descent parser fits when you own the grammar, want precise error messages, and the language is stable — SQL dialects, configuration languages, query DSLs.
- A separate binder fits whenever names can be resolved only with external state (a catalog, a symbol table).
- A parser generator (yacc/bison, ANTLR) fits better when the grammar is huge and changes often — PostgreSQL’s 18,000-line grammar would be unmaintainable by hand.
- A tree is overkill for a fixed-shape command language (
GET key,SET key value): Redis parses with a few string operations and needs no AST.
Failure modes
- Reserved words as identifiers:
SELECT user FROM …fails becauseuseris a keyword; quoting ("user") is the escape hatch. - Precedence surprises:
WHERE a = 1 OR b = 2 AND c = 3groups AND first; without parentheses the result is not what the author meant. - Implicit casts hiding type errors:
WHERE id = '42'works in engines that coerce and silently disables index use in some others. - Ambiguous column after adding a column to a joined table: a query that bound fine yesterday fails today with "column reference is ambiguous".
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- CompilersLexer / parser / AST → SQL front endThere is no Compilers domain on the platform yet, but the machinery is identical to every language front end: tokens, a grammar, a tree, a symbol table.
- DSARecursion and the call stack → Recursive-descent parsing
- DSATree traversal → Binder walking the AST