(Darkwing Duck, the terror that flaps in the night, for DuckDB)
darkwing is a hand-written, zero-dependency Go port of DuckDB v2.0's new PEG-based SQL parser, in the same family as:
- sqlc-dev/oliphant — PostgreSQL (libpg_query)
- sqlc-dev/marino — MySQL (TiDB fork, goyacc)
- sqlc-dev/meyer — SQLite (Lemon)
- sqlc-dev/teesql — T-SQL (SQL Server)
- sqlc-dev/zetajones — GoogleSQL (BigQuery)
- sqlc-dev/doubleclick — ClickHouse
Its immediate purpose is to power a first-class DuckDB engine in sqlc. Unlike the siblings, there is no incumbent parser to replace — DuckDB is a new dialect for sqlc, so the public API and AST can be designed for sqlc's needs from day one.
DuckDB v2.0 replaces its PostgreSQL-derived YACC parser with an in-house PEG parser (merged in duckdb/duckdb#22194; announced in the v2.0 preview and described in depth in "Your Database Deserves a Better Parser", building on the 2024 runtime-extensible parsers post and a CIDR 2026 paper). The old libpg_query parser is deleted from the tree.
Everything lives under src/parser/peg/ in the DuckDB repo:
| Component | Upstream | Size |
|---|---|---|
| Grammar | grammar/statements/*.gram (39 files) |
~1,400 lines |
| Keyword lists | grammar/keywords/*.list (5 files) |
528 keywords |
| Tokenizer | tokenizer/base_tokenizer.cpp + parser_tokenizer.cpp |
~600 lines |
| Grammar loader | peg_parser.cpp, parsed_grammar.cpp |
~330 lines |
| Matcher | matcher_factory.cpp, matcher.cpp, matcher/*.hpp |
~550 lines + headers |
| Transformer | transformer/*.cpp (46 files) |
~48,000 lines |
Initial pin: DuckDB main at a recorded commit hash (v2.0 is not tagged
yet as of 2026-08-20; the parser extension API is explicitly preview-status).
The pin advances to the v2.0.0 tag the week it lands, via the § Regeneration
workflow — as in oliphant, "advance the pin" is a first-class workflow, not an
event. Until 2.0 ships, expect grammar churn and budget one pin advance per
upstream nightly we choose to chase (weekly is plenty).
The siblings are hand-written recursive descent because their upstream
grammars are toolchain artifacts (Lemon, YACC, ANTLR): the only way to be
zero-dependency was to re-express the grammar as Go functions. DuckDB is
different in kind: upstream's own production parser is an interpreter over
a machine-readable grammar text. DuckDB parses the .gram files at startup
into rule objects, compiles them to a matcher tree, and matches tokens
against it. The grammar text is not an input to a build step — it is the
parser's data.
darkwing therefore ports the architecture faithfully instead of flattening it into recursive descent:
- Vendor the grammar verbatim. The
.gramand.listfiles are copied unmodified from the pinned DuckDB commit and embedded withgo:embed. - Port the engine. Tokenizer, grammar loader, matcher (with packrat memoization) — ~1,500 lines of C++ that become a comparable amount of Go.
- Hand-write the transformer. Generic parse tree → typed Go AST. This is the bulk of the work, exactly as it is the bulk of upstream (48k lines).
Why this beats recursive descent here:
- Accept/reject conformance is structural. Whether darkwing accepts a statement is decided entirely by the engine + the vendored grammar — the transformer only shapes the tree. The full corpus accept/reject gate can go green in milestone 2, before a single transformer exists. A recursive descent port cannot separate those concerns.
- Pin advances are diffs, not rewrites. When DuckDB 2.1 adds syntax, the grammar diff is re-vendored mechanically; only genuinely new rules need new transformer functions. Recursive descent would need every grammar change re-derived by hand. This matters more for DuckDB than any sibling dialect: the whole point of the PEG parser is that DuckDB's grammar now evolves quickly (extensions add syntax at runtime).
- PEG semantics come for free. Ordered choice ("first match wins",
specific-before-general ordering like
YEAR TO MONTHbeforeYEAR) is exactly what the engine does; replicating those commitment points manually is where a hand-port would silently diverge.
What does not change from the family playbook: zero dependencies (the
engine is hand-written Go; no parser generator, no codegen step in the build,
go.mod is the module line and the Go version), and the grammar files carry
provenance the same way meyer vendors parse.y — except here they are
load-bearing, so they are covered by the conformance suite rather than by
documentation tests.
One upstream quirk is a hard conformance rule: negative lookahead (!) is
parsed but ignored by DuckDB's matcher (an acknowledged upstream TODO).
darkwing must replicate the effective behavior of the pinned build, not
textbook PEG semantics — so darkwing's matcher ignores ! too, with a test
pinning that choice and a note to flip it in lockstep when upstream does.
A port of base_tokenizer.cpp. DuckDB's PEG matcher is token-level, not
character-level: the tokenizer runs first, strips comments, and emits
tokens with byte offsets; the matcher never sees raw text. Lexical territory
(PostgreSQL-flavored, with DuckDB additions):
'...'strings (doubled-quote escape),E'...'escape strings,"..."quoted identifiers, dollar-quoted strings$tag$...$tag$(empty and named tags, the$[0-9]disambiguation rule:$1is a parameter, never a dollar-quote opener).- Parameter markers, first-class for sqlc:
?(positional),$1(numbered),$name("collabel" parameters — the tokenizer decides$foois a parameter only when no closing$follows a valid tag). - Numbers (ints, decimals, scientific, hex, underscore separators),
operators (PostgreSQL-style multi-char operator tokens,
::casts,->/->>JSON operators,**,//),$as a valid non-initial identifier character. --line and/* */block comments (nesting per upstream), filtered before matching but never attributed to a token's span.- Keywords are case-insensitive; the token records the keyword's category (see § Keywords) plus its raw spelling and byte span.
The tokenizer also underpins upstream's autocomplete/highlighting; darkwing
ports only the parsing tokenizer (parser_tokenizer.cpp behavior).
A port of peg_parser.cpp + parsed_grammar.cpp: parse the embedded .gram
text into rule objects at package init (sync.Once, then immutable and
goroutine-safe). Grammar syntax to support, per upstream's
src/parser/peg/README.md:
Rule <- definition, sequences, ordered choice/,?*+, grouping parens,#comments, rules starting with%(%whitespace).- Parameterized rules (macros):
List(D) <- D (',' D)* ','?andParens(D) <- '(' D ')'fromcommon.gram, with nesting (Parens(List(Expression))). - Single-quoted literals (case-insensitive keyword/operator matches),
<[a-z_]i[a-z0-9_]i*>character-class captures,!negative lookahead (parsed, ignored — see above). - Duplicate rule names are a load-time error; loading the vendored grammar successfully is itself a test.
A port of matcher_factory.cpp + matcher.cpp: compile rules into a matcher
tree (list/choice/optional/repeat/keyword/literal matchers), then match the
token stream producing a generic parse tree (ParseResult: list, choice,
optional, repeat, identifier, string, number, keyword — mirroring upstream's
types since the transformer indexes into them by grammar position).
- Packrat memoization to kill exponential backtracking (upstream
memoizes designated hot rules —
LogicalOrExpression,ComparisonExpression,AdditiveExpression, etc.; darkwing ports the same list and keeps the mechanism general). Upstream's motivating case — 19 unmatched(going from 10.6 s to 1 ms — becomes a benchmark. - Rule overrides: special tokens handled natively rather than by grammar
expansion —
Identifierand its context/category variants (ColId,TableName,ReservedColumnName,FunctionName, …),StringLiteral,NumberLiteral,OperatorLiteral,EndOfInput. The override list is transcribed frommatcher_factory.cppand checked by a test that walks the vendored grammar for special rules with no definition. - Error reporting: furthest-failure position + expected-token set, the
basis for
Parser Error: syntax error at or near "X"with line/column — matching the pinned build's messages and positions is a conformance goal (§ Oracle), and the blog markets the improved errors, so fidelity here is user-visible.
The real work: one transformer function per grammar rule that produces an AST
node, registered in a map[string]transformFunc keyed by rule name
(upstream's REGISTER_TRANSFORM pattern; upstream generates trampolines from
grammar_types.yml, darkwing hand-writes the registration table — a
cmd/-level dev tool cross-checks that every rule reachable from Statement
either has a transformer or is consumed structurally by its parent). Each
function carries an attribution comment naming its upstream
transform_*.cpp counterpart, zetajones-style.
DuckDB classifies every keyword into exactly one of five categories
(reserved 75, unreserved 339, column_name 54, func_name 29,
type_name 31 — 528 total), and the grammar's identifier rules consume them
through category-aware special tokens (ColId accepts unreserved +
column-name keywords; FunctionName accepts func-name keywords; plain
Identifier rejects reserved keywords; ReservedIdentifier variants accept
even reserved ones in qualified positions, e.g. catalog.schema."select").
This is the PEG analog of PostgreSQL's col_name_keyword/type_func_name_keyword
split and is most of the accept/reject battle. The .list files are vendored
and embedded; the exactly-one-category invariant is a load-time test. The
keyword table is also what sqlc's reserved.go generation will consume.
The grammar is compact but the dialect is broad — 37 top-level statement types including DuckDB natives with no sibling precedent:
- FROM-first selects (
FROM t SELECT x, bareFROM t),SELECT * EXCLUDE (...) REPLACE (...) RENAME (...), star with lambda filters,COLUMNS(...),QUALIFY,SAMPLE/USING SAMPLE,GROUP BY ALL/ORDER BY ALL,UNION BY NAME,DISTINCT ON, WITH ... USING KEY, prefix aliases (x: expr),TABLE t, chained comparison bans, trailing commas. - Bare expression statements (
Statement's final alternative —1 + 1is a valid statement),SUMMARIZE,DESCRIBE/SHOW. - PIVOT / UNPIVOT / PIVOT_WIDER / PIVOT_LONGER (both the statement forms
and the table-ref forms),
MERGE INTO. ATTACH/DETACH/USE/CONNECT/DISCONNECT,COPY ... TO/FROM(option soup),EXPORT/IMPORT DATABASE,INSTALL/LOAD/UPDATE EXTENSIONS,CREATE MACRO/SECRET/SEQUENCE/TYPE,CHECKPOINT,TRUNCATE,COMMENT ON,VACUUM,SET/RESET(+ scoped variants),PRAGMA(call and assignment forms), prepared statements (PREPARE/EXECUTE/DEALLOCATE).- Expression layer: lambdas (
x -> x + 1),TRY_CAST,::casts, list/struct/map literals, list comprehensions, slicing (l[1:3]),AT TIME ZONE,WITHIN GROUP,FILTER,IGNORE/RESPECT NULLS, named function arguments (:=and=>), interval qualifiers (YEAR TO MONTH),POSITION(x IN y), star expressions inside function calls (count(*)is grammar, not special case).
The precedence ladder is expressed in the grammar
(Expression <- LambdaArrowExpression, descending through OR → AND → NOT →
IS → comparison → BETWEEN/IN/LIKE → collate/at-time-zone → additive →
multiplicative → exponentiation → prefix/postfix → indexing/casting), so
darkwing inherits it from the vendored text rather than implementing it —
another argument for the engine port.
| repo | golden output | generated by |
|---|---|---|
| zetajones | ASTNode::DebugString trees |
vendored upstream testdata |
| teesql | ScriptDom JSON | bundled C# tool |
| doubleclick | EXPLAIN AST text |
pinned ClickHouse binary |
| meyer | accept/reject + message + offset | pinned SQLite build |
| oliphant | pg_query protobuf JSON | pinned libpg_query build |
| darkwing | accept/reject + message/position, plus json_serialize_sql JSON for selects |
pinned DuckDB binary |
Corpus. DuckDB's own test suite: test/sql/**/*.test (~4,700 files,
sqllogictest dialect — statement ok / statement error (+ expected-message
fragment after ----) / query blocks). cmd/regenerate extracts every SQL
statement with a small sqllogictest reader (skipping loop/foreach
template substitutions, keeping everything literal), plus the
test/fuzzer/ regression corpus as the robustness set. Consolidated
meyer-style storage — one corpus file per upstream file, cases separated by
==, SQL and expectation separated by --, one metadata.json todo/skip
sidecar per file; single-digit MB, never doubleclick's 127k-file explosion.
Expected outputs are never edited by hand.
Accept/reject oracle. Every extracted statement runs through the pinned
official DuckDB CLI against :memory:. Classification: error message
starting with Parser Error → must-reject (message + position recorded);
success or any post-parse error (Binder Error, Catalog Error, …) →
must-accept. Same rule as meyer: statements that die after parsing are
must-accept for darkwing. Extension-gated syntax is whatever the pinned
binary's parser accepts (the grammar is monolithic at parse time; autoloading
affects binding, not parsing).
Tree-shape oracle. DuckDB can dump parse trees, unlike SQLite:
json_serialize_sql('SELECT ...') emits the internal AST as JSON (SELECT
statements only). Because darkwing's AST deliberately mirrors DuckDB's node
shapes (§ AST), internal/serialize renders darkwing's tree in
json_serialize_sql-compatible JSON and diffs it against vendored oracle
output for the SELECT subset of the corpus — doubleclick's EXPLAIN-AST
technique upgraded to structured JSON. Volatile fields (query_location
differences, version stamps) are normalized by the comparator, with each
normalization documented. For non-SELECT statements, meyer's substitutes
apply: self-maintained AST snapshots (internal/dump, human-reviewed,
regenerated by tooling) and the round-trip property
(parse(render(parse(x))) == parse(x) via a test-only ast.String()
renderer that promises re-parseability, nothing more).
Differential loop. cmd/difftest mutates corpus seeds and compares
darkwing's accept/reject/message against the pinned binary — the strongest
check on the matcher's ordered-choice fidelity, and cheap to run for hours in
CI's cron lane.
Package ast, DuckDB-native naming — node names follow DuckDB's parsed
statement classes so the transformer and serializer read side-by-side with
upstream, and sqlc's future convert.go owns the mapping to sqlc's sql/ast:
- Interfaces:
Node(Pos,End,Children),Stmt,Expr,TableRefwith unexported marker methods; every node embedsSpan{Start, End int}(byte offsets), JSON tags on everything. - Statements (~37):
SelectStatement(wrapping aQueryNodetree:SelectNode,SetOperationNode,RecursiveCTENode,ValuesNode— DuckDB's shape),InsertStatement(+OnConflictInfo,RETURNING, BY NAME/POSITION),UpdateStatement,DeleteStatement(+ USING),MergeIntoStatement,CreateStatementvariants (table / view / index / schema / sequence / type / macro / secret),AlterStatement,DropStatement,CopyStatement,AttachStatement/DetachStatement,UseStatement,PivotStatement/UnpivotStatement,CallStatement,PragmaStatement,SetStatement/ResetStatement,ExplainStatement,PrepareStatement/ExecuteStatement/DeallocateStatement,TransactionStatement,ExportStatement/ImportStatement,LoadStatement/InstallStatement,VacuumStatement,CheckpointStatement,TruncateStatement,CommentOnStatement,AnalyzeStatement,DescribeStatement/ShowStatement,ExpressionStatement. - Expressions (~22, DuckDB's
ParsedExpressionclasses):ColumnRefExpression,ConstantExpression,ParameterExpression(kind + number + name preserved — the single most important node for sqlc's parameter inference),FunctionExpression(with DISTINCT, ORDER BY, FILTER, WITHIN GROUP, EXPORT_STATE, IGNORE/RESPECT NULLS),OperatorExpression,ComparisonExpression,ConjunctionExpression,CastExpression,CaseExpression,SubqueryExpression,WindowExpression,StarExpression(EXCLUDE/REPLACE/RENAME/COLUMNS),LambdaExpression,BetweenExpression,CollateExpression,PositionalReferenceExpression(#1),DefaultExpression, plus literal structs for list/struct/map/interval. - Table refs:
BaseTableRef(+ AT clause for time travel),JoinRef(incl. ASOF/POSITIONAL/ANTI/SEMI/LATERAL),SubqueryRef,TableFunctionRef,PivotRef,ValuesRef,ShowRef,EmptyTableRef. - Supporting:
TypeName(DuckDB's rich types:STRUCT(...),MAP(...),UNION(...),LIST/[]suffixes,DECIMAL(p,s), enums),ColumnDef, constraints,CommonTableExpr(+ USING KEY, MATERIALIZED),OrderModifier/LimitModifier,SampleOptions,WindowDef/frames,CopyOptions,GenericOption(the COPY/ATTACH/SECRET option soup).
Spans are load-bearing exactly as in meyer: sqlc slices original source by
statement span to find -- name: comments, so Parse returns statements
whose spans tile the input (empty statements skipped, never breaking span
accounting), and parser.Error carries byte offset + rendered
Parser Error: syntax error at or near "X" with line/column.
Mirrors the family so sqlc integration is uniform:
package parser // github.com/sqlc-dev/darkwing/parser
func Parse(ctx context.Context, r io.Reader) ([]ast.Stmt, error)plus ParseStatement / ParseExpr helpers. The engine
(internal/grammar, internal/matcher) stays internal in v1; if DuckDB's
extension-grammar story matures, exposing rule injection is a v2 decision,
not a v1 API commitment.
DuckDB is MIT (grammar, source, and tests alike) — clean, like meyer's
public-domain situation but with attribution. darkwing is MIT, copyright
the sqlc authors, with LICENSE.DUCKDB reproducing upstream's MIT notice.
Vendored grammar/keyword files and the extracted corpus record provenance
(upstream commit hash + SHA-256) in internal/grammar/README.md and
parser/testdata/README.md.
darkwing/
├── go.mod # github.com/sqlc-dev/darkwing — no deps
├── LICENSE # MIT
├── LICENSE.DUCKDB # upstream MIT notice
├── PLAN.md # this file
├── CLAUDE.md # dev-loop guide (next-test → implement → -check-parse)
├── token/ # Kind, Token{Kind, Text, Span}, keyword categories
├── lexer/ # port of base_tokenizer.cpp
├── ast/ # nodes, Span, Children, test renderer (String)
├── parser/
│ ├── parser.go # Parse/ParseStatement/ParseExpr, statement splitting
│ ├── transform.go # transformer registry + dispatch
│ ├── transform_select.go # one file per upstream transform_*.cpp cluster
│ ├── transform_expr.go
│ ├── transform_ddl.go … transform_misc.go
│ ├── parser_test.go # corpus harness (+ -check-parse)
│ ├── serialize_test.go # json_serialize_sql goldens (SELECT subset)
│ ├── errors_test.go # message/position fidelity
│ ├── roundtrip_test.go, span_test.go, snapshot_test.go, fuzz_test.go
│ ├── grammar_test.go # every reachable rule transformed or consumed
│ ├── bench_test.go # incl. the 19-parens packrat benchmark
│ └── testdata/ # README (provenance), corpus, metadata sidecars
├── internal/
│ ├── grammar/ # embedded .gram/.list files + loader (peg_parser.cpp)
│ ├── matcher/ # matcher tree, packrat, ParseResult (matcher*.cpp)
│ ├── serialize/ # json_serialize_sql-compatible JSON renderer
│ ├── dump/ # AST snapshot renderer
│ ├── sqltest/ # sqllogictest .test reader
│ ├── testfile/ # corpus file format reader/writer
│ └── duckdbsrc/ # pinned release: download, checksum, oracle runner
└── cmd/
├── next-test/ # pick next todo case
├── debug-parse/ # parse argv SQL: tokens, raw ParseResult, or AST
├── difftest/ # mutation differential testing vs pinned binary
└── regenerate/ # re-vendor grammar + extract corpus + run oracle
- Engine — module, license, CI (
go build+go test -race), token package with the five keyword categories, tokenizer port with unit tests transcribed from upstream tokenizer behavior, grammar loader over the vendored.gram/.listfiles (loading cleanly is the gate), matcher with packrat + rule overrides,cmd/debug-parsedumping rawParseResult. - Corpus + accept/reject green —
cmd/regenerate(sqllogictest extraction + pinned-binary oracle), corpus harness with todo metadata. Because accept/reject needs no transformers, this milestone ends with the headline gate: darkwing accepts iff pinned DuckDB parses, corpus-wide. Grammar-engine bugs surface here, while the surface area is still small. - AST + transformer core —
astpackage, expressions and the full SELECT tree (query nodes, table refs, windows, CTEs, PIVOT refs);internal/serialize+json_serialize_sqlgoldens go green over the SELECT subset. - DML + DDL transformers — INSERT/UPDATE/DELETE/MERGE with RETURNING and parameters end-to-end; CREATE/ALTER/DROP families, types, constraints.
- DuckDB-native statements — COPY/ATTACH/SET/PRAGMA/macros/secrets/
EXPORT/extension statements; snapshot coverage for everything
json_serialize_sqlcan't see. - Hardening — error message/position fidelity over the negative corpus,
cmd/difftestfuzzing, benchmarks (parse throughput + pathological backtracking), race-clean parallel corpus run. - Pin advance to v2.0.0 final — re-vendor grammar, re-derive corpus expectations, review diffs; this rehearses the § Regeneration workflow that every later DuckDB release will use.
- sqlc integration (in the sqlc repo, separate effort) — new
internal/engine/duckdb: catalog defaults,convert.go(darkwing AST →sql/ast),reserved.gofrom darkwing's keyword table, parameter inference for?/$1/$name, endtoend fixtures. New engine, so scope is minimal-viable (queries + DDL sqlc understands), gated behindengine: "duckdb".
- Not a formatter/pretty-printer (the test renderer promises only re-parseability).
- No semantic analysis: no binding, no catalog awareness, no type checking. Errors the pinned binary raises post-parse are out of scope by definition.
- No error recovery / multi-error reporting in v1 (matches upstream and all siblings).
- No runtime grammar extension API in v1 — the engine could support it, but upstream's extension API is preview-status; revisit after it stabilizes.
- No port of autocomplete/highlighting (upstream builds them on the same tokenizer; out of scope here).