MotherDuck Labs · experiment

Postgres vs MotherDuck

View source

Is the same analytics query really faster on MotherDuck than on Postgres? This experiment runs the exact same SQL against both engines, live in your browser — so you can measure the gap yourself.

Under the hood it's a full scan of ~3.9M order-items joined to orders, through the same pg driver — only the connection host changes. Hit Run comparison and watch Postgres grind while MotherDuck has already drawn its chart.

The query — run verbatim against both engines
SELECT
    s.plan_tier,
    date_trunc('month', o.ordered_at) AS month,
    SUM(oi.line_total)                AS revenue,
    COUNT(DISTINCT o.order_id)        AS orders
  FROM order_items oi
  JOIN orders o ON o.order_id = oi.order_id
  JOIN shops  s ON s.shop_id  = o.shop_id
  WHERE o.status = 'paid'
  GROUP BY 1, 2
  ORDER BY 1, 2
PostgresPostgres· your managed Postgres
Press “Run comparison” to query this engine.
MotherDuck· Postgres wire endpoint
Press “Run comparison” to query this engine.

Fair comparison. Both engines run the identical SQL, and the Postgres side is indexed on its primary and foreign keys — orders(order_id, shop_id, status, ordered_at), order_items(order_id, shop_id, product_id), and every table’s PK — so it’s row-store vs columnar, not Postgres without indexes (seed script).

Comparable compute. Postgres runs on a standard managed instance — 0.5 vCPU · 4 GB RAM · 10 GB storage, single node. MotherDuck runs on a single Standard Duckling — its default, production-grade compute tier. Roughly standard compute on both sides — the “after” isn’t an oversized warehouse.

About the dataset

A synthetic multi-shop commerce platform — shops (tenants) on plan tiers, their catalog, and ~3.9M order line-items. The revenue query above is a full scan of order_items joined up to orders and shops — exactly the kind of analytical aggregate that row-store Postgres labors over and a columnar engine eats for breakfast.

TableKindRowsWhat it is
shopsdimension500tenants, each on a plan tier
categoriesdimension12product categories
productsdimension50,000catalog across all shops
customersdimension500,000buyers
ordersfact2,000,000one row per placed order
order_itemsfact3,938,272line items — the heavy grain

How the connection works

Both engines are reached through the same Node pg driver. MotherDuck speaks the Postgres wire protocol, so “switching to MotherDuck” is just a different host + credentials — no DuckDB native extension, no SQL rewrite, no driver change. That’s why this runs fine in a serverless function.

Postgres — standard connection string
new Pool({
  connectionString: POSTGRES_URL,
  ssl: { rejectUnauthorized: false },
})
MotherDuck — its Postgres wire endpoint
new Pool({
  host: "pg.us-east-1-aws.motherduck.com",
  port: 5432,
  user: "motherduck",        // any non-empty user
  password: MOTHERDUCK_TOKEN, // the token is the credential
  database: "multishop_commerce",
  ssl: { rejectUnauthorized: false },
})

Defined once in lib/db.ts — the ?source= param picks which pool answers. Same query text either way.

MotherDuck Postgres wire endpoint docs ↗

Three ways to query MotherDuck from JS/TS

This demo uses the Postgres wire path because it drops into an existing Postgres app with no new dependencies and runs in a serverless function. When you want the full native engine or browser-side compute, reach for one of the other two.

Postgres wireThis app
pg

MotherDuck's Postgres-protocol endpoint, via the standard node-postgres driver.

import { Pool } from "pg";

const pool = new Pool({
  host: "pg.us-east-1-aws.motherduck.com",
  user: "motherduck",         // any non-empty user
  password: MOTHERDUCK_TOKEN, // token is the credential
  database: "multishop_commerce",
  ssl: { rejectUnauthorized: false },
});
const { rows } = await pool.query("SELECT 1");
  • +Zero new deps if you already use Postgres
  • +Pure JS — runs in any serverless / Node runtime
  • +Drop-in for an existing PG app: just swap the host

Trade-off · Goes through the Postgres-protocol surface — a subset of DuckDB SQL and PG type coercion; no local-file ATTACH.

MotherDuck docs ↗
DuckDB Node.js
@duckdb/node-api

The native DuckDB engine in-process, connected to MotherDuck with an md: string.

import duckdb from "@duckdb/node-api";

const instance = await duckdb.DuckDBInstance.create(
  `md:multishop_commerce?motherduck_token=${MOTHERDUCK_TOKEN}`
);
const connection = await instance.connect();
const result = await connection.run("SELECT 1");
  • +Full native DuckDB SQL + extensions
  • +ATTACH local files / Parquet alongside MotherDuck
  • +Arrow-native results; hybrid local+cloud execution

Trade-off · Native addon — platform-specific binary, larger bundle, heavier cold starts; not edge-runtime compatible.

MotherDuck docs ↗
MotherDuck Wasm
@motherduck/wasm-client

DuckDB-Wasm in the browser — query MotherDuck straight from the client.

import { MDConnection } from "@motherduck/wasm-client";

const connection = MDConnection.create({
  mdToken: READ_SCALING_TOKEN, // reaches the browser!
});
await connection.isInitialized();
const result = await connection.evaluateQuery("SELECT 1");
  • +Queries run in the browser — no server round-trip
  • +Hybrid execution: local Wasm + cloud compute
  • +Great for interactive dashboards & per-user drill-downs

Trade-off · The token reaches the client — use a read-scaling / short-lived token, never your main one. Plus Wasm bundle + browser memory limits.

MotherDuck docs ↗

All three authenticate with the same MotherDuck access token. For the Wasm path, mint a read-scaling token server-side so your primary token never ships to the browser.