From-scratch pure-Rust database with PowQL -- a pipeline query language that reads left to right -- plus a SQL frontend on the same engine. Ask for a parent and its children and get exactly that: one row per parent, children as a native nested value, no join fan-out to undo and no JSON text to re-parse. Built for single-writer embedded state and read-only snapshot serving, not for many clients sharing one read-write database.
Latest release: v0.28.0
Speed is not the interesting part of PowDB, and the benchmarks below are honest about where it loses. One of these four changes the answer you get. The other three change how much you have to write to get it.
Symmetric aggregates: a join cannot inflate an average
SELECT a.tier, AVG(a.balance)
FROM Account a JOIN Ord o ON a.id = o.account_id
GROUP BY a.tier;
-- 15.0. The join repeats each account once per
-- order, so the account with 4 orders is counted
-- four times. The true average is 20.0.
Account as a join Ord as o on a.id = o.account_id
group a.tier { a.tier, avg_bal: avg(a.balance) }
# 20.0. Each account contributes once.
# Opt back in to the joined-row number explicitly:
avg(raw a.balance) # 15.0
Balances 10, 10 and 40 with 4, 1 and 1 orders. Both numbers are legitimate answers to different
questions, and SQL gives you the inflated one unless you know to write a DISTINCT
subquery. PowQL makes the safe one the default and the other one explicit. PowDB's own SQL
frontend keeps SQL's semantics on purpose, so SQL results stay SQL-compatible.
Nested projections: one row per parent, children nested inside it
SELECT u.name, o.total, o.product_id
FROM User u
LEFT JOIN "Order" o ON o.user_id = u.id;
-- Alice repeats once per order.
-- Cara (no orders) comes back as a row of NULLs.
-- The client regroups by hand.
--
-- A correlated subquery with json_group_array
-- gets the same shape as PowQL, as text.
User as u {
u.name,
orders: Order as o filter o.user_id = u.id
{ o.total, o.product_id }
}
# Alice, [{"product_id":101,"total":9.5},
# {"product_id":102,"total":20.25}]
# Bob, [{"product_id":null,"total":5.5}]
# Cara, []
Every parent appears exactly once, so there is no fan-out to undo. A parent with no children gets
an empty array, never NULL and never a dropped row. Children take their own order,
limit, and offset, and nesting goes more than one level deep.
Entity links: declare the relationship once, then traverse it by name
-- Rewrite the join condition every time
SELECT o.total, u.name
FROM "Order" o
JOIN User u ON o.user_id = u.id;
link Order.user -> User on user_id = id
Order as o { o.total, o.user.name }
User as u { u.name, u.orders { .total } }
A scalar hop through a non-unique key is refused as a hard error rather than silently multiplying your rows. That is the failure mode a join gives you quietly and a link gives you loudly.
Native JSON: a real binary type, and paths you can index
-- Filter, group, aggregate and order on a path
Post group .data->category {
.data->category,
total: sum(.data->amount)
}
# Index a scalar JSON path like any other column
alter Post add index (.data->published_at)
JSON is stored as PJ1, a binary document type, not as text that gets re-parsed on every read. Paths can be filtered, grouped, aggregated, ordered, and indexed.
What is spelling and what is not. The nested shape itself is not exclusive: stock
SQLite produces the same output, empty array for a childless parent included, with a correlated
subquery and json_group_array(json_object(...)), and DuckDB, SurrealDB and several ORMs
have their own spellings. What differs here is that PowDB owns the storage engine, the executor and
the wire protocol, so the nested value crosses the wire as PJ1 binary and the client decodes that
rather than parsing JSON text. It is a binary decode instead of a text parse, not the absence of a
decode, and we have not published a benchmark of the difference. The aggregate above is the one item
on this page that changes the answer rather than the typing.
Full detail: Symmetric aggregates, nested projections, entity links, and the PowQL reference.
PowDB is a single-writer embedded engine with truly parallel reads. Every writer takes the whole write-admission gate, and there is no MVCC. That shape decides where it shines.
PowDB vs SQLite on 100K rows, neither engine fsyncs. All 15 workloads are listed, including the four where PowDB ties or loses. These are single-request latencies (one query at a time), not throughput under many simultaneous clients. Single-row durable inserts are fsync-bound; that write story is covered in Embedded in Node below.
Median of 5 runs on an Apple M5 Max laptop (macOS 26.5.1, rustc 1.97.0), commit e3dfa71, measured 2026-08-15. Laptop numbers, not CI numbers. PowDB writes to a real temp directory while SQLite is :memory:, so the three write rows (insert_single, insert_batch_1k, delete_by_filter) are sensitive to competing disk I/O and change sign depending on machine load. Treat them as ties and re-measure them yourself. Full methodology, per-run spread, and what changed in the harness: the 2026-07-24 snapshot.
| Workload | PowDB | SQLite | Speedup |
|---|---|---|---|
| agg_min | 221µs | 1.70ms | 7.7x |
| agg_max | 217µs | 1.47ms | 6.8x |
| agg_sum | 234µs | 1.45ms | 6.2x |
| update_by_pk | 60ns | 272ns | 4.5x |
| agg_avg | 455µs | 1.70ms | 3.7x |
| scan_filter_count | 380µs | 1.40ms | 3.7x |
| point_lookup_nonindexed | 101µs | 319µs | 3.2x |
| scan_filter_sort_limit | 2.46ms | 6.41ms | 2.6x |
| multi_col_and_filter | 1.58ms | 3.21ms | 2.0x |
| update_by_filter | 2.36ms | 4.54ms | 1.9x |
| insert_single | 380ns | 638ns | tied |
| scan_filter_project_top100 | 8.1µs | 8.9µs | tied |
| delete_by_filter | 1.57ms | 1.75ms | tied |
| insert_batch_1k | 242ns | 214ns | tied |
| point_lookup_indexed | 3.17µs | 202ns | 15.7x slower |
PowQL reads left to right like a pipeline. No SELECT ... FROM ... WHERE juggling.
Filter and project
SELECT name, price
FROM Product
WHERE price > 10
ORDER BY name;
Product filter .price > 10
order .name
{ .name, .price }
Aggregate with filter
SELECT AVG(age)
FROM User
WHERE city = 'NYC';
avg(User filter .city = "NYC"
{ .age })
Group by with having
SELECT status, COUNT(*)
FROM User
GROUP BY status
HAVING COUNT(*) > 5;
User group .status
having count(*) > 5
{ .status, count(*) }
Insert a row
INSERT INTO User (name, email, age)
VALUES ('Alice', 'alice@example.com', 30);
insert User {
name := "Alice",
email := "alice@example.com",
age := 30
}
The same engine, in-process. @zvndev/powdb-embedded is a native Node addon: open a database, run PowQL or SQL, no TCP, no daemon. Durability is a mode you pick, so batched writes can be made fast without giving up crash safety.
import { Database } from "@zvndev/powdb-embedded";
const db = Database.open("./data");
// "normal" = off-lock background fsync: much faster writes,
// a bounded crash-loss window. This closes the write gap vs SQLite.
db.setSyncMode("normal");
db.query('type User { required name: str, required email: str, age: int }');
db.query('insert User { name := "Alice", email := "alice@example.com", age := 30 }');
// PowQL
db.query('User filter .age > 25 { .name, .age }');
// ...or SQL, on the same handle — lowered to PowQL by the SQL frontend
db.querySql("SELECT name, age FROM User WHERE age > 25 ORDER BY age DESC");
Durability is a knob, not a fork: "full" fsyncs every commit (safest, the default), "normal" moves the fsync off the write lock for a bounded crash-loss window, and batching writes in a begin/commit transaction drops the cost from one fsync per row to roughly one per 64. That is how single-row insert throughput stops being fsync-bound.
Every layer of PowDB is written in Rust, from the storage engine to the query executor.
Filter expressions compile into byte-level operations that skip full row decoding. This is why the aggregates measure 3.7-7.7x SQLite and filtered scans 1-3.7x, while point lookups measure about 15x slower.
PowQL, not PowDB's SQL subset. One row per parent with correlated children assembled into a native JSON array, with per-parent order and limit and multi-level nesting. No join fan-out to undo.
PowQL, not PowDB's SQL subset. Declare a relationship once with link Order.user -> User on user_id = id, then traverse it by name. A scalar hop through a non-unique key is a hard error, never a silent fan-out.
JSON is a first-class binary document type, not text. Filter, group, aggregate, and order on -> paths, and build B+tree indexes over scalar JSON paths.
Inspect the plan PowDB chose, including link paths and index selection, so a slow query is diagnosable instead of mysterious.
Opt-in /metrics endpoint (POWDB_METRICS_ADDR) exposing connection, query, and auth-failure counts. Unauthenticated by design, so keep it on a private network.
Disk-persisted B+ tree indexes in a custom BIDX binary format, including scalar JSON-path indexes. Indexes survive restarts and are chosen automatically using collected statistics.
Write-ahead log with statement-boundary group commit. Full crash recovery via WAL replay, page-zero recovery, and automatic index rebuild.
FNV-1a hashed plan cache with literal substitution. Parse and plan once, execute thousands of times. Prepared queries skip the entire front-end pipeline.
A SQL parser lowers a supported subset — SELECT/JOIN/GROUP BY, INSERT ... RETURNING, AUTOINCREMENT, DDL, transactions — to the same PowQL AST and plan cache. One engine, two languages, no second execution path.
Native in-process addon (@zvndev/powdb-embedded): Database.open, then PowQL or SQL with no server. setSyncMode("normal") trades per-commit fsync for OS-level durability, which is what makes writes fast.
First-class TypeScript client (@zvndev/powdb-client) with a clean async API. Connect to PowDB server over TCP with full type safety.
TLS encryption plus two auth modes: a shared password (POWDB_PASSWORD) or named users with roles (admin / readwrite / readonly, argon2id-hashed).
The engine libraries are pure Rust: no C FFI, no libsqlite3-sys, no bindgen, and nothing to install beside a built binary. Building powdb-server or powdb-cli from source does need a C toolchain and cmake, because their TLS stack compiles aws-lc-sys. Windows is not supported: the mmap scan path is Unix-only.
PowQL reads left to right: table, filter, order, limit, project. No inside-out clause structure. Queries read like sentences.
PowDB is pre-1.0 and deliberately scoped. Be honest with yourself before adopting it:
If any of those are dealbreakers, use SQLite -- and we say so plainly in PowDB vs SQLite: when to use which.