DuckDB SQL
github.com/ornew/pego/parsers/duckdb parses the SQL dialect of DuckDB, as DuckDB 1.5.6
parses it: the statements of its grammar (a fork of PostgreSQL's) with the syntax that DuckDB adds (FROM-first
queries, PIVOT, QUALIFY, ASOF joins, lambdas, list comprehensions, struct and map literals, COLUMNS(...), GROUP BY ALL, SELECT * EXCLUDE ..., COPY, ATTACH, secrets and the rest), and the scanner that goes with it. The parser is
generated by PEGO from duckdb.pego. It depends only on the standard library, and not on DuckDB.
go get github.com/ornew/pego/parsers/duckdb
Use
// The script as typed values, each node with its Span in the input.
script, err := duckdb.ParseAST(`SELECT region, sum(amount) AS total FROM sales WHERE year = 2024 GROUP BY region`)
sel := script.Statements[0].(*duckdb.Select)
core := sel.Body.(*duckdb.SelectCore)
for _, item := range core.Items {
fmt.Println(item.Span.Start, item.Span.End) // 7 13, then 15 35
}
// The script as DuckDB cuts it: the statements, each with the text DuckDB keeps for it, and its type.
statements, err := duckdb.Split("CREATE TABLE t (a INT); INSERT INTO t VALUES (1), (2); FROM t")
for _, s := range statements {
fmt.Println(duckdb.Kind(s.Statement), s.Text) // CREATE, INSERT, SELECT
}
// Visit the nodes in the order of the input.
duckdb.Walk(script, func(n any) bool {
if t, ok := n.(*duckdb.BaseTable); ok {
fmt.Println(strings.Join(t.Name.Names(), "."))
}
return true
})
// Only check.
err = duckdb.Recognize("SELECT 1 UNION") // a *duckdb.SyntaxError with Line, Col, Pos
ParseAST(input, unit...) (*Script, error) |
The script: Statements, each a Select, InsertStmt, CreateTableStmt, ... (the Statement union), with expressions, types and table references below them, and their Span |
Split(input) ([]StatementText, error) |
The statements as DuckDB splits the script: Statement, Text, Start, End |
Kind(Statement) string |
The type DuckDB gives it: SELECT, INSERT, CREATE, ALTER, COPY, SET, ... |
Walk(node, fn), SpanOf(node) |
The nodes in the order of the input (fn returns false to skip the children), and the span of any node |
(*Ident).Name(), .Quoted(), .Fold(), .EqualFold(o) |
The name an identifier denotes: a word as written, "a ""b""" without its quotes, U&"d\0061ta" decoded; Fold is the name in lower case, the key by which DuckDB compares names |
(*Name).Names() []string |
The parts of a qualified name |
(*StringLit).Value(), .Kind() |
The string: '' decoded, continued segments joined, the escapes of E'...' and U&'...', the text of $tag$...$tag$ |
(*BitLit).Bits() |
The digits of B'101' and X'FF', and whether they are hexadecimal |
(*Number).Type(), .Int64(), .Float64(), .Digits(), .DecimalWidthScale() |
The type DuckDB gives the literal (INTEGER, BIGINT, HUGEINT, DECIMAL, DOUBLE), and its value without the _ that separate digits |
(*Param).Index(), .ParamName(), .Positional() |
$1 and ?1, $name, and ? |
Keyword(word), Keywords() |
The category of a keyword (Unreserved, ColumnName, TypeFunction, Reserved), as duckdb_keywords() says |
QuoteIdent(name) string |
The name written so that it reads back as one identifier: between double quotes unless it is a plain word that is not a keyword that forbids it |
Recognize(input, unit...) error |
Only checks the syntax |
Parse(input, unit...) (*Node, error) |
The tree of *Node, as the engine returns it |
*SyntaxError |
The position (Line, Col, Pos) and the expected tokens of a syntax error |
Positions are in code points by default; duckdb.ParseAST(src, duckdb.Bytes) counts bytes (Split uses bytes).
The syntax tree
The types follow the grammar of DuckDB, without the details of how the Bison grammar is organized:
- Statements:
Select(the query:With,Body,OrderBy,Limit,Offset,Locking; the body is aSelectCore,ValuesClause,TableQuery,PivotQuery,UnpivotQuery,SetOporParenQuery),InsertStmt,UpdateStmt,DeleteStmt,MergeStmt,CreateTableStmt,CreateTableAsStmt,CreateViewStmt,CreateIndexStmt,CreateSchemaStmt,CreateSequenceStmt,CreateTypeStmt,CreateMacroStmt,CreateSecretStmt,AlterTableStmtand the other ALTERs,DropStmt,CopyStmt,CopyDatabaseStmt,ExportStmt,ImportStmt,AttachStmt,DetachStmt,UseStmt,LoadStmt,PragmaStmt,SetStmt,ResetStmt,ShowStmt,ExplainStmt,PrepareStmt,ExecuteStmt,DeallocateStmt,TransactionStmt,VacuumStmt,AnalyzeStmt,CheckpointStmt,CallStmt,CommentStmtandUpdateExtensionsStmt. - Expressions (the
Exprunion): literals,ColumnRef,Starwith itsEXCLUDE,REPLACEandRENAME,Binary,Unary,Not,IsExpr,Between,In,Like,AnyAll,Cast,Case,FuncCall(withFILTER,ORDER BY,WITHIN GROUPandOVER),SpecialCall(EXTRACT,SUBSTRING,TRIM,POSITION, ...),Lambda,ListLit,StructLit,MapLit,ListComp,Indirection(field access, subscripts and slices),Subquery,Exists,IntervalLit,TypedLitand the rest. Operators areBinarynodes whoseOpis the text of the operator as the scanner cut it; the precedence is in the shape of the tree. - Tables (
TableRef):BaseTable,SubqueryRef,FuncTable,ValuesRef,Join,ParenTable,PivotRef,UnpivotRef, with their aliases, sampling andATclauses. - Types (
Type): the name as written, the modifiers, the fields ofSTRUCTandUNION, the key and value ofMAPand the array dimensions.
A rule that matches a keyword or a punctuation sign without building a node gives a Match, with the matched text;
fields of a node that hold such text (Op, Kind, Quantifier) are strings.
Names and literals
DuckDB keeps the case of an identifier and compares names without regard to the case of ASCII letters: compare
Fold() of two identifiers, or use EqualFold. A name is an Ident whose Text is as written: a word, a quoted
identifier, a U&"..." identifier, or a string where DuckDB accepts one as a name ('t' after AS, in COPY, ...).
Which words are names where is decided by the category of the keyword, as DuckDB does: year and type name
columns and functions, left and join name functions but not columns, select and from name nothing without
quotes except after AS and after a dot.
The scanner is part of the grammar. It reads nested /* */ comments, -- comments, $$...$$ and $tag$...$tag$
strings, E'...', U&'...', B'...' and X'...' literals, N'...', numbers with _ between digits (1_000), the operators as PostgreSQL's scanner cuts them (the trailing + and - of an operator
without a special character are not part of it), the parameters ?, ?1, $1 and $name, and the Unicode white space characters that DuckDB
replaces by spaces.
Conformance
The tests compare the parser with DuckDB 1.5.6 on 158,605 texts, all vendored in testdata/reference:
| Corpus | Texts | What it is |
|---|---|---|
tests |
45,912 | The statements and queries of the test/sql files of DuckDB v1.5.6 |
exprs |
7,655 | Expressions made of every pair of operators, for precedence and associativity |
lexical |
1,219 | Numbers, strings, identifiers, operators, comments and white space of the scanner |
keywords |
83,619 | Every keyword where a name may stand (column, table, alias, function, type, ...) |
mutants |
20,000 | Statements of the tests with a token deleted, duplicated, swapped, replaced or inserted, or cut short |
found |
200 | Texts on which an earlier version of the parser and DuckDB disagreed, found by 1.4 million more mutations (other seeds) and by trying the constructs around them: tables with * and ONLY, PIVOT without clauses, operators followed by NOT, LIMIT 10%, ... |
go test checks, for each text, what DuckDB's extract_statements says, and for each accepted text how it splits
the script; for the SELECT statements of tests and exprs it also compares the AST (below). The numbers (go test -v -run 'TestReference'):
| tests | exprs | lexical | keywords | mutants | found | |
|---|---|---|---|---|---|---|
| Both accept | 45,537 | 6,166 | 852 | 64,053 | 3,894 | 91 |
| Both reject (a syntax error) | 195 | 1,489 | 338 | 18,333 | 15,984 | 85 |
| DuckDB rejects after parsing, the parser accepts | 180 | 0 | 29 | 1,233 | 122 | 11 |
| Known deviations (testdata/deviations.jsonl) | 0 | 0 | 0 | 0 | 0 | 13 |
| The parser rejects what DuckDB accepts, other | 0 | 0 | 0 | 0 | 0 | 0 |
| The parser accepts what DuckDB rejects with a syntax error, other | 0 | 0 | 0 | 0 | 0 | 0 |
The parser and DuckDB agree on every text where DuckDB's parser itself finds a syntax error or none, but the thirteen
known deviations (below). The third row counts the texts that DuckDB's parser rejects in the step after the Bison
grammar, when it transforms the syntax tree into its own: the grammar cannot know. They are 1,575 texts, of which about 1,150 name a window that does not exist, 133 are
an empty select list in a context the grammar accepts (cte2 AS (SELECT )), and the others are checks such as
VALUES lists of different lengths (19), U&'...' and \u escapes, which DuckDB reads but does not implement (28), a
type modifier that is not a constant (13), LIMIT or ORDER BY in a recursive query (16), PIVOT ON NULL (11),
if() with another number of arguments than three (11), EXCLUDE or FILTER with a window function that does not
allow them (19), a foreign key with CASCADE (6), generated columns added to a table (6), SELECT INTO (4), FOR UPDATE (4), duplicate names in a macro, a CTE or an EXCLUDE list, and about fifty more that occur once or twice.
For the statements of the scripts that DuckDB accepts, the number of statements, their types and their texts agree
(Split and Kind): 44,949 scripts of tests (44,678 statements with the same text, and 523 scripts not compared
because DuckDB expands a statement into several: PIVOT makes a CREATE TYPE first, COPY FROM DATABASE is four
statements, and ALTER TABLE ... ADD COLUMN ... DEFAULT is five), 852 of lexical and 3,842 of mutants. The text DuckDB
keeps for a statement is what lies between the semicolons that surround it, comments and white space included, and with
the Unicode white space of the text replaced by spaces.
The AST
For a SELECT statement, json_serialize_sql returns the AST that DuckDB's transformer builds. The tests contain a
mapping (shape_*_test.go) that builds the same JSON from the syntax tree of this module, reduced to a canonical form
(no locations; no key whose value is null, empty or false), and compare the two. It reproduces what the transformer
does to the syntax: function names in lower case, count(*) as count_star, x IN (list) and x IN y, NOT turned into
the opposite comparison, BETWEEN, LIKE as ~~, a ~ b as regexp_full_match, IF as CASE, windows with their
frames and the named windows they refer to, the lambdas and list comprehensions as calls of list_apply and
list_filter, intervals as to_days(CAST(trunc(CAST('1' AS DOUBLE)) AS INTEGER)), ANY and ALL as subqueries,
GROUPING SETS, ROLLUP and CUBE as lists of sets, chains of UNION as a tree whose operands are paired from the
left, the type of every number literal from its digits, and so on.
| SELECT statements | not compared | compared | identical | |
|---|---|---|---|---|
tests |
28,031 | 919 | 27,112 | 27,110 |
exprs |
6,166 | 0 | 6,166 | 6,166 |
A statement is not compared when it has a construct that the mapping does not cover (the most common are PIVOT and
UNPIVOT, which DuckDB expands, SAMPLE clauses, LATERAL, the VARIANT and GEOMETRY types, WITHIN GROUP, ARRAY(...)
with an ORDER BY, and DESCRIBE). The two statements that differ are WITH RECURSIVE t(b) AS MATERIALIZED ((WITH helper(c) AS (SELECT 5) SELECT ... (the aliases that DuckDB puts in a recursive CTE written with a parenthesized WITH) and a NOT
applied to NOT BETWEEN in a fuzzer-made expression. The mapping is test code: it is not an API of the module.
The precedence and the associativity of the operators were checked further with 250,000 random expressions made with
other seeds than the corpus (REFERENCE_FILE, see testdata/README.md): the same acceptance, and the same
AST in all of the 114,000 SELECT statements that they make.
Known deviations and limits
- Thirteen texts (testdata/deviations.jsonl, each with its reason) on which the parser and DuckDB
differ in acceptance. Six come from the pass with which DuckDB replaces Unicode white space before it parses: it does
not close a dollar-quoted string when a letter follows the closing tag (
select $a$b$a$x<U+3000>x), so it leaves the white space inside the name that follows, where the parser reads a space. One is the spelling of an unreserved keyword (below). Six are constructs that are not understood: DuckDB accepts the wordsINandANDand, afterGROUPS BETWEEN, a lone dot as the first bound of a frame (beforePRECEDING), rejectsNOTat the start of that bound andSETOFbefore a qualified type, and accepts a percent sign after a postfix operator (LIMIT -1| %). The other differences that mutating the tests found, in 1.4 million texts, were fixed and are in the corpusfound. - The checks after parsing (above) are not made: a text such as
SELECT sum(x) OVER wwhere the windowwis not defined is accepted. A function that checks the most common of them is not part of this module. Splitand statements that DuckDB expands:PIVOT,COPY FROM DATABASEandALTER TABLE ... ADD COLUMN ... DEFAULTare one statement here and several for DuckDB, which also gives them other texts.- Input that is not UTF-8: DuckDB takes text, and the parser reads each invalid byte as U+FFFD in the positions of code
points (the default), where two different bytes are the same character (a dollar-quoted string can open with
$\xa5$and close with$\xb1$); withBytesthey are different, andSplitcounts bytes. PRAGMAstatements are rewritten by DuckDB into others (PRAGMA name = valueis aSET, a call is aSELECT);Kindof aPragmaStmtisPRAGMAand its text is the text as written.- Nesting is limited by the depth limit of the generated parser, 100,000 rule calls: 16,600 levels of parentheses,
14,200 of lists, 12,500 of
CASE, 11,100 of derived tables, 10,000 of function calls, 7,100 of subqueries and 99,980 prefix operators in a chain (NOT NOT ... a,- - ... a). Deeper input fails with an error. - Unreserved keywords as names: a word that is not a keyword at all is required in a few places (an alias without
AS, the dotted name of a type, the setting ofALTER DATABASE, ...). The 330 unreserved keywords are recognized there in lower case, upper case and capitalized (text,TEXT,Text) and not in any other spelling:SELECT 1 tExTis accepted as an alias, where DuckDB rejects it. The other keywords are matched in every case everywhere. Matching the 330 in every case would add 140,000 lines to the parser and half as much again to its compile time. - Compile time and size: the generated
parser.gois 13.2 MB (643,000 lines), because the generator writes about 50 lines for each node of the grammar, and the grammar has some 12,000 of them. The first build takes about a minute (63 s of wall-clock time and 112 s of CPU with an empty build cache, on the machine of the benchmarks); the Go build cache makes the next ones free. - DuckDB's extensions (the parsers that extensions add, such as PRQL) and the settings that change how DuckDB parses
(
SETof an option of the parser) are not covered.
Grammar
duckdb.pego follows the grammar of DuckDB (third_party/libpg_query/grammar, the Bison files) and its
scanner (scan.l). The ways in which it departs from the structure of the Bison grammar, where a PEG needs it or where
it measurably pays, are commented in the grammar:
- The scanner is in the grammar. A rule for a token matches the token only; white space and comments are
skipped (
ws) where a rule needs them. The operators are cut asscan.lcuts them, with the lookahead that decides where a run of operator characters ends. The keywords are rules of their own, generated by internal/refgen/keywords.py from DuckDB's lists, which also write the tries that tell, for each kind of name, whether a word is a keyword that the kind forbids. - Precedence and associativity are those of the
%left,%rightand%nonassocdeclarations, in aprattblock of levels: comparisons do not associate (a < b < cis an error, also on the right ofAND), the operators of theLIKE,INandBETWEENlevel do not either,->>has the precedence of a token that completes the expression on its left but takes an arithmetic expression on its right, and a postfix operator (a !) is read where the Bison grammar would shift it.NOTbeforeBETWEEN,IN,LIKE,ILIKEandSIMILAR,NULLSbeforeFIRSTandLAST, andWITHbeforeTIMEandORDINALITYare tokens of their own in the Bison grammar (NOT_LA,NULLS_LA,WITH_LA): the rules look at the word that follows. - Where a keyword may be a name is decided by its category (
unreserved,column_name,type_function,reserved), with the exceptions of the grammar:CUBE,ROLLUP,ENUM,BETWEENandOPERATORthat the parser shifts as keywords in some positions. - A rule has one action: where the Bison grammar gives alternatives different actions, they are rules of their own, so
that every node has its type. Optional parts are
*Tfields, repeated parts[]T. - Alternatives are tried only where they can match: an identifier that cannot begin a call, a typed literal or a
E'...'string is read by a rule that needs no lookahead (plain_column), the operators of an expression look at the character they begin with, and a function call is tried before the typed literal that looks like one (DECIMAL(10, 2) '1.5'). - Joins associate to the left, and
INTERSECTbinds tighter thanUNIONandEXCEPT, which associate to the left.
Performance
Parsing a script of 256 KB (1,271 statements: tables, CTEs with joins, aggregates, window functions, subqueries, CASE, casts,
lists and structs, set operations and DML; the benchmark writes it with BENCH_SCRIPT=file go test -run TestWriteBenchScript), go test -bench . on an Apple M3 Max:
| Time | Throughput | Allocations | |
|---|---|---|---|
ParseAST |
46.6 ms | 5.6 MB/s | 74,000 |
Recognize |
51.0 ms | 5.1 MB/s | 72,000 |
Parse |
68.3 ms | 3.8 MB/s | 79,000 |
DuckDB 1.5.6: extract_statements of the same script (Python, which includes making the statement objects) |
11.9 ms | 22 MB/s |
DuckDB's time is the best of ten runs of extract_statements (the parser of DuckDB, and the step that makes its
statement objects) on the script that the test wrote, with the Python module of DuckDB 1.5.6:
BENCH_SCRIPT=/tmp/bench.sql go test -run TestWriteBenchScript
python3 -c "
import duckdb, time
sql = open('/tmp/bench.sql').read()
con = duckdb.connect()
best = 1e9
for i in range(10):
t = time.perf_counter()
statements = con.extract_statements(sql)
best = min(best, time.perf_counter() - t)
print(len(statements), 'statements', round(best * 1000, 1), 'ms', round(len(sql) / best / 1e6), 'MB/s')"
DuckDB's parser is C++ and parses the same script about four times as fast. The time here is mostly the cost of a grammar
that has to decide, at each token, among the alternatives that the Bison automaton chooses by a table: the ordered choice
of a PEG tries them one after the other. In the CPU profile of ParseAST, about a tenth of the time is spent recording
what each failed alternative expected (for the error message), another tenth in the memo table, and another in the
allocator and the garbage collector. Recognize builds nothing and is not faster than ParseAST, whose rules the generator
compiles into direct code (see the code generation guide). Tuning the grammar took ParseAST from 4.1 MB/s to
5.6 MB/s: the operators of the expressions are tried only when the character that follows could begin them, a function call
is not parsed twice to find that no string literal follows it, IN, LIKE, IS and the rest are one operator each, and a
word is compared with the unreserved keywords that begin with its letter, not with all 330 (that alone made a CREATE TABLE
with a TEXT column 5 times as fast). BenchmarkStatements gives the speed on 35 kinds of statement, from 2.5 MB/s
(SELECT [1, 2, 3], {'a': 1, 'b': 2}, x[1], x.y) to 19 MB/s (ATTACH 'f.db' AS db).
Development
go generate ./parsers # regenerate parser.go after changing duckdb.pego (from the repository root)
go test ./parsers # parser.go up to date; golden files on every backend of the engine
cd parsers/duckdb && go test ./... # conformance against the vendored data, the examples, the other tests
go test -short # a fraction of the texts
go test -bench . # benchmarks
go test -fuzz FuzzParse # fuzz the parser (ParseAST and Recognize agree, nothing panics)
NESTING_LIMITS=1 go test -v -run TestNesting # the depth at which each construct fails
The reference data is made by internal/refgen and is described in testdata/README.md, with the command that makes it again from a checkout of DuckDB v1.5.6 and its Python module. Only the data is part of the module; DuckDB is not a dependency of anything that is built or tested here.