Native N-API PostgreSQL query parser β a memory-efficient alternative to the WASM build.
The WASM build (@libpg-query/parser) carries a structural memory cost: WebAssembly
linear memory only ever grows. Once a large parse expands the heap, that memory is
never returned to the OS, so a process that parses one big query keeps the high-water
mark for its lifetime, and repeated large parses ratchet RSS upward monotonically.
No allocator choice can change this β it's a property of the WASM memory model.
The native build removes that ceiling: it uses the host allocator, so freed memory can
actually be returned to the OS. Pairing it with jemalloc (via LD_PRELOAD /
DYLD_INSERT_LIBRARIES) roughly halves peak RSS and, more importantly, keeps it
stable across repeated large parses instead of ratcheting.
Parsing a 3.31 MB SQL query (1500Γ UNION ALL, ~65 MB JSON parse tree), 3 parse/free
cycles, darwin-arm64, Node 24. Each backend measured in its own process. "Retained"
is RSS after the result is dropped and GC settles; throughput is a small query Γ10k.
| Backend | idle RSS | peak RSS (max of 3) | retained after free | throughput |
|---|---|---|---|---|
WASM (@libpg-query/parser) |
93 MB | 1359 MB | +1202 MB (never shrinks) | 125k/s |
| Native β system malloc | 53 MB | 932 MB | +812 MB (ratchets up) | 139k/s |
| Native β jemalloc | 55 MB | 498 MB | +377 MB (stabilizes) | 139k/s |
Per-cycle peak progression:
WASM: 1261 β 1359 β 1359 MB (plateaus at a high permanent floor)
Native system: 649 β 867 β 932 MB (fragments, still climbing)
Native jemalloc: 381 β 497 β 498 MB (flat after cycle 2)
WASM linear memory only ever grows, so ~1.2 GB from one big parse is held for the
process lifetime. Native + system malloc is lower but still ratchets. Native +
jemalloc has ~2.7Γ lower peak than WASM, returns freed pages to the OS, and stabilizes.
MALLOC_CONF=dirty_decay_ms:0,muzzy_decay_ms:0 trims peak a little further (~477 MB).
Throughput is identical across allocators β jemalloc is a pure memory win.
Reproduce with node --expose-gc benchmark/memory.mjs --all --cycles 3 (with
@libpg-query/parser installed) and bash benchmark/compare-allocators.sh --cycles 3.
npm install @ashbyhq/libpg-query-nativePlatform-specific binaries ship as separate packages and are installed
automatically via optional dependencies. Each declares os/cpu/libc, so
npm and Yarn install only the one matching the host:
| Package | os |
cpu |
libc |
|---|---|---|---|
@ashbyhq/libpg-query-native-darwin-arm64 |
darwin | arm64 | β |
@ashbyhq/libpg-query-native-linux-x64 |
linux | x64 | glibc |
@ashbyhq/libpg-query-native-linux-arm64 |
linux | arm64 | glibc |
@ashbyhq/libpg-query-native-linux-x64-musl |
linux | x64 | musl |
@ashbyhq/libpg-query-native-linux-arm64-musl |
linux | arm64 | musl |
glibc and musl builds are marked mutually exclusive, so an Alpine host pulls the musl binary and a Debian/Ubuntu host pulls the glibc one β never both.
No node-gyp or compiler toolchain needed at install time.
When building for a target that differs from the install host (e.g. a Linux Docker image built on an Apple Silicon Mac), tell the package manager which architectures to fetch:
# npm
npm install --os=linux --cpu=x64 --libc=glibc
# Yarn Berry β in .yarnrc.yml
supportedArchitectures:
os: [linux]
cpu: [x64]
libc: [glibc]Note: Yarn Classic (1.x) honors
os/cpubut notlibc. On musl hosts it may install both Linux variants; the runtime loader still selects the correct one via musl detection.
Drop-in replacement for @libpg-query/parser:
const { parse, parseSync, deparse, deparseSync, fingerprint, normalize, scan } = require('@ashbyhq/libpg-query-native');
// Sync (no init needed β native loads instantly)
const result = parseSync('SELECT id, name FROM users WHERE active = true');
// Async (same result, just wrapped in a promise)
const result2 = await parse('SELECT id, name FROM users WHERE active = true');| Function | Sync | Async | Returns |
|---|---|---|---|
parseSync(sql) / parse(sql) |
β | β | ParseResult (JSON AST) |
parsePlPgSQLSync(sql) / parsePlPgSQL(sql) |
β | β | PL/pgSQL parse tree |
fingerprintSync(sql) / fingerprint(sql) |
β | β | 16-char hex fingerprint |
normalizeSync(sql) / normalize(sql) |
β | β | Normalized query string |
scanSync(sql) / scan(sql) |
β | β | ScanResult with tokens |
deparseSync(tree, opts?) / deparse(tree, opts?) |
β | β | SQL string |
extractCommentsSync(sql) / extractComments(sql) |
β | β | DeparseComment[] |
deparse() is the inverse of parse(), using PostgreSQL's own deparser rather than a
reimplementation of it β so its output tracks the server's grammar, and constructs added
in PG 18 round-trip instead of being silently dropped.
const { parseSync, deparseSync } = require('@ashbyhq/libpg-query-native');
const tree = parseSync('select a,b from t where x=1');
deparseSync(tree);
// SELECT a, b FROM t WHERE x = 1
// Edit the tree in between to rewrite a query:
tree.stmts[0].stmt.SelectStmt.fromClause[0].RangeVar.relname = 'other_table';
deparseSync(tree);
// SELECT a, b FROM other_table WHERE x = 1Encoding is strict: a misspelled field or a bogus enum value throws rather than being
dropped and deparsed into quietly wrong SQL. Trees the deparser itself rejects throw a
SqlError carrying the failing C function and line.
prettyPrint breaks the statement across lines. The remaining layout options are
pretty-print options upstream, so they only take effect alongside it.
deparseSync(tree, { prettyPrint: true, indentSize: 2 });
// SELECT a, b, c
// FROM mytable
// WHERE
// x = 1
// AND y = 2| Option | Default | Description |
|---|---|---|
prettyPrint |
false |
Break the statement across lines |
indentSize |
4 |
Spaces per indent level |
maxLineLength |
80 |
Soft wrap width for lists of items |
trailingNewline |
false |
Append a newline after the statement |
commasStartOfLine |
false |
Put separating commas at the start of the line |
comments |
β | Comments to weave back in (see below) |
Parse trees don't carry comments, so a parse/deparse round trip drops them. Pull them off the source first and hand them back:
const { extractCommentsSync } = require('@ashbyhq/libpg-query-native');
const sql = '-- keep me\nSELECT a FROM t';
deparseSync(parseSync(sql), { comments: extractCommentsSync(sql) });
// -- keep me
// SELECT a FROM tEach comment carries matchLocation (the offset it anchors to), newlinesBefore,
newlinesAfter and text β filter or rewrite the list before passing it back.
deparse() is heavier than parse(), in both directions:
- Nesting depth. Encoding is recursive, and nesting grows about one level per set
operation. The limit is 2000, so a chain of ~2000
UNION/INTERSECT/EXCEPTis the ceiling; past it you get aRangeErrornaming the limit. This is a safety bound, not just a quota βdeparseRawStmton the C side has no depth guard of its own. - Peak memory.
pg_query_deparse_protobuf()rebuilds the entire tree as PostgresNodestructs in C, so peak allocation is proportional to tree size β on the order of the parse itself. On top of that, encoding builds a transient protobuf message graph in the JS heap (~7Γ the JSON tree) which the GC reclaims afterwards.
The same allocator caveat as parsing applies, and more so: with the system allocator RSS ratchets across repeated deparses, and with jemalloc it stabilizes. Measured on a 26 MB parse tree, four deparse/settle cycles:
| Allocator | after #1 | #2 | #3 | #4 |
|---|---|---|---|---|
| system | 876 MB | 898 MB | 978 MB | 980 MB (still climbing) |
| jemalloc | 510 MB | 568 MB | 556 MB | 562 MB (flat) |
If you deparse large trees repeatedly, run with jemalloc.
pg_query_deparse_protobuf() takes a protobuf-encoded tree, but parse() returns JSON.
pg_query.proto maps between the two with json_name annotations β 1,683 of them, which
is why SelectStmt and targetList in the JSON correspond to select_stmt and
target_list in the schema. protobufjs ignores
json_name, so this used to be a
dead end; @bufbuild/protobuf honours it.
The generated schema lives in src/gen/pg_query_pb.ts and is committed, so npm ci and
the platform builds need no protobuf toolchain. Regenerate it when the libpg_query pin
moves:
npm run generate:protoThat refuses to run unless protos/18/pg_query.proto matches the pinned tag β a tree
encoded against a mismatched schema would deparse into wrong SQL rather than fail loudly.
The native addon uses the system allocator by default. For optimal memory behavior (especially with large queries), preload jemalloc:
# Linux
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 node app.js
# macOS (brew install jemalloc)
DYLD_INSERT_LIBRARIES=$(brew --prefix jemalloc)/lib/libjemalloc.dylib node app.jsOr in your Dockerfile:
RUN apt-get install -y libjemalloc2
ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2The
libjemalloc.so.2path above is for Debian/Ubuntu on x86_64. It differs by distro and architecture (e.g./usr/lib/aarch64-linux-gnu/libjemalloc.so.2on arm64,/usr/lib64/libjemalloc.so.2on RHEL/Fedora). Find it withldconfig -p | grep jemalloc.
# Quick sanity check
node --expose-gc benchmark/memory.mjs --small
# Full benchmark (large query, ~3.31 MB SQL)
node --expose-gc benchmark/memory.mjs
# Compare with/without jemalloc
bash benchmark/run-with-jemalloc.sh
# Throughput benchmark
node --expose-gc benchmark/memory.mjs --throughput
# Compare against WASM (requires @libpg-query/parser installed)
node --expose-gc benchmark/memory.mjs --all
# CI regression benchmark (emits github-action-benchmark JSON)
node --expose-gc benchmark/ci-bench.mjs --out results.jsonThe Native Benchmark workflow runs ci-bench.mjs on a fixed runner
(ubuntu-24.04, under jemalloc) for every PR and push to main. It tracks four
smaller-is-better metrics β large-query parse time, peak RSS, retained RSS, and
small-query latency β against a baseline stored on the gh-pages branch. Each
run posts the per-metric difference to the job summary; a regression beyond
2Γ the baseline comments on the PR and fails the check. The threshold is
deliberately conservative (alert-threshold: 200%) to tolerate shared-runner
noise β tune it in .github/workflows/native-benchmark.yml.
cd native
npm ci
make build # builds libpg_query + the .node addon
npm run build:ts # compiles TypeScript
npm testAfter building:
node scripts/package-platforms.mjsThis creates packages/libpg-query-native-<platform>/ directories ready for npm publish.
The committed version in package.json is the release trigger. Merging a
version bump to the release branch publishes it; nothing else does. Re-pushes,
reverts and re-runs are safe β native-release.yml skips any version already on
npm.
To cut a release by hand: bump version in native/package.json, commit, merge.
The dist-tag is derived from the version (0.2.0 β latest, 0.2.0-beta.1 β
beta), and publishing a prerelease to latest is refused outright.
native-release.yml also accepts a workflow_dispatch with dry-run for
rehearsals.
Two independent automations, deliberately split:
| Workflow | Trigger | Cuts a release? |
|---|---|---|
libpg-query-sync.yml |
new pganalyze/libpg_query release, weekly poll |
Yes |
upstream-tree-sync.yml |
constructive-io/libpg-query-node commits, monthly |
No |
The split follows from what actually ships. This package is built from exactly
three inputs: src/addon.cc + src/index.ts, the libpg_query C library, and
@pgsql/types (types only, zero runtime). The C library is 100% of parsing
behaviour, and nothing from constructive-io/libpg-query-node is compiled into
the published artifact β the fork shares a git tree with it and borrowed the
shape of its API, and that is the whole relationship.
So a new pganalyze release is what changes what consumers get, and it is what
triggers a release. Upstream's npm publishes are a lagging proxy for something
that never reaches this package: upstream builds PG 18 from their own
constructive-io/libpg_query fork at the moving branch 18-constructive, so
their 18.1.x versions are their private patches, not new pganalyze releases.
This fork stays on immutable pganalyze tags. upstream-tree-sync.yml flags it in
the PR body if that ever changes, so the choice gets revisited on purpose.
x-upstream in package.json records what a given build tracks:
"x-upstream": {
"libpgQueryRepo": "https://github.com/pganalyze/libpg_query.git",
"libpgQueryTag": "18.0.0",
"pgMajor": "18",
"constructiveBaseSha": "74ed197..."
}Consumers alias the libpg-query dependency key straight to this package and
import from it directly:
"dependencies": { "libpg-query": "npm:@ashbyhq/libpg-query-native@0.1.1-beta.0" }import { parseSync } from "libpg-query";
import { deparseSync } from "pgsql-deparser";Note pgsql-parser is not in this path. It sits in front of upstream's WASM
libpg-query and pulls it back in transitively β a top-level alias does not
redirect a transitive dependency β so consumers drop it entirely.
There are two contracts here, and only one of them is ours:
The symbol contract β what consumers import by name, currently just
parseSync. Small, and breaking it is squarely our fault. The rest of the
exported surface is asserted too, so a silent narrowing gets caught even though
nothing in this repo would otherwise notice.
The AST contract β whether the tree we emit is one pgsql-deparser can still
render. This is the load-bearing one, and it is invisible to TypeScript:
ParseResult is byte-identical across @pgsql/types majors, so a PG-major bump
on our side can only ever fail here, at runtime. The consumer-contract CI job
round-trips representative migration SQL through parseSync β deparseSync β
parseSync and compares ASTs (ignoring byte-offset location fields, which
shift when the deparser reformats).
Our parser is PG 18; pgsql-deparser is still on its 17 line. Constructs added
in PG 18 parse correctly here and are then silently dropped or rewritten on
the way back out β neither side raises an error:
| Construct | Round-trips as |
|---|---|
PRIMARY KEY (id, valid_at WITHOUT OVERLAPS) |
PRIMARY KEY (id, valid_at) |
RETURNING OLD.x, NEW.x |
clause dropped entirely |
GENERATED ALWAYS AS (...) VIRTUAL |
... STORED |
This is not fixable here, so the test reports it as a warning rather than
failing. It matters because consumers round-trip migration SQL through
parse β deparse: keep these constructs out of migrations until pgsql-deparser
moves to an 18.x line. The warning flips to a note if the deparser catches up.
Run it the way CI does β pack, install with the alias, then:
node native/test/consumer-contract.mjs # from the scratch projectsrc/index.ts is a hand-written reimplementation of upstream's
versions/18/src/index.ts, so upstream API changes do not arrive via a merge
β someone has to port them. upstream-tree-sync.yml compares upstream's exported
surface against .upstream-api-snapshot.json and labels the PR api-drift when
it moves. After porting, accept the new baseline:
cd native && node scripts/check-api-drift.mjs --updatePublishing uses npm trusted publishing (OIDC) β there is no npm token. The registry verifies the workflow's identity directly, so no long-lived credential exists to leak or rotate, and npm attaches a provenance attestation to every published version automatically.
Each package carries its own trusted-publisher config, so all six need one:
| Field | Value |
|---|---|
| Organization | ashbyhq |
| Repository | libpg-query-node |
| Workflow filename | native-release.yml |
| Environment | (none) |
@ashbyhq/libpg-query-native
@ashbyhq/libpg-query-native-darwin-arm64
@ashbyhq/libpg-query-native-linux-x64
@ashbyhq/libpg-query-native-linux-arm64
@ashbyhq/libpg-query-native-linux-x64-musl
@ashbyhq/libpg-query-native-linux-arm64-musl
Set them under Settings β Trusted publisher on each package's npmjs.com page. A missing or wrong config fails that one package's publish; the run is resumable, so fix it and re-run β already-published packages are skipped.
Requires npm β₯ 11.5.1, which the release workflow asserts before publishing.
SYNC_PATβ recommended. A PR opened with the defaultGITHUB_TOKENdoes not trigger other workflows, so sync PRs would arrive with no CI. Without it both sync workflows still run, but warn in the PR body; closing and reopening the PR triggers CI manually.
The five platform packages pin this package's own version, so committing them
desyncs the lockfile on every bump and npm ci fails with EUSAGE before it can
install anything. They are a publish-time construct β src/index.ts prefers the
local prebuilds/<platform>/ binary, and CI installs platform tarballs
explicitly. scripts/sync-optional-deps.mjs injects them at publish time.
npm ci must run before sync-optional-deps.mjs, never after.
MIT