Overview#
MotherDuck is DuckDB in the cloud. It is not a
different SQL engine: it is DuckDB reached through the motherduck extension,
which the ordinary duckdb driver loads when asked to open a path beginning
md:. So MotherDuckDialect extends DuckDBDialect and inherits its query
execution, escaping, type mapping and every builder — read_parquet, COPY
TO, ON CONFLICT upserts, secrets — and overrides only what the md: path
changes.| Property | Value |
|---|
| Dialect name | motherduck |
| Extends | DuckDBDialect |
| Driver | duckdb |
| Default port | none — the extension connects; there is no socket to configure |
| Cloud SDK | none |
| Local runtime | none — hosted; run DuckDB locally instead |
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
duckdb is the same driver the duckdb dialect uses.Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'motherduck',
database: 'analytics', // opens md:analytics
dialectOptions: {
token: process.env.MOTHERDUCK_TOKEN,
},
});
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'motherduck',
url: 'md:analytics',
dialectOptions: {
token: process.env.MOTHERDUCK_TOKEN,
},
});
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
capabilitiesOf() reports no savepoints for this dialect, so a transaction() nested inside another one throws.
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';
const db = new Prorm({
dialect: 'motherduck',
database: 'analytics', // opens md:analytics
dialectOptions: {
token: process.env.MOTHERDUCK_SERVICE_TOKEN,
},
});
| Option | Meaning |
|---|
database | Opens md:<database> as the current database. Omitted: md:, which attaches the whole workspace. |
token | Service token, passed as the motherduck_token driver config — never in the path, so it never appears in a logged path. Omit it and the extension reads the motherduck_token environment variable. |
storage | md:... → used as the path verbatim. A local file path → attached next to the cloud databases on connect (see below). :memory: (the PrormOptions default) is ignored. |
localAlias | Alias for that local file. Default local. |
saasMode | saas_mode=true — no local filesystem access or local extension loading. |
attachMode | attach_mode=single — attach only the named database. |
sessionName | session_name — routes the connection and labels query history. |
dbinstanceInactivityTtl | dbinstance_inactivity_ttl, e.g. '5m'. |
config, readonly | Passed through to DuckDB as for the duckdb dialect. |
A failed connect is re-raised naming MotherDuck, the path, and — when no token
was given and the environment variable is unset — the missing token.What differs from DuckDB#
MotherDuckDialect extends DuckDBDialect (duckdb) and
overrides 19 of its methods (Object.getOwnPropertyNames(MotherDuckDialect.prototype),
computed by the scratch test); everything else is inherited unchanged.| Area | Overridden |
|---|
| Connection and execution | connect |
| DML builders | buildUpdateShareSql |
| DDL and schema | attachShare, buildCreateDatabaseSql, buildCreateShareSql, buildDropShareSql, createDatabaseSQL, createShare, dropDatabaseSQL, dropShare |
| Introspection | assertShareUrl, listShares, readCsv, readJson, readParquet |
| Other | buildAttachMotherDuckSql, buildAttachShareSql, updateShare, withMdRun |
Hybrid ("dual") execution#
A MotherDuck connection is still an in-process DuckDB. A query joining a cloud
table with a local Parquet file runs the cloud scan in MotherDuck and the local
scan here, and bridges the two automatically.const md = new MotherDuckDialect({ database: 'analytics', storage: './scratch.duckdb' });
await md.connect();
// md:analytics is current; ./scratch.duckdb is attached as "local"
Where a table function runs can be pinned with MD_RUN:md.readParquet('s3://bucket/events/*.parquet', { mdRun: 'REMOTE' });
// read_parquet('s3://bucket/events/*.parquet', MD_RUN = REMOTE)
md.readCsv('./local.csv', { header: true, mdRun: 'LOCAL' });
// read_csv_auto('./local.csv', header=true, MD_RUN = LOCAL)
readParquet, readCsv and readJson all take mdRun: 'LOCAL' | 'REMOTE' | 'AUTO'.Databases#
DuckDB has no CREATE DATABASE — locally a database is a file — and the
inherited createDatabaseSQL() emits ATTACH 'name'. Against MotherDuck that
would quietly create a local file, so it is replaced:md.createDatabaseSQL({ name: 'ducks' }); // CREATE DATABASE "ducks"
md.buildCreateDatabaseSql({ name: 'dev', from: { database: 'prod' } });
// CREATE DATABASE "dev" FROM "prod" — zero-copy clone
md.buildCreateDatabaseSql({ name: 'c', from: { share: 'md:_share/ducks/0a9a…' } });
md.buildCreateDatabaseSql({ name: 'c', from: { path: 's3://bucket/ducks.db' } }); // physical copy
md.dropDatabaseSQL('ducks'); // DROP DATABASE "ducks"
md.buildAttachMotherDuckSql('prod', 'p'); // ATTACH 'md:prod' AS "p"
Shares#
const url = await md.createShare({
name: 'birds',
from: 'ducks',
access: 'RESTRICTED',
visibility: 'HIDDEN',
update: 'MANUAL',
includePatterns: ['main.*'],
});
// CREATE SHARE "birds" FROM "ducks" (ACCESS RESTRICTED, VISIBILITY HIDDEN, UPDATE MANUAL, INCLUDE_PATTERN 'main.*')
await md.updateShare('birds'); // UPDATE SHARE "birds" — publish a new snapshot
await md.attachShare(url!, 'birds'); // ATTACH 'md:_share/…' AS "birds"
await md.dropShare('birds', { ifExists: true }); // DROP SHARE IF EXISTS "birds"
await md.listShares(); // LIST SHARES
createShare() returns the share URL MotherDuck answers with. Each builder has
a pure build…Sql counterpart.What it refuses#
| Asked for | What happens |
|---|
visibility: 'HIDDEN' without access: 'RESTRICTED' | Refused — MotherDuck allows HIDDEN only for RESTRICTED shares |
orReplace with ifNotExists (shares, databases) | Refused |
An unknown access / visibility / update / MD_RUN value | Refused, never spliced |
An empty includePatterns | Refused — it would share nothing |
attachShare() / from: { share } with a URL that is not md:_share/... | Refused |
createDatabaseSQL() with encoding, template, lcCollate, … | Refused — PostgreSQL settings with no MotherDuck meaning |
MotherDuck also documents that it does not run custom or community extensions,
user-defined functions, or server-side ATTACH of Postgres/SQLite. Those calls
are not refused here: in dual execution they run in this process, locally,
which may be exactly what is wanted.References#
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 | motherduck |
|---|
| 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 |
Testing#
tests/dialects/motherduck.test.ts exercises the dialect with a mocked driver, so nothing touches a server.See also#
duckdb — the parent dialect this one extends Modified at 2026-10-04 00:40:16