Skip to main content
better-duck is a small family of crates layered over a single vendored copy of DuckDB. This page is the mental model: which crate does what, how the safe Rust API is built on top of DuckDB’s C API, and what “embedded-first” means for your build. Read it once and the rest of the docs — the core driver, the Diesel backend, the Tauri plugin — slot into place.

The crate map

Most applications depend on better-duck-core alone (plus better-duck-diesel if you want the ORM). Everything else is either pulled in transitively or used only in a specific context.
The crate published as better-duck on crates.io is better-duck-core. Throughout these docs, “the core crate” means better-duck-core, and imports are written better_duck_core::….

How the layers stack

better-duck-diesel and better-duck-tauri both build on core rather than wrapping it opaquely — you can always drop to the underlying core Connection (Diesel exposes it via inner_mut(); the Tauri plugin runs core directly) for the appender, columnar reads, UDF registration, or raw SQL that the higher layers don’t surface.

The FFI safety model

DuckDB ships a C API. better-duck’s central promise is that nothing unsafe leaks into your code while still exposing the full type system. A few principles make that hold:
Handles are owned by Rust types that free them on drop (Connection, Database, Appender, Statement, DataChunkHandle, result handles). Inputs that the C API cannot represent are rejected before the call: a &str or path with an interior NUL byte becomes Error::NulError, an out-of-range column index becomes Error::InvalidColumnIndex, a wrong parameter count becomes Error::InvalidParameterCount.
A failed query surfaces as Error::Engine(EngineError { kind, message }), where every field is copied out of DuckDB’s own memory when the error is built. The error stays valid after the result — and the connection — are gone, and kind preserves DuckDB’s typed classification (never guessed by parsing message text).
A VectorMut is built from &mut DataChunkHandle, so the compiler guarantees it is the only live view of that column — an aliasing hole present in some other DuckDB bindings. A DuckResult is consumed in exactly one mode (row-by-row or chunk-by-chunk); mixing them is a clean Error::MixedIterationMode, not undefined behavior.
When DuckDB invalidates a resource mid-operation — most notably a failed flush on the Appender — the Rust wrapper poisons it: every later call fails fast without re-entering DuckDB, and drop skips the dangerous re-flush. The originating connection stays usable.

The handful of unsafe entry points

The safe surface is the default; unsafe appears only where the caller must uphold a contract the compiler cannot check, and each is clearly marked:
  • Connection::open_from_raw(db) — wrap a duckdb_database owned by a host process (loadable extensions); it never closes that database.
  • Connection::register_arrow(view_name, stream) — register a caller-owned ArrowArrayStream pointer; the stream must stay valid until the returned ArrowView guard drops.
Panics and panic = "abort". UDF/callback panics are caught and reported to DuckDB as a query error only under panic = "unwind" (the default for dev/test). Under panic = "abort" — common in release — a panicking callback aborts the process. In fallible UDF bodies, return Err (or duck_bail!) instead of panicking. See error handling.

Bundled DuckDB (embedded-first)

DuckDB is compiled from vendored source and statically linked into your binary by better-duck-sys. There is no system package to install and no shared library to ship at runtime. This is what lets better-duck run inside Tauri desktop apps, iOS cross-builds, and anywhere a system library isn’t an option.
Because the engine is compiled from source, your first build (and any clean rebuild) is slower than a typical crate — it is compiling DuckDB itself. Incremental builds reuse the artifact. Turning on an extension feature (json, parquet, icu) recompiles a DuckDB variant, which takes noticeably longer. See Installation for build-time guidance.

How features flow down to the sys crate

The extension feature flags on better-duck-core are forwarded to better-duck-sys, which compiles that extension straight into the static library — so json, parquet, and icu need no runtime INSTALL / LOAD. Extensions without a bundling feature (for example spatial) are instead loaded at runtime through the extension API. The DuckDB version itself is pinned in better-duck-sys; better_duck_core::library_version() returns the linked version string at runtime.

The loadable-extension inversion

The experimental loadable-extension feature flips the model: the crate links no DuckDB of its own and routes every FFI call through a host DuckDB’s dynamically-loaded API table, building a cdylib. It pairs with the #[duckdb_entrypoint] macro and is for writing a DuckDB extension in Rust — not for a normal embedded application.

Where to next

Feature flags

Every Cargo feature, grouped, with its implications.

Type system

How DuckDB types arrive as DuckValue, feature by feature.

Error handling

How failures surface, from NulError to EngineError.

Versioning & compatibility

Beta policy, MSRV, platforms, and the vendored DuckDB version.