better-duck
A safe, embedded-first Rust client for DuckDB, with an optional Diesel 2.3 ORM backend.
[!WARNING] Beta — the API is settling. Breaking changes before
1.0are possible; check the changelog before upgrading.
Why better-duck?
Most Rust DuckDB bindings depend on Arrow or require a system-installed DuckDB library. better-duck takes a different approach:
- Bundled DuckDB — can ship with the DuckDB C library compiled in; no system package needed.
- No Arrow dependency — columnar I/O is great for data pipelines, but most app-level OLAP code just needs rows. We skip the Arrow overhead entirely.
- Diesel ORM — the
better-duck-dieselcrate is a full Diesel 2.3 backend, so your existingtable!/ query DSL code works without changes. - Embedded-first — designed to run inside Tauri desktop apps, iOS cross-builds, and other environments where you can't rely on a system library.
- Safe public API — every FFI call is wrapped; nothing
unsafeleaks into user code.
Crates
Quick start
[]
# Core only
= "0.1.0-beta.3"
# Or: Core + Diesel ORM backend
= "0.1.0-beta.3"
= "0.1.0-beta.3"
[!NOTE] Cargo's default version requirement (e.g.
"0.1") excludes pre-release versions like-beta.3— pin the exact pre-release version as shown above, or runcargo add better-duck-core --version 0.1.0-beta.3.
better-duck-core
A low-level, no-ORM DuckDB wrapper that gives you direct access to connections, prepared statements, the bulk appender, and the full DuckValue type hierarchy — without pulling in an ORM.
Opening a connection
use Connection;
// in-memory (great for tests and one-shot scripts)
let mut conn = open_in_memory?;
// on-disk file
let mut conn = open?;
Execute and iterate rows
use ;
Parameterized queries
Parameters are positional ($1, $2, …) and passed as &mut [&mut dyn AppendAble]:
use DuckValue;
let mut threshold = Double;
let mut rows = conn.execute_with?;
for row in rows
Bulk insert with the Appender
The Appender streams rows directly into DuckDB's bulk-ingest path — much faster than individual INSERTs for large datasets:
use ;
use ;
use Result;
;
let mut conn = open_in_memory?;
conn.execute_batch?;
let mut app = conn.appender?;
for i in 0..10_000i32
app.save?; // flush to DuckDB
The appender auto-flushes on drop (errors go to stderr); call .save() explicitly if you want to handle flush errors.
Sharing a database, pooling, and async
Connection::open_in_memory() gives each connection its own independent in-memory
database. To share one database across multiple connections — including in-memory
ones — open a Database and connect() from it:
use Database;
let db = open_in_memory?;
let mut a = db.connect?;
let mut b = db.connect?;
a.execute_batch?;
b.execute_batch?; // b sees a's table
With the pool feature, Database backs an r2d2 connection pool:
use ;
let manager = memory?;
let pool = builder.max_size.build?;
let mut conn = pool.get?;
With the async feature, AsyncConnection wraps a Connection behind
tokio::task::spawn_blocking, so it never blocks the async executor:
use AsyncConnection;
let conn = open_in_memory.await?;
conn.execute_batch.await?;
let result = conn.execute.await?; // returns a ResultSet
async + pool together enable AsyncPool, whose with() method checks a
connection out and runs a closure on a blocking thread, matching the pattern used
for transactions (which must not span an .await point).
DuckValue type hierarchy
Rows are yielded as DuckRow, and each column value is a DuckValue:
use DuckValue;
match value
DuckValue is #[non_exhaustive] — match with _ to stay forward-compatible as new types are added.
Supported DuckDB types
| DuckDB type | Rust type |
|---|---|
BOOLEAN |
bool |
TINYINT / UTINYINT |
i8 / u8 |
SMALLINT / USMALLINT |
i16 / u16 |
INTEGER / UINTEGER |
i32 / u32 |
BIGINT / UBIGINT |
i64 / u64 |
HUGEINT / UHUGEINT |
i128 / u128 |
FLOAT |
f32 |
DOUBLE |
f64 |
DECIMAL (feature: decimal) |
rust_decimal::Decimal |
VARCHAR / TEXT |
String |
BLOB |
better_duck_core::types::blob::Blob |
DATE |
chrono::NaiveDate (chrono) / DuckDate |
TIME |
chrono::NaiveTime (chrono) / DuckTime |
TIMESTAMP |
chrono::NaiveDateTime (chrono) |
TIMESTAMPTZ |
chrono::DateTime<Utc> (chrono) |
TIME_TZ |
date_chrono::TimeTz (chrono) / DuckTimeTz — UTC offset fully preserved |
INTERVAL |
chrono::Duration (chrono) / std::time::Duration |
LIST / ARRAY |
Vec<DuckValue> / Box<[DuckValue]> |
STRUCT |
HashMap<String, DuckValue> |
MAP |
HashMap<DuckValue, DuckValue> |
UNION |
Box<DuckValue> (active member; see roadmap for multi-arm write support) |
ENUM |
String |
UUID |
better_duck_core::types::uuid::DuckUuid |
BIT |
better_duck_core::types::bit::DuckBit |
BIGNUM |
better_duck_core::types::bignum::DuckBignum |
User-defined functions (feature: udf)
Register plain Rust functions as DuckDB scalar or table functions with the
#[duckdb_scalar] / #[duckdb_table_function] attribute macros — no unsafe,
no manual vector handling. Parameter and return types are inferred from the
Rust signature.
use ;
/// Scalar function: one value per row, usable in a SELECT list.
/// Table function: rows and columns, usable in a FROM clause.
+ Send
let mut conn = open_in_memory?;
register?;
register?;
conn.execute?; // "ababab"
conn.execute?; // 5050
Option<T> parameters/returns propagate NULL explicitly; a Result<T, E>
return fails the query with E's message. See the udf module docs
for the full attribute reference and the panic-containment/panic = "abort" caveat.
better-duck-diesel
A full Diesel 2.3 backend for DuckDB. Write normal Diesel DSL code against any DuckDB database — including in-memory, on-disk, and (soon) remote.
Connecting
use DuckDbConnection;
use *;
// in-memory
let mut conn = establish?;
// on-disk file
let mut conn = establish?;
// with duckdb:// URL prefix (prefix is stripped)
let mut conn = establish?;
INSERT, SELECT, UPDATE, DELETE
use DuckDbConnection;
use ;
table!
Transactions
conn.transaction?;
DuckDB-specific SQL types
Use DuckDB types that don't have a standard Diesel equivalent by importing them via sql_types:
table!
DuckDB ↔ Diesel ↔ Rust type mapping
Standard Diesel types (work out of the box):
| Diesel SQL type | DuckDB type | Rust type |
|---|---|---|
Bool |
BOOLEAN |
bool |
SmallInt |
SMALLINT |
i16 |
Integer |
INTEGER |
i32 |
BigInt |
BIGINT |
i64 |
Float |
FLOAT |
f32 |
Double |
DOUBLE |
f64 |
Text |
VARCHAR |
String |
Binary |
BLOB |
Vec<u8> |
Date |
DATE |
chrono::NaiveDate (chrono) |
Time |
TIME |
chrono::NaiveTime (chrono) |
Timestamp |
TIMESTAMP |
chrono::NaiveDateTime (chrono) |
Numeric |
DECIMAL |
rust_decimal::Decimal (decimal) |
DuckDB-specific types (import via better_duck_diesel::sql_types::*):
| Diesel SQL type | DuckDB type | Rust type |
|---|---|---|
DuckTinyInt |
TINYINT |
i8 |
DuckUTinyInt |
UTINYINT |
u8 |
DuckUSmallInt |
USMALLINT |
u16 |
DuckUInt |
UINTEGER |
u32 |
DuckUBigInt |
UBIGINT |
u64 |
DuckHugeInt |
HUGEINT |
i128 |
DuckUHugeInt |
UHUGEINT |
u128 |
DuckTimestamptz |
TIMESTAMPTZ |
chrono::DateTime<Utc> (chrono) |
DuckInterval |
INTERVAL |
chrono::Duration (chrono) |
DuckTimeTz |
TIME WITH TIME ZONE |
CoreTimeTz (chrono) |
DuckTimeNs |
TIME_NS |
chrono::NaiveTime (chrono) |
DuckEnum |
ENUM |
String |
DuckList |
LIST |
Vec<DuckValue> |
DuckArray |
ARRAY |
Vec<DuckValue> |
DuckStruct |
STRUCT |
HashMap<String, DuckValue> |
DuckMap |
MAP |
HashMap<DuckValue, DuckValue> |
DuckUnion |
UNION |
Box<DuckValue> (active member) |
DuckUuid |
UUID |
better_duck_core::types::uuid::DuckUuid |
DuckBit |
BIT |
better_duck_core::types::bit::DuckBit |
DuckBignum |
BIGNUM |
better_duck_core::types::bignum::DuckBignum |
[!NOTE] Date/time types work either way: with the
chronofeature they map tochronotypes; without it, they map tobetter_duck_core::types::date_native's plain structs andstd::timetypes. Only one set is compiled at a time.
Feature flags
better-duck-core
| Feature | Default | Description |
|---|---|---|
bundled |
✓ | Compile and embed the DuckDB C library (no system install needed) |
chrono |
✓ | chrono date/time conversions for DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL |
decimal |
✓ | rust_decimal::Decimal support for DECIMAL columns |
json |
— | Enable DuckDB's JSON extension (requires bundled) |
parquet |
— | Enable DuckDB's Parquet extension (requires bundled) |
buildtime_bindgen |
— | Regenerate FFI bindings at build time (requires LLVM/clang) |
async |
— | Tokio-based async facade (AsyncConnection, AsyncDatabase) over spawn_blocking |
pool |
— | r2d2 connection pool backed by a shared Database handle |
udf |
— | #[duckdb_scalar] / #[duckdb_table_function] user-defined functions |
better-duck-diesel
| Feature | Default | Description |
|---|---|---|
bundled |
✓ | Forwards to better-duck-core/bundled |
decimal |
✓ | Diesel Numeric ↔ rust_decimal::Decimal |
chrono |
— | Diesel date/time impls for DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, TIME_TZ, TIME_NS |
r2d2 |
— | r2d2 connection pool support via diesel::r2d2 |
Benchmarks
The workspace includes a benchmark harness at
crates/better-duck-core/benches/comparison.rs
that compares better-duck-core against the community duckdb
crate, in-process (no subprocess overhead on either side), across primitive types, composite
types, and five representative operations. Run it with:
Results are written to docs/benchmarks/: REPORT.md (full tables + charts),
results.json (raw numbers), and one latency/throughput SVG pair per group
(comparison-primitive-types-*.svg, comparison-composite-types-*.svg,
comparison-operations-*.svg).
Sample results (Operations group; see REPORT.md for the full
primitive- and composite-type tables):
| Workload | better-duck-core |
duckdb crate |
|---|---|---|
| CRUD basics (4 ops) | 4.73 ms / 846 ops/s | 4.59 ms / 871 ops/s |
| Bulk ingest — 10k rows (appender) | 31.94 ms / 313.1 k rows/s | 38.55 ms / 259.4 k rows/s |
| Analytical GROUP BY — 100k rows | 4.19 ms / 23.9 M rows/s | 4.24 ms / 23.6 M rows/s |
| Prepared reuse — 100 queries | 75.72 ms / 1.3 k queries/s | 68.16 ms / 1.5 k queries/s |
| All-types scan — 1k rows, 11 cols | 32.91 ms / 30.4 k rows/s | 27.46 ms / 36.4 k rows/s |
Numbers move somewhat between runs due to normal system noise — the relative comparison within a single run is what's meaningful, not absolute milliseconds across runs.
Migrating from the community duckdb crate
| Operation | duckdb crate |
better-duck-core |
|---|---|---|
| Open in-memory | Connection::open_in_memory()? |
Connection::open_in_memory()? |
| Execute DDL | conn.execute_batch(sql)? |
conn.execute_batch(sql)? |
| Insert / DML | conn.execute(sql, [])? |
conn.execute(sql)?.changes() |
| SELECT rows | conn.prepare(sql)?.query([]) |
conn.execute(sql)? (is an Iterator) |
| Parameterized | conn.execute(sql, params![v])? |
conn.execute_with(sql, &mut [&mut v])? |
| Bulk insert | conn.appender(table)? |
conn.appender(table, schema)? |
Supported platforms
| Platform | Status |
|---|---|
| Linux x86_64 | ✓ CI-tested |
| macOS Apple Silicon (aarch64) | ✓ CI-tested |
| macOS x86_64 | ✓ CI-tested |
| Windows x86_64 | ✓ CI-tested |
| iOS aarch64 | ✓ CI cross-build |
| iOS Simulator x86_64 | ✓ CI cross-build |
Roadmap
The library is usable today for most workloads. Here's an honest list of what's still in progress — contributions are very welcome.
Recently landed
- New core types —
UUID,BIT, andBIGNUMare implemented end-to-end (core read/write + DieselFromSql/ToSql).GEOMETRY,VARIANT,ANY, andINTEGER_LITERALremain unsupported — the DuckDB C API has no value accessor for them (unlike the three above), so reading a column of these types still panics. TIME_TZtimezone offset — fully preserved on both read and write, in core and in Diesel.- Diesel
FromSql/ToSqlfor composite types — STRUCT, MAP, UNION, and ARRAY are implemented. UNION's Rust mirror is the active member's value only (see Mid-term below for multi-arm support). - Diesel date/time without
chrono—date_nativeis wired up; DATE/TIME/TIMESTAMP/INTERVAL/ TIMESTAMPTZ/TIME_TZ/TIME_NS all work over Diesel without thechronofeature. DuckResult::exists()and row cache —exists()peeks without consuming the iterator;rewind()replays already-pulled rows.push_debug_binds— implemented;debug_query/EXPLAINlogging works.- Empty-collection type inference —
Vec<T>,Box<[T]>, andHashMap<K, V>convert toLIST/ARRAY/MAPusingT's (orK/V's) static [DuckLogicalType], not by inspecting the first element — so they work even when empty, unlike the untypedDuckValue::List/Array/Map, which still can't infer an element type from zero entries. asyncAPI —AsyncConnection/AsyncDatabase/AsyncPool(featureasync), a tokio-only facade overspawn_blocking.- Core-level connection pooling —
Database+r2d2-backedPool(featurepool), which shares one database across every pooled connection (unlike opening N independent connections).better-duck-diesel's ownr2d2feature (viadiesel::r2d2::ConnectionManager) is unaffected — both may be used side by side. - User-defined functions (feature
udf) —#[duckdb_scalar]and#[duckdb_table_function]register a plain Rust function as a DuckDB scalar or table function, with parameter/return types inferred from the Rust signature viaDuckLogicalType. Backed by a newbetter-duck-macrosproc-macro crate. Panics are caught and reported as query errors underpanic = "unwind"; see theudfmodule docs for thepanic = "abort"caveat. Named parameters, projection pushdown,max_threads, andvarargsare not yet supported. - Query-path performance fixes —
Decimalbinds no longer allocate aStringper row;u128/UHUGEINT has a direct typed append/bind path (previously fell back to a slower generic one); every query execution no longer heap-allocates a throwawayduckdb_resultbox; andDuckResult::count()no longer materializes aDuckRowfor rows it's about to discard. See the benchmarks section and the changelog for details and before/after numbers.
Mid-term
- Diesel
prepare_for_cachedistinction — DuckDB's C API has a singleduckdb_preparepath with no unnamed/one-shot variant, so there is currently nothing to honour here; revisit if that changes upstream. - Multi-arm UNION write — the current write path only builds single-member unions, and
DuckValue::Unioncarries no tag or member names. Real multi-arm unions need a richer variant. - DECIMAL precision —
decimal_value.widthis read but discarded;DECIMAL(18,2)round-trips to a different declared precision. Needs aDuckValue::Decimalshape change to carry width. RawConnectionpanic-on-drop hardening — a connection that fails to close panics inDrop; underpanic = "abort"this aborts the process. Low risk today (close()always returnsOk), but worth hardening to log-and-continue, especially now that the pool multiplies the exposure.
Exploratory / RFC
better-duck-tauricrate — a Tauri plugin that wrapsbetter-duck-corewith auto-discovery of the app data directory, a repository/unit-of-work abstraction, and Tauri command bindings. Filed as an idea; design input welcome.- WASM / browser target — DuckDB has a WASM build; exploring whether
better-duck-corecan compile towasm32-unknown-unknownis on the list.
Contributing
See CONTRIBUTING.md for the full guide — environment setup, git flow, commit conventions, how to add a new DuckDB type, and the PR checklist.
If you hit a bug or want to propose a feature, please open an issue.
License
Licensed under either of:
at your option.