Overview#
DuckDB is an embedded, in-process OLAP (analytical)
database. Its deployment model mirrors SQLite — a single local file or an
in-memory instance, with no server, host, port, or authentication — but its SQL
engine is columnar and analytics-first: real window functions, recursive CTEs,
rich composite types (LIST/STRUCT/MAP/UNION), native sequences, a real
extension system (INSTALL/LOAD), and first-class Parquet/CSV/JSON table
functions.| Property | Value |
|---|
| Dialect name | duckdb |
| Extends | nothing — implements Dialect directly |
| Driver | duckdb |
| Cloud SDK | none |
| Default port | none — embedded |
| Local runtime | embedded — a file or :memory: |
Key facts (from src/dialects/duckdb/index.ts):Dialect name is 'duckdb'; the driver library is the duckdb npm package.
Embedded, single-process, single-user. There is no host/port/
username/password. Those fields are accepted on the config object for API
parity with networked dialects but are silently ignored.
The duckdb driver's public API is callback-based; the dialect wraps every
call (Database, connection.all, connection.run, connection.exec) in
Promises internally.
Columnar/analytical engine: it is optimized for large scans and aggregations,
not high-frequency single-row OLTP writes.
Quick start#
Install#
$ npm install ts-prorm-orm duckdb
$ pnpm add ts-prorm-orm duckdb
$ yarn add ts-prorm-orm duckdb
$ bun add ts-prorm-orm duckdb
Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'duckdb',
storage: './analytics.duckdb', // or ':memory:'
});
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'duckdb',
url: 'duckdb:./analytics.duckdb',
});
A URL is folded into host, port, database, username and password before the dialect is built; any of those passed alongside it wins.Define a model#
import { DataTypes } from 'ts-prorm-orm';
const User = db.define('User', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
name: { type: DataTypes.STRING(100) },
});
await db.sync(); // CREATE TABLE — see Generated SQL below
Query#
import { Op } from 'ts-prorm-orm';
await User.create({ email: 'ada@example.com', name: 'Ada' });
const page = await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Generated SQL#
Each block is the statement the model call above it hands the dialect's query(), captured with the driver stubbed out. The model layer inlines the values.Create table#
db.sync() for the model in the Quick start:CREATE TABLE
Insert, update and delete#
await User.create({ email: 'ada@example.com', name: 'Ada' });
await User.update({ name: 'Grace' }, { where: { email: 'ada@example.com' } });
await User.destroy({ where: { id: 1 } });
Upsert#
await User.upsert({ id: 1, email: 'ada@example.com', name: 'Ada Lovelace' });
upsert() also reads the row by primary key before and after the write, to return the instance.await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Transaction#
await db.transaction(async (t) => {
await User.create({ email: 'grace@example.com', name: 'Grace' }, { transaction: t });
await User.update({ name: 'Grace Hopper' }, { where: { email: 'grace@example.com' }, transaction: t });
});
[!WARNING] No savepoints
Nested transactions need savepoints, and duckdb has none. Run the work in a single transaction, or restructure it so nothing nests.
DISTINCT ON#
distinctOn keeps the first row of each group in order — here the newest row per name:const latest = db.getDialectInstance()!.buildSelectQuery({
tableName: 'users',
distinctOn: ['name'],
order: [['name', 'ASC'], ['id', 'DESC']],
});
await db.query(latest.sql, { replacements: latest.values });
Type mapping for the portable descriptors: STRING → VARCHAR(255), auto-increment INTEGER → INTEGER, BOOLEAN → BOOLEAN, DATE → TIMESTAMP, UUID → UUID, JSON → JSON. The type comparison lines the major engines up side by side.Connection#
import { Prorm } from 'ts-prorm-orm';
// In-memory (default): fast, ephemeral, gone when the process exits.
const mem = new Prorm({
dialect: 'duckdb',
storage: ':memory:',
});
// File-backed: a single on-disk DuckDB database file.
const db = new Prorm({
dialect: 'duckdb',
storage: './analytics.duckdb',
});
Options accepted by the dialect (DuckDBDialectOptions):storage — file path to the database, or ':memory:'. Defaults to
':memory:'.
readonly — open the database in read-only mode (access_mode: READ_ONLY).
config — a Record<string, string> of DuckDB config options passed straight
through to the Database constructor, e.g. { max_memory: '4GB' }.
const ro = new Prorm({
dialect: 'duckdb',
storage: './analytics.duckdb',
dialectOptions: { readonly: true, config: { max_memory: '4GB' } },
});
The connect callback only resolves once the native handle has actually
opened, so open failures (corrupt file, permission denied, read-only open
against a locked writer) surface at connect() rather than on the first
query.
getDatabaseVersion() runs SELECT version().
Identifiers and value escaping#
DuckDB uses standard SQL literals, so escaping differs from MySQL-family
dialects:Identifiers are double-quoted: escapeId('my col') → "my col"
(embedded " doubled).
Booleans escape to TRUE / FALSE (not 0/1).
Date values escape to TIMESTAMP '2024-01-01 00:00:00.000'.
Buffer values escape to a '\xNN...'::BLOB cast.
JS arrays escape to DuckDB list literals: [1, 2, 3].
Positional parameters use ? placeholders (SQLite-style).
Data types#
getDataTypeSql maps ORM type descriptors to DuckDB types:| ORM type descriptor | Generated SQL |
|---|
{ key: 'STRING' } | VARCHAR(255) |
{ key: 'STRING', length: 100 } | VARCHAR(100) |
{ key: 'TEXT' } | VARCHAR |
{ key: 'INTEGER' } | INTEGER |
{ key: 'INTEGER', unsigned: true } | UINTEGER |
{ key: 'BIGINT' } | BIGINT |
{ key: 'BIGINT', unsigned: true } | UBIGINT |
{ key: 'FLOAT' } | REAL |
{ key: 'DOUBLE' } | DOUBLE |
{ key: 'DECIMAL', precision: 12, scale: 4 } | DECIMAL(12,4) |
{ key: 'BOOLEAN' } | BOOLEAN |
{ key: 'DATE' } | TIMESTAMP |
{ key: 'DATEONLY' } | DATE |
{ key: 'TIME' } | TIME |
{ key: 'BLOB' } | BLOB |
{ key: 'JSON' } / { key: 'JSONB' } | JSON |
{ key: 'UUID' } | UUID |
{ key: 'ENUM', values: ['a', 'b'] } | VARCHAR CHECK(col IN ('a','b')) |
Note that DATE maps to TIMESTAMP (date + time), while DATEONLY maps to a
bare DATE.Composite (nested) types#
DuckDB has genuine nested types, and the dialect emits them directly:{ key: 'LIST', type: <elementType> } (or ARRAY) → <elementType>[], e.g.
INTEGER[]. Falls back to VARCHAR[] when the element type is omitted.
{ key: 'STRUCT', fields: { a: <type>, ... } } → STRUCT(a TYPE, ...).
{ key: 'MAP', keyType, valueType } → MAP(keyType, valueType).
{ key: 'UNION', fields: { tag: <type>, ... } } → UNION(tag TYPE, ...).
ENUM is emulated#
DuckDB has a native ENUM, but it requires a separately named type
(CREATE TYPE ... AS ENUM (...)) with out-of-band lifecycle management. To keep
column definitions self-contained, the dialect instead emulates enums as
VARCHAR with a CHECK constraint (the same approach SQLite uses).Auto-increment and sequences#
DuckDB has no AUTOINCREMENT keyword — it uses native sequences. When a
column is defined with autoIncrement: true, the dialect first creates a
backing sequence (seq_<table>_<column>) and defines the column with
DEFAULT nextval('seq_...').Sequences are also exposed as a first-class, native feature:await db.dialect.createSequence({ name: 'order_ids', startWith: 1000, incrementBy: 1 });
const next = await db.dialect.nextSequenceValue('order_ids');
const seqs = await db.dialect.listSequences();
createIdentityColumn is emulated the same way (DuckDB has no
GENERATED ... AS IDENTITY): it creates a sequence and sets the column default
to nextval(...).Upsert (ON CONFLICT)#
DuckDB uses PostgreSQL/SQLite-style INSERT ... ON CONFLICT. Because the
conflict target must be named explicitly, an upsert requires conflictFields
(the PRIMARY KEY or UNIQUE columns) unless doNothing is set:db.dialect.buildUpsertQuery(
'kv',
{ k: 'a', v: 1 },
{ conflictFields: ['k'] },
);
updateOnDuplicate: ['v'] restricts the DO UPDATE SET list to those columns.
doNothing: true emits ON CONFLICT DO NOTHING (with an optional target).
conflictTargets accepts multiple chained ON CONFLICT (...) DO ... targets.
RETURNING is supported (returning: true → RETURNING *).
Omitting conflictFields on a DO UPDATE upsert throws:DuckDB upsert requires `conflictFields` naming the PRIMARY KEY or UNIQUE columns
to resolve conflicts against (e.g. { conflictFields: ["id"] }), unless
`doNothing` is set.
Transactions#
Top-level transactions use BEGIN TRANSACTION / COMMIT / ROLLBACK. Nested
transactions are emulated with SAVEPOINT / RELEASE SAVEPOINT /
ROLLBACK TO SAVEPOINT (see DuckDBTransaction).const tx = await db.dialect.startTransaction();
try {
await db.dialect.query('INSERT INTO t (id) VALUES (1)');
await tx.commit();
} catch (err) {
await tx.rollback();
throw err;
}
DuckDB-specific value-add: files as tables#
DuckDB's signature capability is querying Parquet/CSV/JSON files directly as
tables, and exporting results back out. The dialect exposes builders that return
table-function expressions usable in a FROM clause:// SELECT ... FROM read_parquet('data/*.parquet', hive_partitioning=true)
const src = db.dialect.readParquet('data/*.parquet', { hivePartitioning: true });
const rows = await db.dialect.query(`SELECT region, SUM(amount) AS total FROM ${src} GROUP BY region`);
// CSV and JSON too:
db.dialect.readCsv('sales.csv', { header: true }); // read_csv_auto('sales.csv', header=true)
db.dialect.readJson('events.ndjson', { format: 'newline_delimited' });
Export a query straight to a file with copyTo:await db.dialect.copyTo(
'SELECT * FROM sales',
'out/sales',
'PARQUET',
{ partitionBy: ['region'], overwrite: true, compression: 'zstd' },
);
Reading remote data (S3 / httpfs)#
createExtension('httpfs') installs and loads the extension but does not supply
credentials. Two mechanisms are exposed:await db.dialect.createExtension('httpfs');
// Global SET variables (simplest, one credential set at a time):
await db.dialect.configureS3Credentials({
region: 'us-east-1',
accessKeyId: '...',
secretAccessKey: '...',
});
// Or the newer, recommended named-secret mechanism (scoped, can coexist):
await db.dialect.createSecret('my_bucket', {
keyId: '...',
secret: '...',
region: 'us-east-1',
scope: 's3://my-bucket',
});
Extensions#
Unlike most dialects, DuckDB's extension system is implemented for real:await db.dialect.createExtension('parquet'); // INSTALL parquet; LOAD parquet;
const has = await db.dialect.hasExtension('parquet');
const all = await db.dialect.getExtensions();
There is no uninstall — dropExtension() throws. To stop using an
extension, simply do not LOAD it in future connections.Attaching other databases#
DuckDB can attach additional database files (DuckDB, or SQLite/Postgres via the
respective extension) and query them as alias.table:await db.dialect.attachDatabase('./other.duckdb', 'other');
await db.dialect.query('SELECT * FROM other.users');
await db.dialect.detachDatabase('other');
CREATE DATABASE is emulated via ATTACH (DuckDB has no CREATE DATABASE
statement — a "database" is a file). SELECT * EXCLUDE (...) is also supported
through the attribute builder for column exclusion.Honest limitations#
Several features from other dialects are not supported and throw a clear
error rather than silently doing nothing:Triggers — not supported (createTrigger/dropTrigger throw). Move
reactive logic into application code.
Stored procedures — no procedural language. createStoredProcedure,
executeStoredProcedure, etc. throw; the closest analogue is a scalar/table
MACRO or a view, which the error message points to.
Materialized views — no CREATE MATERIALIZED VIEW. Use
CREATE TABLE ... AS SELECT ... (re-run to refresh) or copyTo() a Parquet
file plus readParquet().
Foreign Data Wrappers — PostgreSQL-only; all FDW methods throw.
Row-level security / policies — not supported.
User / role / privilege management — DuckDB is single-user; all
user/role/grant methods throw.
ALTER TABLE ADD FOREIGN KEY — not supported; define foreign keys at
CREATE TABLE time instead.
Partial indexes — CREATE INDEX ... WHERE ... is unsupported;
addIndex with a where option throws.
Fulltext / spatial indexes — require the fts / spatial extensions;
createFulltextIndex / createSpatialIndex throw with guidance to use
PRAGMA create_fts_index(...) or the RTREE index type.
Table-level partitioning is emulated (child tables + a metadata tablea UNION ALL view, like SQLite), since DuckDB's real partitioning lives at
the storage layer via Hive-partitioned Parquet (copyTo/readParquet).
changeOwner — there is no per-table ownership model to change.
Caveats#
In-memory storage is ephemeral. With storage: ':memory:' (the default),
all data is lost when the process exits. Use a file path to persist.
Analytical, not OLTP. DuckDB shines at large scans and aggregations over
columnar data; it is not designed for high-concurrency single-row writes.
Single writer. As an embedded database, a file can have one read-write
process at a time; other processes must open it read-only.
Capabilities#
Computed from the code — capabilitiesOf() (src/transaction-support.ts),
whereSupportOf() (src/query-builders/expression-render.ts),
sqlObjectCapabilitiesOf() (src/sql-objects/capabilities.ts) and the
builders — by the scratch test described in the
capability matrix.| Capability | duckdb |
|---|
| Transactions atomic | yes |
| Savepoints | no — a nested transaction() throws |
| Isolation | up to REPEATABLE READ |
| Transaction modes | read-only no; deferrable no; constraint deferral no |
| RETURNING | RETURNING on INSERT |
| Upsert | INSERT … ON CONFLICT (…) DO UPDATE |
| JSON column | JSON (JSONB → JSON) |
| Vector column | FLOAT[3] |
| Array column | INTEGER[] |
| WHERE builder | 39 operator keys; $and/$or/$not; $exists: yes; $in subquery: yes; expression operands: yes |
| SQL objects | family duckdb: triggers no; procedures no; functions macro; materialized views emulated (app); sequences yes; enums named; RLS no |
| Streaming | queryStream() present; LISTEN absent; createExtension() present |
Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16