Connect tech.v3.dataset to DuckDB.
A near drop-in replacement for tmducken that uses Java's Panama Foreign Function & Memory API instead of JNA.
Better stability. Built on JDK 22+ Project Panama (java.lang.foreign.*) instead of JNA:
- Native memory lives in scoped
Arenas β released deterministically, not when the GC runs. - GC-race use-after-free segfaults are ruled out by construction.
More DuckDB types. Read and write support for BLOB, HUGEINT, DECIMAL, INTERVAL, ENUM, LIST, STRUCT, MAP, and all timestamp precision variants β types tmducken does not handle.
Streaming appender API. open-appender / append-dataset! /
flush-appender! keep DuckDB's appender alive across batches, amortizing setup
cost β up to 10Γ faster than repeated insert-dataset! for small-batch
ingest (see Streaming
inserts).
Performance tuned. Parallel column encode/decode, direct MethodHandle FFI
dispatch, partitioned parallel-concat for multi-chunk reads β up to 4Γ
faster than tmducken (see Benchmarks).
- JDK 22+ (Panama FFM is a final API as of JDK 22)
- DuckDB 1.5+ (tested against 1.5.2)
Add the dependency and the required JVM option to your deps.edn:
{:deps {ai.dyal/ducktape {:mvn/version "0.1.0-SNAPSHOT"}}
:aliases
{:dev {:jvm-opts ["--enable-native-access=ALL-UNNAMED"]}}}The --enable-native-access=ALL-UNNAMED JVM option is required β Panama's
FFI refuses native downcalls without it.
(require '[ducktape.core :as duck]
'[tech.v3.dataset :as ds])
(duck/initialize!)
(with-open [db (duck/open-db) ;; in-memory, or (open-db "/tmp/my.db")
conn (duck/connect db)]
(let [my-ds (ds/->dataset {:name ["Alice" "Bob" "Carol"]
:age [30 25 35]
:score [9.5 8.2 9.8]}
{:dataset-name "people"})]
(duck/create-table! conn my-ds)
(duck/insert-dataset! conn my-ds))
(duck/sql->dataset conn "SELECT * FROM people WHERE score > 9.0"
{:key-fn keyword}))
;; => :_unnamed [2 3]:
;; | :name | :age | :score |
;; |--------|-----:|-------:|
;; | Alice | 30 | 9.5 |
;; | Carol | 35 | 9.8 |open-db returns a Db, connect returns a Conn. Both implement
java.lang.AutoCloseable. Close is idempotent; ops on a closed handle
throw IllegalStateException. (duck/disconnect conn) and
(duck/close-db db) are equivalent to (.close conn) / (.close db).
;; with-tx β BEGIN, body, COMMIT on success; ROLLBACK + rethrow on throw
(duck/with-tx [conn]
(duck/run-query! conn "INSERT INTO ledger VALUES (1, 100)")
(duck/run-query! conn "INSERT INTO ledger VALUES (2, -100)"))
;; transact! β same semantics, fn-based
(duck/transact! conn
(fn [c]
(duck/run-query! c "INSERT INTO ledger VALUES (3, 50)")
:ok))DuckDB does not support nested transactions.
For producers that feed the database many small batches (Kafka consumers, paginated API ingest, file shards), use the stateful appender API to amortize DuckDB's per-call setup costs across batches:
(with-open [app (duck/open-appender conn sample-ds)]
(doseq [batch dataset-stream]
(duck/append-dataset! app batch))
;; close flushes; or call (duck/flush-appender! app) for explicit
;; commit points if you need bounded data-loss windows.
)sample-ds is a tech.v3.dataset whose column dtypes (and :name
metadata) define the schema every batch must match. Multiple appenders
can be open simultaneously on the same connection β typically one per
destination table.
See Benchmarks for a quantitative comparison vs repeated
insert-dataset! calls (up to 10Γ faster for tiny batches).
| Function | Description |
|---|---|
initialize! |
Load the DuckDB shared library. Call once at startup. |
open-db / close-db |
Open/close a database (path or in-memory). Returns an AutoCloseable Db. |
connect / disconnect |
Create/destroy a connection. Returns an AutoCloseable Conn. |
open? |
Predicate β true while a Db/Conn handle's native resource is live. |
transact! / with-tx |
Run body inside BEGIN/COMMIT; auto-ROLLBACK + rethrow on any throw. |
run-query! |
Execute SQL, ignore results (DDL, DML) |
create-table! / drop-table! |
Create/drop a table from a dataset schema |
insert-dataset! |
Bulk insert via DuckDB's data chunk appender API |
open-appender / append-dataset! / flush-appender! |
Long-lived streaming appender β amortizes setup across many batches |
sql->dataset |
Query β single dataset |
sql->datasets |
Query β lazy sequence of chunk datasets |
prepare |
Prepared statement (0-arity, 1-arity, or N-arity) |
initialize! searches for the DuckDB shared library in this order:
:duckdb-homeoption (directory path)DUCKDB_HOMEenvironment variable- Default system library paths
| DuckDB Type | Clojure | Read | Write |
|---|---|---|---|
| BOOLEAN, TINYINT, SMALLINT, INTEGER, BIGINT | primitives | β | β |
| UTINYINT, USMALLINT, UINTEGER, UBIGINT | primitives | β | β |
| FLOAT, DOUBLE | primitives | β | β |
| VARCHAR | String | β | β |
| BLOB | byte[] | β | β |
| UUID | java.util.UUID | β | β |
| DATE | LocalDate | β | β |
| TIME | LocalTime | β | β |
| TIMESTAMP | Instant | β | β |
| TIMESTAMP WITH TIME ZONE | Instant | β | β |
| TIMESTAMP_S / _MS / _NS | Instant | β | β |
| HUGEINT | BigInteger | β | β |
| DECIMAL | BigDecimal | β | β |
| INTERVAL | {:months :days :micros} |
β | β |
| ENUM | String | β | β |
| LIST | vector | β | β |
| STRUCT | map (keyword keys) | β | β |
| MAP | map | β | β |
tmducken uses JNA (via dtype-next's FFI layer) to call DuckDB's C API. Panama eliminates several layers of overhead:
- No marshalling. JNA copies arguments through
libffifor every call. Panama generates directMethodHandledowncalls that the JIT compiles to ordinary machine code. - No reflection. JNA resolves signatures at runtime. Panama resolves
FunctionDescriptorlayouts at link time and produces typed handles the JIT can inline. - No global lock. JNA's library loading holds a global synchronization lock. Panama's
SymbolLookupis lock-free after initial load. - Deterministic memory. JNA relies on
Memory.finalizefor native allocations (GC-dependent cleanup). Panama'sArenascoping guarantees deterministic deallocation withwith-open. - Typed memory access. JNA's
Pointer.getLong(offset)goes through a general-purpose accessor. Panama'sMemorySegment.get(ValueLayout.JAVA_LONG, offset)carries the layout statically, enabling the JIT to emit a singlemovinstruction.
1M rows, JDK 25, DuckDB 1.5.2, Apple M-series. Same JVM, same datasets, 1.5s JIT warmup per fn, 30 samples per phase per library, interleaved per-sample alternation. Speedup is tmducken_mean / ducktape_mean; values above 1.0Γ mean ducktape is faster. All twelve metrics are statistically significant at 95% CI.
| Workload | tmducken rows/s | ducktape rows/s | Speedup | |
|---|---|---|---|---|
| numeric | INSERT | 25,636,285 | 28,864,127 | 1.13Γ |
| QUERY | 48,066,662 | 170,902,963 | 3.56Γ | |
| string | INSERT | 2,626,336 | 4,190,803 | 1.60Γ |
| QUERY | 4,677,947 | 8,327,285 | 1.78Γ | |
| uuid | INSERT | 21,876,992 | 38,133,634 | 1.74Γ |
| QUERY | 19,504,444 | 30,279,061 | 1.55Γ | |
| mixed | INSERT | 6,341,387 | 9,288,231 | 1.46Γ |
| QUERY | 9,254,418 | 18,987,116 | 2.05Γ | |
| wide-numeric | INSERT | 16,916,895 | 18,984,929 | 1.12Γ |
| QUERY | 21,564,755 | 86,642,974 | 4.02Γ | |
| wide-mixed | INSERT | 3,611,626 | 4,681,157 | 1.30Γ |
| QUERY | 5,387,781 | 9,697,254 | 1.80Γ |
Workload schemas (1M rows each):
- numeric β 4 columns:
int64,float64,int32,float32. - string β 3 columns: short string (~5 chars), long string (~25 chars),
int64id. - uuid β 2 columns:
int64id,UUID. - mixed β 4 columns:
int64,float64, string,LocalDate. - wide-numeric β 8 numeric/temporal columns: 2Γ
int64, 2Γfloat64, 2Γint32, 2ΓLocalDate. Exercises the partitioned parallel-concat fast-path with enough columns to fully utilise typical core counts. - wide-mixed β 10 columns: the 8 from
wide-numericplus 2 string columns. Realistic OLAP fact-table shape, mixing fast-path numeric columns with fallback-path string columns.
The bench harness lives in dev/tmducken_comparison.clj. Run (require '[tmducken-comparison :as cmp]) then (cmp/compare-all), or invoke individual workloads via (cmp/compare-numeric), (cmp/compare-wide-numeric), etc.
The streaming open-appender / append-dataset! API amortizes the per-call
DuckDB FFI setup (appender create/destroy, column-type probe, data chunk
allocation, logical type creation/destruction) across many batches. Below,
100k total rows split into varying numbers of batches; each cell is
speedup-mean Γ / trimmed-mean Γ for insert-dataset! Γ· appender.
| Workload | 10 batches Γ 10k rows | 100 batches Γ 1k rows | 1000 batches Γ 100 rows | 10000 batches Γ 10 rows |
|---|---|---|---|---|
| numeric | 1.15Γ / 1.27Γ | 2.75Γ * / 2.98Γ | 8.94Γ * / 9.14Γ | 10.62Γ * / 10.54Γ |
| string | 1.09Γ * / 1.09Γ | 1.53Γ * / 1.54Γ | 4.82Γ * / 4.81Γ | 9.19Γ * / 9.20Γ |
| mixed | 1.23Γ * / 1.18Γ | 2.05Γ * / 2.01Γ | 6.03Γ * / 6.12Γ | 8.27Γ * / 9.28Γ |
* = statistically significant at 95% CI on the mean. Same JVM (JDK 25,
DuckDB 1.5.2, Apple M-series), 1.5s warmup per fn, 30 interleaved samples.
The amortization scales with batch frequency. At 10 Γ 10k-row batches there
is little setup to amortize and per-batch encoding work dominates (1.1β1.3Γ).
At 10000 Γ 10-row batches the per-batch setup overhead dominates the
insert-dataset! path, so the streaming API wins by roughly an order of
magnitude. Numeric workloads see the largest relative gains because the
actual encoding work is cheapest, making setup overhead proportionally larger.
The bench harness lives in dev/appender_comparison.clj. Run
(require '[appender-comparison :as ac]) then (ac/compare-all), or
(ac/compare-streaming :string 100000 1000) for a single configuration.
The included flake.nix provides DuckDB and sets DUCKDB_HOME automatically:
nix developSee Installation for the dependency coordinate and required JVM option. Snapshots are published to Clojars; nothing else needs to be configured.
Ducktape publishes to Clojars as ai.dyal/ducktape. The build script lives
in dev/build.clj and runs via the :build alias.
The version is resolved in this order: :version CLI arg β VERSION env var β
0.1.0-SNAPSHOT default. So all three of these work:
VERSION=0.2.0-SNAPSHOT clj -T:build deploy
clj -T:build deploy :version '"0.2.0-SNAPSHOT"'
clj -T:build deploy # β 0.1.0-SNAPSHOT| Command | What it does |
|---|---|
clj -T:build jar |
Build the jar under target/ |
clj -T:build install |
Install to ~/.m2 for local consumption |
clj -T:build deploy |
Publish to Clojars (needs credentials, below) |
clj -T:build clean |
Remove target/ |
deploy reads two env vars:
CLOJARS_USERNAMEβ your Clojars usernameCLOJARS_PASSWORDβ a Clojars deploy token, ideally scoped toai.dyal/*. Not your account password.
Run the Release workflow from the repo's Actions tab. The default
version is 0.1.0-SNAPSHOT; override it in the workflow input if needed.
The release flow has two steps: stamp the changelog, then tag.
# 1. Prepend the new release section to CHANGELOG.md
git cliff --tag v0.1.0 --unreleased --prepend CHANGELOG.md
# 2. Commit, tag, push
git add CHANGELOG.md
git commit -m "docs: changelog for v0.1.0"
git tag v0.1.0
git push origin main v0.1.0The Release workflow then:
- Runs the test suite.
- Publishes
ai.dyal/ducktape 0.1.0to Clojars. - Re-runs
git-clifffor release notes (same content as the newCHANGELOG.mdsection). - Creates a GitHub Release at the tag with those notes as the body.
Preview the notes before stamping:
git cliff --unreleased # what would land in the next release
git cliff --latest # what landed in the most recent releaseSections in CHANGELOG.md are grouped by Conventional Commit type (feat: β
Features, fix: β Bug Fixes, perf: β Performance, etc.) per the rules in
cliff.toml.
MIT β Copyright Β© 2026 Dynamic Alpha Technologies Inc. See LICENSE.