A minimal DuckDB WASM build for browsers and Cloudflare Workers.
| Package | Description | Install |
|---|---|---|
@ducklings/browser |
Browser version (async API) | npm install @ducklings/browser |
@ducklings/workers |
Cloudflare Workers version (async API) | npm install @ducklings/workers |
The API documentation can be found at https://ducklings-api.serverless-duckdb.com
You can try the browser package in an example at https://ducklings.serverless-duckdb.com
- Minimal footprint: ~6.0MB (browser) / ~8.1MB (workers) gzipped WASM (optimized with -Oz, LTO, wasm-opt)
- TypeScript API: Full TypeScript support with type definitions
- Prepared statements: Secure parameterized queries with full type support
- Streaming results: Memory-efficient chunked data processing
- Transactions: BEGIN/COMMIT/ROLLBACK with wrapper helpers
- Arrow support: Query results as Arrow Tables, insert data via Arrow IPC streams, powered by Flechette
- Parquet support: Read Parquet files with built-in extension
- httpfs support: Load remote files via HTTP/HTTPS
- JSON support: Native JSON functions and
read_json()for JSON files - Cloudflare Workers: First-class support with dedicated async package
- Browser support: Works in modern browsers with ES modules
| Feature | Status |
|---|---|
| In-memory databases | ✅ |
| SQL queries | ✅ |
| Prepared statements | ✅ |
| Transactions | ✅ |
| Streaming results | ✅ |
| Arrow Table results | ✅ |
| Arrow IPC insert | ✅ |
| Parquet extension | ✅ |
| httpfs extension | ✅ |
| JSON extension | ✅ |
| Cloudflare Workers | ✅ |
| Browser support | ✅ |
npm install @ducklings/browserimport { init, DuckDB } from '@ducklings/browser';
// Initialize the WASM module (runs in Web Worker)
await init();
// Create a database and connection
const db = new DuckDB();
const conn = await db.connect();
// Execute queries - returns array of JS objects
const rows = await conn.query('SELECT 42 as answer');
console.log(rows); // [{ answer: 42 }]
// Or get results as Arrow Table
const table = await conn.queryArrow('SELECT * FROM range(5)');
console.log(table.numRows); // 5
// Clean up
await conn.close();
await db.close();npm install @ducklings/workersExample Worker:
import { init, DuckDB } from '@ducklings/workers';
import wasmModule from '@ducklings/workers/wasm';
export default {
async fetch(request: Request): Promise<Response> {
// Initialize with pre-compiled WASM module
await init({ wasmModule });
const db = new DuckDB();
const conn = db.connect();
// All queries are async
const rows = await conn.query<{answer: number}>('SELECT 42 as answer');
conn.close();
db.close();
return Response.json(rows);
}
};Why two packages? Cloudflare Workers doesn't support synchronous XMLHttpRequest (browser-only API). The workers package uses Emscripten's Asyncify to enable async fetch() calls, making httpfs work properly. The async API means all query methods return Promises.
| Package | API Style | Size (gzipped) | httpfs |
|---|---|---|---|
@ducklings/browser |
Async (Web Worker) | ~6.0 MB | Works in browsers |
@ducklings/workers |
Async (Asyncify) | ~8.1 MB | Works in CF Workers |
Return query results as Arrow IPC stream for efficient data transfer:
import { init, DuckDB, tableToIPC } from '@ducklings/workers';
import wasmModule from '@ducklings/workers/wasm';
export default {
async fetch(request: Request): Promise<Response> {
await init({ wasmModule });
const db = new DuckDB();
const conn = db.connect();
// Get results as Arrow Table
const table = await conn.queryArrow('SELECT * FROM range(1000) t(i)');
// Serialize to Arrow IPC stream format
const ipcBytes = tableToIPC(table, { format: 'stream' });
conn.close();
db.close();
return new Response(ipcBytes, {
headers: {
'Content-Type': 'application/vnd.apache.arrow.stream',
},
});
}
};Consume with any Arrow-compatible client:
# Python
import pyarrow as pa
response = requests.get('https://your-worker.dev/arrow')
reader = pa.ipc.open_stream(response.content)
table = reader.read_all()// JavaScript (Flechette)
import { tableFromIPC } from '@ducklings/browser';
const response = await fetch('https://your-worker.dev/arrow');
const bytes = new Uint8Array(await response.arrayBuffer());
const table = tableFromIPC(bytes);// Browser - auto-locates WASM file
import { init } from '@ducklings/browser';
await init();
// Browser with Vite - use ?url imports for reliable asset resolution
import wasmUrl from '@ducklings/browser/wasm/duckdb.wasm?url';
import wasmJsUrl from '@ducklings/browser/wasm/duckdb.js?url';
import workerUrl from '@ducklings/browser/worker?url';
await init({ wasmUrl, wasmJsUrl, workerUrl });
// Browser - custom WASM URL
await init('/path/to/duckdb.wasm');
// Cloudflare Workers - use the workers package
import { init } from '@ducklings/workers';
import wasmModule from '@ducklings/workers/wasm';
await init({ wasmModule });Load directly from jsDelivr or unpkg - cross-origin workers are handled automatically:
<script type="module">
import { init, DuckDB } from 'https://cdn.jsdelivr.net/npm/@ducklings/browser@1.5.0/+esm';
await init();
const db = new DuckDB();
const conn = await db.connect();
const result = await conn.query('SELECT 42 as answer');
console.log(result); // [{ answer: 42 }]
await conn.close();
await db.close();
</script>// Create in-memory database
const db = new DuckDB();
// Or create with automatic initialization (browser only)
const db = await DuckDB.create();
// Create a connection
const conn = db.connect();
// Close the database
db.close();Configure database security settings using DuckDBConfig:
import { DuckDB, AccessMode } from '@ducklings/workers';
// Default configuration (secure defaults)
const db = new DuckDB();
// Equivalent to:
// new DuckDB({
// accessMode: AccessMode.AUTOMATIC,
// enableExternalAccess: true,
// lockConfiguration: true,
// });
// Read-only mode - prevents any data modification
const readOnlyDb = new DuckDB({
accessMode: AccessMode.READ_ONLY,
});
// Disable external access (disables httpfs)
const isolatedDb = new DuckDB({
enableExternalAccess: false,
});
// Allow runtime config changes via SQL
const flexibleDb = new DuckDB({
lockConfiguration: false,
});
// Custom configuration options
const customDb = new DuckDB({
customConfig: {
threads: '1',
},
});| Option | Type | Default | Description |
|---|---|---|---|
accessMode |
AccessMode |
AUTOMATIC |
Database access mode: AUTOMATIC, READ_ONLY, or READ_WRITE |
enableExternalAccess |
boolean |
true |
Enable httpfs and file I/O. Set to false to prevent all external data access |
lockConfiguration |
boolean |
true |
Lock configuration after startup. Prevents SET commands via SQL |
customConfig |
Record<string, string> |
{} |
Custom DuckDB configuration options |
| Mode | Description |
|---|---|
AccessMode.AUTOMATIC |
DuckDB determines mode (resolves to READ_WRITE for in-memory) |
AccessMode.READ_ONLY |
All write operations blocked |
AccessMode.READ_WRITE |
Allow both reads and writes |
Both packages have an async API. All query methods return Promises.
const conn = await db.connect();
// Query returning JS objects
const rows = await conn.query<{id: number, name: string}>('SELECT * FROM users');
// Returns: [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}]
// Query returning Arrow Table (Flechette)
const table = await conn.queryArrow('SELECT * FROM users');
// Returns: Flechette Table with .numRows, .numCols, .schema, .toArray()
// Execute statement without returning results
await conn.execute('INSERT INTO users VALUES (1, "Alice")');
// Insert Arrow IPC data into a table
import { tableFromArrays, tableToIPC, utf8 } from '@ducklings/browser';
const arrow = tableFromArrays(
{ id: [1, 2], name: ['Alice', 'Bob'] },
{ types: { name: utf8() } }
);
await conn.insertArrowFromIPCStream('users', tableToIPC(arrow, { format: 'stream' }));
// Close the connection
await conn.close();// Create a prepared statement
const stmt = await conn.prepare('SELECT * FROM users WHERE id = ? AND active = ?');
// Bind parameters (1-based index)
stmt.bindInt32(1, 42);
stmt.bindBoolean(2, true);
// Or use generic bind with type inference
stmt.bind(1, 42); // number -> int32
stmt.bind(2, 'hello'); // string
stmt.bind(3, true); // boolean
stmt.bind(4, null); // null
stmt.bind(5, 123n); // bigint -> int64
// Execute and get results
const results = await stmt.run();
console.log(results); // [{id: 42, name: 'Alice', active: true}]
// Clean up
await stmt.close();| Method | TypeScript Type | DuckDB Type |
|---|---|---|
bindBoolean(idx, val) |
boolean |
BOOLEAN |
bindInt32(idx, val) |
number |
INTEGER |
bindInt64(idx, val) |
bigint | number |
BIGINT |
bindFloat(idx, val) |
number |
FLOAT |
bindDouble(idx, val) |
number |
DOUBLE |
bindString(idx, val) |
string |
VARCHAR |
bindBlob(idx, val) |
Uint8Array |
BLOB |
bindNull(idx) |
- | NULL |
bindDate(idx, val) |
Date |
DATE |
bindTimestamp(idx, val) |
Date |
TIMESTAMP |
// Manual transaction control
await conn.beginTransaction();
try {
await conn.query('INSERT INTO accounts VALUES (1, 1000)');
await conn.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1');
await conn.commit();
} catch (e) {
await conn.rollback();
throw e;
}
// Or use the transaction wrapper (auto-rollback on error)
await conn.transaction(async () => {
await conn.query('INSERT INTO orders VALUES (...)');
await conn.query('UPDATE inventory SET qty = qty - 1');
return 'success';
});For large result sets, use streaming to process data in chunks:
const stream = await conn.queryStreaming('SELECT * FROM large_table');
// Get metadata
console.log(stream.columnCount); // Number of columns
console.log(stream.chunkCount); // Number of chunks
console.log(stream.getColumns()); // Column info [{name, type}, ...]
// Iterate over chunks
for await (const chunk of stream) {
console.log(`Chunk has ${chunk.rowCount} rows`);
// Typed accessors
for (let row = 0; row < chunk.rowCount; row++) {
const id = chunk.getInt64(row, 0);
const name = chunk.getString(row, 1);
const active = chunk.getBoolean(row, 2);
const isNull = chunk.isNull(row, 3);
}
// Or get entire column as array
const ids = chunk.getColumn(0);
}
// Stream auto-closes after iteration| Method | Return Type | Description |
|---|---|---|
getBoolean(row, col) |
boolean |
Get boolean value |
getInt32(row, col) |
number |
Get 32-bit integer |
getInt64(row, col) |
number |
Get 64-bit integer (may lose precision) |
getBigInt(row, col) |
bigint |
Get 64-bit integer as BigInt |
getDouble(row, col) |
number |
Get double/float value |
getString(row, col) |
string |
Get string value |
getDate(row, col) |
number |
Get date as days since epoch |
getTimestamp(row, col) |
number |
Get timestamp as microseconds |
getDateObject(row, col) |
Date |
Get date/timestamp as JS Date |
getValue(row, col) |
unknown |
Get value with auto type detection |
getJSON<T>(row, col) |
T |
Parse JSON string to object |
isNull(row, col) |
boolean |
Check if value is NULL |
import { tableFromArrays, tableFromIPC, tableToIPC, utf8 } from '@ducklings/browser';
// Query as Arrow Table
const table = await conn.queryArrow('SELECT i, i*2 AS doubled FROM range(5) t(i)');
console.log(table.numRows); // 5
console.log(table.numCols); // 2
console.log(table.schema); // Column metadata
// Build Arrow tables manually
const custom = tableFromArrays({
id: [1, 2, 3],
name: ['Alice', 'Bob', 'Charlie']
});
// Serialize to/from Arrow IPC
const bytes = tableToIPC(table);
const restored = tableFromIPC(bytes);
// Insert Arrow IPC data directly into a table
const data = tableFromArrays(
{ id: [1, 2, 3], label: ['a', 'b', 'c'] },
{ types: { label: utf8() } } // Use plain utf8 (see note below)
);
const ipc = tableToIPC(data, { format: 'stream' });
await conn.insertArrowFromIPCStream('my_table', ipc);Dictionary encoding: Flechette's
tableFromArrays()infersdictionary(utf8())for string columns by default. The Arrow IPC decoder used byinsertArrowFromIPCStream()does not support dictionary-encoded IPC streams. When building tables for insertion, explicitly set string columns toutf8()via thetypesoption:import { utf8 } from '@ducklings/browser'; // or '@ducklings/workers' const table = tableFromArrays( { name: ['Alice', 'Bob'] }, { types: { name: utf8() } } );
Load data from remote HTTP/HTTPS sources:
// Query remote Parquet file
const rows = await conn.query(`
SELECT *
FROM 'https://example.com/data.parquet'
LIMIT 10
`);
// Query remote CSV file
const csvData = await conn.query(`
SELECT *
FROM read_csv('https://example.com/data.csv')
`);
// Query remote JSON file
const jsonData = await conn.query(`
SELECT *
FROM read_json('https://example.com/data.json')
`);Access private S3-compatible storage (AWS S3, Cloudflare R2, Google Cloud Storage) using DuckDB secrets:
// Create an S3 secret
await conn.execute(`
CREATE SECRET my_s3 (
TYPE S3,
KEY_ID 'your-access-key-id',
SECRET 'your-secret-access-key',
REGION 'us-east-1'
)
`);
// Query S3 files
const rows = await conn.query(`
SELECT * FROM 's3://bucket-name/path/to/file.parquet'
`);
// Create a Cloudflare R2 secret
await conn.execute(`
CREATE SECRET my_r2 (
TYPE R2,
KEY_ID 'your-r2-access-key-id',
SECRET 'your-r2-secret-access-key',
ACCOUNT_ID 'your-cloudflare-account-id'
)
`);
// Query R2 files
const rows = await conn.query(`
SELECT * FROM 'r2://bucket-name/path/to/file.parquet'
`);
// Create a Google Cloud Storage secret
await conn.execute(`
CREATE SECRET my_gcs (
TYPE GCS,
KEY_ID 'your-gcs-key-id',
SECRET 'your-gcs-secret'
)
`);
// Query GCS files
const rows = await conn.query(`
SELECT * FROM 'gcs://bucket-name/path/to/file.parquet'
`);Supported secret types: S3, R2, GCS
See the Cloudflare Workers example for a complete example with environment-based R2 secrets.
The JSON extension provides native JSON parsing and manipulation:
// Parse JSON strings
const parsed = await conn.query(`SELECT json('{"name": "Alice", "age": 30}')`);
// Extract values (returns JSON type with quotes)
const jsonValue = await conn.query(`
SELECT json_extract('{"user": {"name": "Bob"}}', '$.user.name') AS name
`);
// Returns: [{name: "\"Bob\""}]
// Extract as string (returns raw value without quotes)
const stringValue = await conn.query(`
SELECT json_extract_string('{"user": {"name": "Bob"}}', '$.user.name') AS name
`);
// Returns: [{name: "Bob"}]
// Using ->> operator (shorthand for json_extract_string)
const result = await conn.query(`
SELECT '{"id": 1, "name": "Test"}'::JSON->>'$.name' AS name
`);
// Returns: [{name: "Test"}]
// Get JSON keys
const keys = await conn.query(`SELECT json_keys('{"a": 1, "b": 2, "c": 3}')`);
// Read JSON files (local or remote with httpfs)
const data = await conn.query(`SELECT * FROM read_json('data.json')`);
// Convert to JSON
const jsonOut = await conn.query(`
SELECT to_json({name: 'test', values: [1, 2, 3]})
`);For streaming results, use the getJSON() method to parse JSON columns:
const stream = await conn.queryStreaming(`
SELECT json('{"key": "value"}') AS data
`);
for await (const chunk of stream) {
// Automatically parses JSON string to object
const obj = chunk.getJSON<{key: string}>(0, 0);
console.log(obj.key); // "value"
}
// Stream auto-closes after iterationThe WASM binary is optimized for size using:
- -Oz: Maximum size optimization
- LTO: Link-Time Optimization (
-flto) - emmalloc: Smaller memory allocator
- wasm-opt: Binaryen post-processing with
-Oz --converge - Reduced exports: Only 59 essential C functions exported
Result: ~6.0MB gzipped for browser, ~8.1MB gzipped for workers (with Parquet, httpfs, and JSON extensions)
- Node.js 18+
- pnpm 8+
- Emscripten SDK (for DuckDB compilation)
- Binaryen (for wasm-opt)
# Clone with submodules
git clone --recursive https://github.com/tobilg/ducklings.git
cd ducklings
# Install dependencies
pnpm install
# Initialize submodules
make deps# Full build (browser WASM + TypeScript)
make all
# Build both browser and workers WASM
make duckdb-all
# Build both TypeScript packages
make typescript-all
# Individual steps
make duckdb-browser # Compile browser WASM (~2 min)
make duckdb-workers # Compile workers WASM with Asyncify (~3 min)
make typescript-browser # Build @ducklings/browser package
make typescript-workers # Build @ducklings/workers package
# Clean and rebuild
make clean && make allBoth npm packages use the same version, derived from DUCKDB_VERSION in the Makefile:
Makefile: DUCKDB_VERSION := v1.5.0
↓
@ducklings/browser@1.5.0
@ducklings/workers@1.5.0
To update the version, change DUCKDB_VERSION in the Makefile. The version is automatically synced to both package.json files during the build process via make sync-versions.
# Manually sync versions (also runs automatically during typescript builds)
make sync-versions
# Show current versions
make show-versionsFor development/prerelease versions, use VERSION_SUFFIX:
# Build with dev version suffix
make sync-versions VERSION_SUFFIX=-dev.1
make show-versions VERSION_SUFFIX=-dev.1
# Output: npm packages: 1.5.0-dev.1Or set it in the Makefile:
VERSION_SUFFIX := -dev.1 # Results in 1.5.0-dev.1
VERSION_SUFFIX := -alpha.0 # Results in 1.5.0-alpha.0
VERSION_SUFFIX := -beta.1 # Results in 1.5.0-beta.1
VERSION_SUFFIX := -rc.1 # Results in 1.5.0-rc.1
VERSION_SUFFIX := # Empty for stable releaseDev versions are published to npm with the dev tag:
# Install latest stable
npm install @ducklings/browser
# Install latest dev version
npm install @ducklings/browser@devStable release (new DuckDB version):
-
Update
DUCKDB_VERSIONin Makefile:DUCKDB_VERSION := v1.5.0 VERSION_SUFFIX :=
-
Update DuckDB submodule:
make pin-versions
-
Commit and tag:
git add -A git commit -m "Bump DuckDB to v1.5.0" git tag v1.5.0 git push origin main --tags -
The release workflow automatically:
- Validates tag matches
DUCKDB_VERSION - Builds WASM binaries
- Publishes to npm with
latesttag - Creates GitHub release
- Validates tag matches
Dev release (manual dispatch):
- Go to Actions → Release → Run workflow
- Enter version suffix (e.g.,
-dev.1) - Optionally enable dry run
- Run workflow
The workflow publishes with --tag dev so it won't affect latest.
┌─────────────────────┐
│ deps/duckdb/ │
│ (C++ source) │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ deps/duckdb-httpfs │
│ (httpfs extension) │
└─────────┬───────────┘
│
┌───────────────────────────┼───────────────────────────┐
│ │ │
▼ │ ▼
┌───────────────────────┐ │ ┌───────────────────────┐
│ Emscripten (em++) │ │ │ Emscripten (em++) │
│ Browser build │ │ │ Workers build │
│ -Oz, LTO │ │ │ -Oz, LTO, ASYNCIFY │
└───────────┬───────────┘ │ └───────────┬───────────┘
│ │ │
▼ │ ▼
┌───────────────────────┐ │ ┌───────────────────────┐
│ wasm-opt -Oz │ │ │ wasm-opt -Oz │
│ (Binaryen) │ │ │ (Binaryen) │
└───────────┬───────────┘ │ └───────────┬───────────┘
│ │ │
▼ │ ▼
┌───────────────────────┐ │ ┌───────────────────────┐
│ dist/ │ │ │ dist/ │
│ ├── duckdb.js │ │ │ ├── duckdb-workers │
│ └── duckdb.wasm │ │ │ │ .js │
│ (~6.0MB gz) │ │ │ └── duckdb-workers │
└───────────┬───────────┘ │ │ .wasm (~8.1MB) │
│ │ └───────────┬───────────┘
│ │ │
▼ │ ▼
┌───────────────────────┐ │ ┌───────────────────────┐
│ tsup (TypeScript) │ │ │ tsup (TypeScript) │
│ packages/duckdb- │ │ │ packages/duckdb- │
│ wasm-nano/ │ │ │ wasm-nano-workers/ │
└───────────┬───────────┘ │ └───────────┬───────────┘
│ │ │
▼ │ ▼
┌───────────────────────┐ │ ┌───────────────────────┐
│ npm package │ │ │ npm package │
│ @ducklings/browser │◄──────────────┴──────────────►│ @ducklings/workers │
│ (async API) │ │ (async API) │
└───────────────────────┘ └───────────────────────┘
ducklings/
├── Makefile # Build orchestration
├── deps/ # Git submodules
│ └── duckdb/ # DuckDB v1.5.0
├── dist/ # WASM build output
│ ├── duckdb.js # Browser JS glue
│ ├── duckdb.wasm # Browser WASM (~6.0MB gzipped)
│ ├── duckdb-workers.js # Workers JS glue (with Asyncify)
│ └── duckdb-workers.wasm # Workers WASM (~8.1MB gzipped)
├── packages/
│ ├── ducklings-browser/ # @ducklings/browser (npm)
│ │ ├── src/index.ts # Browser entry point
│ │ └── dist/ # Built package
│ │ ├── index.js # ESM bundle
│ │ └── wasm/ # WASM files
│ ├── ducklings-workers/ # @ducklings/workers (npm)
│ │ ├── src/index.ts # Workers entry point (async API)
│ │ └── dist/ # Built package
│ │ ├── index.js # ESM bundle
│ │ └── wasm/ # WASM files
│ ├── example-browser/ # Browser example
│ └── example-cloudflare-worker/ # CF Workers example
├── .github/
│ └── workflows/
│ ├── ci.yml # CI workflow (build, test)
│ └── release.yml # Release workflow (npm publish)
└── scripts/
└── build-duckdb.sh # Emscripten build script
┌─────────────────────────────────────────────────────────────────┐
│ TypeScript API (packages/) │
│ init() → DuckDB → Connection → query() / prepare() / stream() │
├─────────────────────────────────────────────────────────────────┤
│ PreparedStatement / StreamingResult │
│ Parameter binding, chunked iteration, typed accessors │
├─────────────────────────────────────────────────────────────────┤
│ Flechette (Arrow Tables) │
│ tableFromArrays() for building Arrow from columnar data │
├─────────────────────────────────────────────────────────────────┤
│ Emscripten JS Glue (ccall/cwrap) │
│ Type marshalling, memory management, HEAP access │
├─────────────────────────────────┬───────────────────────────────┤
│ @ducklings/browser │ @ducklings/workers │
│ Browser WASM (~6.0MB gz) │ Workers WASM (~8.1MB gz) │
│ - Web Worker + XMLHttpRequest │ - Asyncify + fetch() │
│ - Async API (Promises) │ - Async API (Promises) │
└─────────────────────────────────┴───────────────────────────────┘
- WASM Size: Browser ~6.0MB, Workers ~8.1MB gzipped. Exceeds CF Workers free tier (3MB) but works with paid tier
- No file system: In-memory databases only (use httpfs for remote files)
- No threads: Single-threaded execution
- WASM_BIGINT=0: 64-bit integers passed as two 32-bit values (handled internally)
- No dynamic extension loading: Only statically compiled extensions (Parquet, JSON, httpfs) are available.
INSTALL/LOADcommands for other extensions will not work. Dynamic loading requires Emscripten's-sMAIN_MODULEflag which significantly increases binary size (~2-3x)
MIT