The Rust SDK for building DuckDB loadable extensions — no C, no C++, no glue code.
quack-rs provides safe, production-grade wrappers for the DuckDB C Extension API, removing every known FFI pitfall so you can focus entirely on writing extension logic in pure Rust.
- Why quack-rs?
- What quack-rs Solves
- Quick Start
- Module Reference
- FFI Pitfalls Reference
- Community Extension Compliance
- Architecture
- Testing strategy
- Known Limitations
- Changelog
- Contributing
- License
The DuckDB community extensions FAQ states:
Writing a Rust-based DuckDB extension requires writing glue code in C++ and will force you to build through DuckDB's CMake & C++ based extension template. We understand that this is not ideal and acknowledge the fact that Rust developers prefer to work on pure Rust codebases.
The DuckDB C Extension API (available since v1.1) changes this. quack-rs wraps that API
and eliminates every rough edge, so you write zero lines of C or C++.
| Problem | Without quack-rs | With quack-rs |
|---|---|---|
| Entry point boilerplate | ~40 lines of unsafe extern "C" code |
1 macro call |
| State init/destroy | Raw Box::into_raw / Box::from_raw |
FfiState<T> handles all of it |
| Boolean reads | UB if read as bool directly |
VectorReader::read_bool uses u8 != 0 |
| NULL output | Silent corruption if ensure_validity_writable skipped |
VectorWriter::set_null calls it automatically |
| LogicalType memory | Leak if not freed | LogicalType implements Drop |
| Aggregate combine | Config fields lost on segment-tree merges | Testable with AggregateTestHarness |
| FFI panics | Process abort or undefined behavior | init_extension never panics; scalar_callback! / table_scan_callback! catch panics |
| Table functions | ~100 lines of raw bind/init/scan callbacks | TableFunctionBuilder 5-method chain, or TypedTableFunctionBuilder<S> with two safe Rust closures |
| Replacement scans | Undocumented vtable + manual string allocation | ReplacementScanBuilder 4-method chain |
| Complex types (STRUCT/LIST/MAP/ARRAY) | Manual offset arithmetic over child vectors | StructVector, ListVector, MapVector, ArrayVector helpers |
| Complex param/return types | Raw duckdb_create_logical_type + manual lifecycle |
param_logical(LogicalType) / returns_logical(LogicalType) on all builders |
| Init error propagation | Forced to panic!() when runtime allocation fails |
ExtensionError has From<io::Error> — use ? directly |
| TLS configuration | No standard way to inject custom TLS configs | TlsConfigProvider trait (type-erased, zero deps) |
| Security warnings | Every extension re-invents warning infrastructure | ExtensionWarning + WarningCollector with CWE codes |
| Secrets management | Custom in-memory secrets + DuckDB bridge per extension | SecretsManager trait + SecretEntry builder |
| Extension naming | Rejected by DuckDB CI with no explanation | validate_extension_name catches issues before submission |
| description.yml | No tooling to validate before submission | validate_description_yml_str validates the whole file |
| New project setup | Hours of boilerplate + reading DuckDB internals | generate_scaffold produces all 11 required files |
Building a DuckDB extension in Rust — from project setup to community submission — requires navigating undocumented C API contracts, FFI memory rules, and data-encoding specifics found only in DuckDB's source code, which surface as silent corruption, process aborts, or unexplained CI rejections rather than compiler errors. quack-rs eliminates these barriers systematically across the complete extension lifecycle — scaffolding, function registration, type-safe data access, aggregate testing, metadata validation, and community submission readiness — with every abstraction backed by a documented, reproducible pitfall in LESSONS.md, making correct behavior automatic and incorrect behavior a compile-time error wherever the type system permits. The result is that any Rust developer can build, test, and ship a production-quality DuckDB extension without prior knowledge of DuckDB internals, covering every extension type exposed by DuckDB's public C Extension API: scalar, aggregate, table, cast, copy, replacement scan, and SQL macro functions.
quack-rs encapsulates 21 documented FFI pitfalls — hard-won knowledge from building
real DuckDB extensions in Rust:
L1 COMBINE must propagate ALL config fields (not just data)
L2 State destroy double-free → FfiState<T> nulls pointers after free
L3 No panics across FFI → init_extension uses Result throughout
L4 ensure_validity_writable required before NULL output → VectorWriter handles it
L5 Boolean reading must use u8 != 0 → VectorReader enforces this
L6 Function set name must be set on EACH member → Set builders enforce on every member
L7 LogicalType memory leak → LogicalType implements Drop
L8 DEFAULT_NULL_HANDLING does NOT propagate NULLs for scalar functions →
map1/map2/map1_str/map2_str do it; DataChunk::propagate_nulls for raw callbacks
P1 Library name must match [lib] name in Cargo.toml exactly
P2 C API version ("v1.2.0") ≠ DuckDB release version ("v1.4.4" / "v1.5.0")
P3 E2E SQLLogicTests required for community submission
P4 extension-ci-tools submodule must be initialized
P5 SQLLogicTest output must match DuckDB CLI output exactly
P6 Function registration can fail silently → builders check return values
P7 DuckDB strings use 16-byte format with inline and pointer variants
P8 INTERVAL is { months: i32, days: i32, micros: i64 } — not a single i64
P9 loadable-extension dispatch table uninitialised in cargo test → InMemoryDb initialises it
See LESSONS.md for full analysis of each pitfall.
[dependencies]
quack-rs = "0.13"
libduckdb-sys = { version = ">=1.4.4, <2", features = ["loadable-extension"] }DuckDB compatibility:
quack-rssupports DuckDB 1.4.x and 1.5.x. Both releases expose the same C API version (v1.2.0), confirmed by E2E tests against DuckDB 1.4.4 and DuckDB 1.5.0. The upper bound<2prevents silent adoption of a future major release that may change the C API. When the C API version changes,quack-rswill need to be updated and re-released.
// src/lib.rs
use quack_rs::prelude::*;
// Step 1: Define your aggregate state
#[derive(Default)]
struct WordCountState {
count: i64,
}
impl AggregateState for WordCountState {}
// Step 2: Write callbacks using safe SDK helpers
unsafe extern "C" fn update(
_info: libduckdb_sys::duckdb_function_info,
chunk: libduckdb_sys::duckdb_data_chunk,
states: *mut libduckdb_sys::duckdb_aggregate_state,
) {
let reader = unsafe { VectorReader::new(chunk, 0) };
for row in 0..reader.row_count() {
if unsafe { reader.is_valid(row) } {
let words = unsafe { reader.read_str(row) }
.split_whitespace()
.count() as i64;
if let Some(state) =
unsafe { FfiState::<WordCountState>::with_state_mut(*states.add(row)) }
{
state.count += words;
}
}
}
}
unsafe extern "C" fn finalize(
_info: libduckdb_sys::duckdb_function_info,
states: *mut libduckdb_sys::duckdb_aggregate_state,
result: libduckdb_sys::duckdb_vector,
count: libduckdb_sys::idx_t,
offset: libduckdb_sys::idx_t,
) {
let mut writer = unsafe { VectorWriter::new(result) };
for i in 0..count as usize {
let idx = i + offset as usize;
match unsafe { FfiState::<WordCountState>::with_state(*states.add(i)) } {
Some(state) => unsafe { writer.write_i64(idx, state.count) },
None => unsafe { writer.set_null(idx) },
}
}
}
unsafe extern "C" fn combine(
_info: libduckdb_sys::duckdb_function_info,
source: *mut libduckdb_sys::duckdb_aggregate_state,
target: *mut libduckdb_sys::duckdb_aggregate_state,
count: libduckdb_sys::idx_t,
) {
for i in 0..count as usize {
if let (Some(src), Some(tgt)) = (
unsafe { FfiState::<WordCountState>::with_state(*source.add(i)) },
unsafe { FfiState::<WordCountState>::with_state_mut(*target.add(i)) },
) {
tgt.count += src.count;
}
}
}
// Step 3: Register using the builder
fn register(con: libduckdb_sys::duckdb_connection) -> ExtResult<()> {
unsafe {
AggregateFunctionBuilder::try_new("word_count")?
.param(TypeId::Varchar)
.returns(TypeId::BigInt)
.state_size(FfiState::<WordCountState>::size_callback)
.init(FfiState::<WordCountState>::init_callback)
.update(update)
.combine(combine)
.finalize(finalize)
.destructor(FfiState::<WordCountState>::destroy_callback)
.register(con)?;
}
Ok(())
}
// Step 4: One macro call generates the entry point (pass the full symbol name DuckDB expects)
entry_point!(my_extension_init_c_api, |con| register(con));use quack_rs::scaffold::{generate_scaffold, ScaffoldConfig};
let config = ScaffoldConfig {
name: "my_extension".to_string(),
description: "Fast text analytics for DuckDB".to_string(),
version: "0.1.0".to_string(),
license: "MIT".to_string(),
maintainer: "Your Name".to_string(),
github_repo: "yourorg/duckdb-my-extension".to_string(),
excluded_platforms: vec![], // or vec!["wasm_mvp".to_string(), ...]
// `target_duckdb_version`, `use_unstable_c_api` and `git_ref` default to
// the stable-ABI settings. Set `use_unstable_c_api: true` (and
// `target_duckdb_version` to an exact release) when the extension enables
// the `duckdb-1-5` features — see the `abi` module.
..Default::default()
};
let files = generate_scaffold(&config)?;
for file in &files {
println!("{}", file.path);
// write file.content to disk
}This generates all 11 files required for a DuckDB community extension submission:
Cargo.toml ← cdylib, pinned deps, release profile
Makefile ← delegates to cargo + extension-ci-tools
extension_config.cmake ← required by extension-ci-tools
src/lib.rs ← entry point template (no C++ needed)
src/wasm_lib.rs ← WebAssembly shim
description.yml ← community extension metadata
test/sql/my_extension.test ← SQLLogicTest skeleton
.github/workflows/extension-ci.yml ← cross-platform CI (Linux/macOS/Windows)
.gitmodules ← extension-ci-tools submodule
.gitignore
.cargo/config.toml ← Windows CRT static linking
DuckDB loadable extensions require a metadata footer appended to the .so/.dylib/.dll
after cargo build --release. quack-rs ships a native Rust binary for this step,
replacing the Python append_extension_metadata.py script from the C++ template:
# Install the binary from the published crate
cargo install quack-rs --bin append_metadata
# Append metadata to your built extension (input .so → output .duckdb_extension)
append_metadata target/release/libmy_extension.so \
my_extension.duckdb_extension \
--duckdb-version v1.2.0 \
--platform linux_amd64Pitfall P2: The
--duckdb-versionflag must bev1.2.0(the C API version), not the DuckDB release version (v1.4.4orv1.5.0). DuckDB 1.4.x and 1.5.x both use C API versionv1.2.0. Use theDUCKDB_API_VERSIONconstant fromquack_rsto avoid hard-coding the wrong value.
| Module | Purpose | Key types / functions |
|---|---|---|
entry_point |
Extension initialization entry point | init_extension, init_extension_v2, entry_point!, entry_point_v2! |
abi |
C extension API layout verification (stable vs unstable region) | AbiPolicy, check, STABLE_API_SLOT_COUNT |
connection |
Version-agnostic extension registration facade | Connection, Registrar |
callback |
Safe extern "C" callback wrapper macros |
scalar_callback!, table_scan_callback! |
aggregate |
Aggregate function registration | AggregateFunctionBuilder, AggregateFunctionSetBuilder, AggregateFunctionInfo |
aggregate::state |
Generic FFI state management | AggregateState, FfiState<T> |
aggregate::callbacks |
Callback type aliases | UpdateFn, CombineFn, FinalizeFn, … |
scalar |
Scalar function registration | ScalarFunctionBuilder, ScalarFunctionSetBuilder, ScalarOverloadBuilder, ScalarFunctionInfo, ScalarBindInfo¹, ScalarInitInfo¹ |
scalar::typed |
Scalar functions as safe Rust closures | ScalarFunctionBuilder::map1 / map2 / map1_str / map2_str / map1_opt / map2_opt, ScalarValue, ScalarOut |
cast |
Custom type cast functions | CastFunctionBuilder, CastFunctionInfo, CastMode |
table |
Table function registration (bind/init/scan) | TableFunctionBuilder, TypedTableFunctionBuilder, BindInfo, FfiBindData, FfiInitData |
replacement_scan |
SELECT * FROM 'file.xyz' replacement scans |
ReplacementScanBuilder |
sql_macro |
SQL macro registration (no FFI callbacks) | SqlMacro, MacroBody |
data_chunk |
Ergonomic wrapper for DuckDB data chunks | DataChunk |
chunk_writer |
Auto-sizing chunk writer for scan callbacks | ChunkWriter |
value |
RAII wrapper for DuckDB values — every scalar and composite constructor, typed extraction | Value |
vector |
Safe reading/writing of DuckDB vectors | VectorReader, VectorWriter, ValidityBitmap, vector_size() |
vector::complex |
STRUCT / LIST / MAP / ARRAY child vector access | StructVector, ListVector, MapVector, ArrayVector |
vector::ops¹ |
Whole-vector operations driven by a SelectionVector |
OwnedVector, copy_selected, slice, reference_value, reference_vector |
vector::struct_writer |
Batched typed writer for STRUCT output vectors | StructWriter |
vector::struct_reader |
Batched typed reader for STRUCT input vectors | StructReader |
vector::string |
16-byte DuckDB string format | DuckStringView, read_duck_string |
types |
DuckDB type system wrappers, incl. registering custom types | TypeId, LogicalType (register, is_composite), NullHandling |
interval |
INTERVAL ↔ microseconds conversion | DuckInterval, interval_to_micros |
datetime |
DATE/TIME/TIMESTAMP calendar conversions, HUGEINT/DECIMAL helpers | Date, Time, Timestamp, date_from_days, is_finite_date |
query |
Running SQL from an extension, incl. streaming¹ and cancellation | QueryResult, PreparedStatement, OwnedConnection, OwnedDataChunk, InterruptHandle, QueryProgress |
error |
FFI-safe error type | ExtensionError, ExtResult<T> |
tls |
Type-erased TLS config provider for HTTP-capable extensions | TlsConfigProvider |
warning |
Structured security warning API | ExtensionWarning, WarningSeverity, WarningCollector |
secrets |
Secrets manager bridge trait | SecretsManager, SecretEntry |
config |
RAII wrapper for DuckDB database configuration | DbConfig |
validate |
Community extension compliance | All validators below |
validate::description_yml |
description.yml parsing and validation | parse_description_yml, DescriptionYml |
validate::extension_name |
Extension naming rules | validate_extension_name |
validate::function_name |
SQL identifier rules | validate_function_name |
validate::semver |
Semantic versioning | validate_semver, ExtensionStability |
validate::spdx |
SPDX license identifiers | validate_spdx_license |
validate::platform |
DuckDB build targets | validate_platform, DUCKDB_PLATFORMS |
validate::release_profile |
Cargo release profile | validate_release_profile |
scaffold |
Project generator | generate_scaffold, ScaffoldConfig |
testing |
Mock vectors, aggregate harness, and registrar | AggregateTestHarness<S>, MockVectorWriter, MockVectorReader, MockRegistrar |
prelude |
Common re-exports | use quack_rs::prelude::* |
appender |
Bulk row appender — row at a time or a chunk at a time | Appender, AppendError |
table_description |
Table metadata (column names, DEFAULTs; count and types need ¹) |
TableDescription |
catalog¹ |
Catalog entry lookup | CatalogEntry, Catalog, CatalogEntryType |
client_context¹ |
Client context access (catalog, config, connection ID) | ClientContext |
config_option¹ |
Extension-defined configuration options | ConfigOptionBuilder, ConfigOptionScope |
copy_function¹ |
Custom COPY TO and COPY FROM handlers |
CopyFunctionBuilder, CopyBindInfo, CopyGlobalInitInfo, CopySinkInfo, CopyFinalizeInfo |
error_data¹ |
Structured error data + UTF-8 validation | ErrorData, DuckDbErrorType, check_valid_utf8 |
expression¹ |
Bound expression inspection / constant folding | Expression |
file_system¹ |
DuckDB virtual file system access | FileSystem, FileHandle, FileOpenOptions, FileFlag |
instance_cache¹ |
Shared database instance cache | InstanceCache |
selection_vector¹ |
Zero-copy row-index selection vectors | SelectionVector |
arrow² |
Arrow C Data Interface bridge — no arrow crate dependency |
ArrowOptions, ArrowSchema, ArrowArray, ArrowConvertedSchema, to_arrow_schema, data_chunk_to_arrow, schema_from_arrow, data_chunk_from_arrow |
¹ Requires the
duckdb-1-5feature flag (DuckDB 1.5.0+).² Requires the
duckdb-1-5-4feature flag. The functions themselves are in DuckDB's C API from 1.5.0; the flag exists becauselibduckdb-sysdid not ship the Arrow C Data Interface struct layouts until 1.10504.0.
The following table summarizes every known DuckDB Rust FFI pitfall and how quack-rs addresses
it. The full analysis — including symptoms, root cause, and minimal reproduction — is in
LESSONS.md.
| ID | Name | Symptom | quack-rs Solution |
|---|---|---|---|
| L1 | COMBINE config propagation | Aggregate returns wrong results under parallelism | Testable with AggregateTestHarness |
| L2 | Double-free in destroy | Heap corruption / SIGABRT | FfiState<T>::destroy_callback nulls pointer after free |
| L3 | Panic across FFI | Process abort / UB | init_extension propagates Result and runs the registration closure under catch_unwind; a wrapper macro does the same for every callback kind — scalar, table bind/init/scan, aggregate update/combine/finalize/destroy, cast and replacement scan — routing the panic message to that kind's set_error. Requires panic = "unwind", which the scaffold generates |
| L4 | Missing ensure_validity_writable |
Segfault / silent NULL corruption | VectorWriter::set_null calls it automatically |
| L5 | Boolean undefined behavior | Non-deterministic bool semantics | VectorReader::read_bool reads u8 != 0 |
| L6 | Function set name on each member | Silent registration failure | AggregateFunctionSetBuilder and ScalarFunctionSetBuilder set name on every member |
| L7 | LogicalType memory leak |
RSS grows with each extension load | LogicalType implements Drop |
| ID | Name | Symptom | quack-rs Solution |
|---|---|---|---|
| P1 | Library name mismatch | Extension fails to load | Documented; scaffold sets it correctly |
| P2 | C API version ≠ release version | Wrong -dv flag corrupts extension metadata |
DUCKDB_API_VERSION = "v1.2.0" constant; append_metadata binary ships with the crate; ScaffoldConfig rejects -dv/ABI-type pairings DuckDB would refuse |
| P3 | Missing E2E tests | Community submission rejected | Scaffold generates SQLLogicTest skeleton |
| P4 | Uninitialized submodule | make fails with missing files |
Documented; scaffold generates .gitmodules |
| P5 | SQLLogicTest format mismatch | Tests fail with exact-match errors | Documented with format reference |
| P6 | Registration failure not checked | Function silently not registered | Builders check and propagate return values |
| P7 | DuckDB 16-byte string format | Garbled or truncated strings | DuckStringView, read_duck_string |
| P8 | INTERVAL layout misunderstood | INTERVAL computed incorrectly | DuckInterval with interval_to_micros |
| P9 | loadable-extension dispatch table uninitialised in cargo test |
InMemoryDb::open() panics with "DuckDB API not initialized" |
InMemoryDb::open() calls CreateAPIv1() shim to populate dispatch table before opening connection — after which the whole C API works in cargo test, including registration (tests/ffi_roundtrip.rs) |
| P10 | duckdb_ext_api_v1 unstable region shifts between releases |
Heap corruption / double free on a DuckDB other than the build target — with no load-time warning |
abi::check() compares the compiled-in layout against the running engine's; AbiPolicy::Strict (default) turns a mismatch into a LOAD error. scripts/check-abi-table.py keeps the layout table honest |
quack-rs enforces every requirement from the
DuckDB community extensions development guide.
Every community extension must include a description.yml metadata file.
quack-rs can validate the entire file before submission:
use quack_rs::validate::description_yml::{
parse_description_yml, validate_rust_extension, validate_description_yml_str,
};
// Quick pass/fail check
let result = validate_description_yml_str(include_str!("description.yml"));
assert!(result.is_ok(), "description.yml has errors: {}", result.unwrap_err());
// Structured access for programmatic inspection
let desc = parse_description_yml(include_str!("description.yml"))?;
println!("Extension: {} v{}", desc.name, desc.version);
println!("Maintainers: {:?}", desc.maintainers);
// Validate Rust-specific fields (language, build, toolchains)
validate_rust_extension(&desc)?;Validated fields:
| Field | Rule |
|---|---|
extension.name |
^[a-z][a-z0-9_-]*$, max 64 chars |
extension.version |
Any of [A-Za-z0-9._+-], up to 64 chars — DuckDB specifies no format, and 11 of 43 published extensions use a date-based build id |
extension.license |
Recognized SPDX identifier |
extension.excluded_platforms |
Semicolon-separated list of known DuckDB platforms |
extension.maintainers |
At least one maintainer required |
repo.github |
Must contain / (owner/repo format) |
repo.ref |
Non-empty git ref |
Example description.yml:
extension:
name: my_extension
description: Fast text analytics for DuckDB.
version: 0.1.0
language: Rust
build: cargo
license: MIT
requires_toolchains: rust;python3
excluded_platforms: "wasm_mvp;wasm_eh;wasm_threads"
maintainers:
- Your Name
repo:
github: yourorg/duckdb-my-extension
# Must be a commit hash, not a branch: the community repository builds
# exactly this revision and signs the result, so a moving reference would
# make the build unreproducible. DuckDB's docs: "Provide the hash of the
# latest commit on the branch targeting stable as `ref`".
ref: 0a11ddc058beb2d480ccbfa83e16a68400c5d076
# ref_next: <hash> # optional: a revision compatible with DuckDB main,
# # used while a new DuckDB release is being prepared.
docs:
hello_world: |
SELECT my_extension_version();
extended_description: |
A longer description, rendered on the community-extensions site.Extensions submitted to the community repository must follow strict naming rules. Validate before you submit:
use quack_rs::validate::{validate_extension_name, validate_function_name};
// Extension names: lowercase alphanumeric, hyphens and underscores allowed
assert!(validate_extension_name("my_analytics").is_ok());
assert!(validate_extension_name("MyExt").is_err()); // uppercase rejected
assert!(validate_extension_name("my ext").is_err()); // spaces rejected
assert!(validate_extension_name("").is_err()); // empty rejected
// Function/SQL identifier names: letters, digits and underscores (mixed case is
// fine — DuckDB itself ships `formatReadableSize`)
assert!(validate_function_name("word_count").is_ok());
assert!(validate_function_name("word-count").is_err()); // hyphens not allowed in SQL
assert!(validate_function_name("WordCount").is_err()); // uppercase rejectedDuckDB community extensions must build for every standard platform or declare
explicit exclusions. The list below mirrors config/distribution_matrix.json in
duckdb/extension-ci-tools,
and a CI job (scripts/check-platform-table.py) fails when it drifts:
linux_amd64 linux_arm64
linux_amd64_musl† linux_arm64_musl†
osx_amd64 osx_arm64
windows_amd64 windows_amd64_mingw windows_arm64†
wasm_mvp wasm_eh wasm_threads
† opt-in: not built unless an extension asks for it, so excluding it is a no-op.
is_opt_in_platformreports which these are.
linux_amd64_gcc4is gone — DuckDB retired the legacy CXX ABI target.
use quack_rs::validate::{validate_platform, validate_excluded_platforms_str, DUCKDB_PLATFORMS};
// Validate a single platform
assert!(validate_platform("linux_amd64").is_ok());
assert!(validate_platform("freebsd_amd64").is_err()); // not a DuckDB platform
// Validate the excluded_platforms field from description.yml
// (semicolon-separated, as it appears in the YAML file)
assert!(validate_excluded_platforms_str("wasm_mvp;wasm_eh;wasm_threads").is_ok());
assert!(validate_excluded_platforms_str("invalid_platform").is_err());
assert!(validate_excluded_platforms_str("").is_ok()); // empty = no exclusions
println!("All DuckDB platforms: {:?}", DUCKDB_PLATFORMS);DuckDB extensions use a three-tier versioning scheme:
| Tier | Format | Example | Meaning |
|---|---|---|---|
| Unstable | Short git hash | 690bfc5 |
No stability guarantees |
| Pre-release | Semver 0.y.z |
0.1.0 |
Working toward stability |
| Stable | Semver x.y.z (x ≥ 1) |
1.0.0 |
Full semver semantics |
use quack_rs::validate::{validate_extension_version, validate_semver};
use quack_rs::validate::semver::{classify_extension_version, ExtensionStability};
// validate_extension_version accepts both semver and git hashes
assert!(validate_extension_version("1.0.0").is_ok());
assert!(validate_extension_version("0.1.0").is_ok());
assert!(validate_extension_version("690bfc5").is_ok()); // git hash (unstable)
assert!(validate_extension_version("").is_err());
// Classify the stability tier
let (stability, _) = classify_extension_version("1.0.0").unwrap();
assert_eq!(stability, ExtensionStability::Stable);
let (stability, _) = classify_extension_version("0.1.0").unwrap();
assert_eq!(stability, ExtensionStability::PreRelease);
let (stability, _) = classify_extension_version("690bfc5").unwrap();
assert_eq!(stability, ExtensionStability::Unstable);DuckDB loadable extensions are shared libraries. The Cargo release profile must be configured correctly:
[profile.release]
panic = "unwind" # REQUIRED: see below
lto = true # Recommended: reduces binary size
codegen-units = 1 # Recommended: better optimization quality
opt-level = 3 # Recommended: maximum performance
strip = true # Recommended: smaller binaryunwind, not abort. quack-rs wraps every extern "C" entry point in
catch_unwind, turning a panic in your code into a DuckDB error. catch_unwind
cannot catch anything under panic = "abort" — the runtime aborts before
unwinding starts, so a panic kills the user's whole DuckDB session:
$ rustc -O panic_probe.rs && ./panic_probe # caught, exit=0
$ rustc -O -C panic=abort ... && ./panic_probe # Aborted, exit=134
The older abort advice came from panics escaping an extern "C" boundary once
being undefined behavior. They no longer are — Rust defines that as an abort —
and quack-rs catches them before the boundary anyway.
use quack_rs::validate::validate_release_profile;
// Validate from parsed Cargo.toml values
let check = validate_release_profile("unwind", "true", "3", "1").unwrap();
assert!(check.is_fully_optimized());
assert!(check.panic_unwind); // REQUIRED
assert!(check.lto_enabled); // recommended
assert!(check.opt_level_3); // recommended
assert!(check.codegen_units_1); // recommended
// panic = "abort" is rejected: it makes quack-rs's panic handling inert
let err = validate_release_profile("abort", "true", "3", "1").unwrap_err();
assert!(err.as_str().contains("catch_unwind"));flowchart TB
Author(["**Extension Author**<br/>use quack_rs::prelude::*"]):::author
subgraph REG ["Registration layer"]
direction LR
EP["**entry_point**<br/>entry_point! · init_extension"]
AGG["**aggregate**<br/>AggregateFunctionBuilder<br/>AggregateFunctionSetBuilder · FfiState<T>"]
SCL["**scalar**<br/>ScalarFunctionBuilder<br/>ScalarFunctionSetBuilder"]
TBL["**table**<br/>TableFunctionBuilder · TypedTableFunctionBuilder<br/>BindInfo · FfiBindData · FfiInitData"]
RSC["**replacement_scan**<br/>ReplacementScanBuilder"]
SM["**sql_macro**<br/>SqlMacro · MacroBody"]
CST["**cast**<br/>CastFunctionBuilder"]
CPY["**copy_function**¹<br/>CopyFunctionBuilder"]
end
subgraph DATA ["Data layer"]
direction LR
VEC["**vector**<br/>VectorReader · VectorWriter<br/>ValidityBitmap · DuckStringView"]
CMP["**vector::complex**<br/>StructVector · ListVector · MapVector · ArrayVector"]
TYP["**types**<br/>TypeId · LogicalType"]
INT["**interval**<br/>DuckInterval · interval_to_micros"]
CFG["**config_option**¹<br/>ConfigOptionBuilder"]
CAT["**catalog**¹<br/>CatalogEntry · Catalog"]
CTX["**client_context**¹<br/>ClientContext"]
TDS["**table_description**<br/>TableDescription"]
APP["**appender**<br/>Appender"]
end
SYS["**libduckdb-sys** >=1.4.4, <2<br/>DuckDB C Extension API<br/>headers only · no linked library"]:::ffi
RT[("**DuckDB**<br/>Runtime")]:::duckdb
subgraph EXT ["Extension infrastructure"]
direction LR
TLS["**tls**<br/>TlsConfigProvider"]
WRN["**warning**<br/>ExtensionWarning · WarningCollector"]
SEC["**secrets**<br/>SecretsManager · SecretEntry"]
end
subgraph DEV ["Dev-time utilities"]
direction LR
ERR["**error**<br/>ExtensionError · ExtResult<T>"]
TST["**testing**<br/>AggregateTestHarness<S><br/>pure Rust · no FFI"]
VAL["**validate**<br/>extension_name · semver<br/>spdx · platform<br/>description_yml"]
SCF["**scaffold**<br/>generate_scaffold<br/>ScaffoldConfig"]
end
Author --> REG
REG --> DATA
DATA --> SYS
SYS --> RT
Author -.->|"dev-time"| DEV
classDef author fill:#1e3a5f,stroke:#5a9fd4,color:#ddf0ff
classDef ffi fill:#3d2406,stroke:#c87941,color:#f5dbb4
classDef duckdb fill:#1c3b1c,stroke:#4a9e4a,color:#c8ecc8
-
Thin wrapper mandate: Every abstraction must pay for itself in reduced boilerplate or improved safety. When in doubt, prefer simplicity over cleverness.
-
No panics across FFI:
unwrap()andpanic!()are forbidden in any code path that crosses the FFI boundary. All errors propagate asResult<T, ExtensionError>. -
Bounded version range:
libduckdb-sys = ">=1.4.4, <2"is deliberate. The C API is stable across DuckDB 1.4.x and 1.5.x (verified by E2E tests on both). The upper bound prevents silent adoption of a future major release. When the C API version changes,quack-rswill be updated. -
Testable business logic: Aggregate state structs have zero FFI dependencies. They can be tested in isolation using
AggregateTestHarness<S>without a DuckDB runtime. -
Enforce community standards: The
validatemodule makes it impossible to accidentally submit a non-compliant extension. Validation errors are caught at build time, not submission time.
All unsafe code within quack-rs is sound and documented. Extension authors must write
unsafe extern "C" callback functions (required by DuckDB's C API), but the SDK's helpers
(FfiState, VectorReader, VectorWriter) minimize the surface area of unsafe code
within those callbacks.
The documentation convention is:
- Every
unsafe fnstates what the caller must guarantee under# Safety. - Every
unsafeblock inside a safe function carries a// SAFETY:comment — there the crate, not the caller, is asserting the invariant. - Inside an
unsafe fn, blocks that simply forward the function's own documented contract are not re-annotated.unsafe_op_in_unsafe_fnis denied crate-wide, so those blocks are required syntax rather than new assertions.
// Extension author code: no unsafe required
fn register(con: duckdb_connection) -> ExtResult<()> {
AggregateFunctionBuilder::try_new("word_count")?
.param(TypeId::Varchar)
.returns(TypeId::BigInt)
// ... callbacks (which are unsafe extern "C" fns) ...
.register(con) // unsafe is inside the SDK
}ADR-1: Thin Wrapper Mandate
quack-rs wraps, but does not redesign, the DuckDB C API. We do not invent new abstractions
that would require understanding two APIs. Extension authors who want to go below the SDK can
use libduckdb-sys directly — the two libraries compose without conflict.
ADR-2: Bounded Version Range
libduckdb-sys = ">=1.4.4, <2" is intentional. The DuckDB C Extension API is stable
across 1.4.x and 1.5.x — verified by E2E tests against DuckDB 1.4.4 and DuckDB 1.5.0.
Both releases use C API version v1.2.0. The upper bound <2 prevents silent adoption
of a future major-band release that may change the C API version or callback signatures.
When a future DuckDB release bumps the C API version, quack-rs will need to be updated
to match.
ADR-3: No Panics Across FFI
The Rust reference is explicit: unwinding across an FFI boundary is undefined behavior.
quack-rs enforces this architecturally: every FFI boundary in the SDK is wrapped by
init_extension, which converts Result::Err into a DuckDB error report via set_error.
No unwrap(), expect(), or panic!() appears in any code path reachable from a DuckDB
callback.
quack-rs uses four layers of tests:
Each module contains #[cfg(test)] unit tests that verify pure-Rust behavior without
a DuckDB runtime. These test state machine correctness, builder field storage, validation
logic, and the description.yml parser.
The interval module and AggregateTestHarness include proptest-based tests:
interval_to_microsoverflow is detected for all possible field combinationsAggregateTestHarness::combineassociativity holds for sum aggregatescombineidentity element property: combining with empty state is idempotent
Pure-Rust cross-module tests that exercise the public API without a live DuckDB process.
These cover DuckInterval, TypeId, FfiState<T> lifecycle, AggregateTestHarness,
ExtensionError, VectorReader/VectorWriter layout, DuckStringView, and SqlMacro.
A comprehensive extension that exercises every feature in quack-rs: scalar, aggregate,
table, cast, replacement scan, and SQL macro functions — plus complex types (STRUCT, LIST, MAP),
entry_point_v2!/Connection/Registrar, aggregate sets, scalar sets with per-overload NULL
handling, DuckInterval, ValidityBitmap, named_param, local_init, implicit_cost,
extra_info, and all VectorReader/VectorWriter type variants. All 39 live SQL tests pass
against both DuckDB 1.4.4 and 1.5.0.
The AggregateTestHarness<S> type lets you test aggregate state logic in isolation:
use quack_rs::testing::AggregateTestHarness;
use quack_rs::aggregate::AggregateState;
#[derive(Default)]
struct SumState { total: i64 }
impl AggregateState for SumState {}
// Test update logic
let result = AggregateTestHarness::<SumState>::aggregate(
[10_i64, 20, 30],
|s, v| s.total += v,
);
assert_eq!(result.total, 60);
// Test combine: verify config fields are propagated (Pitfall L1)
let mut source = AggregateTestHarness::<SumState>::new();
source.update(|s| s.total = 100);
let mut target = AggregateTestHarness::<SumState>::new();
target.combine(&source, |src, tgt| tgt.total += src.total);
assert_eq!(target.finalize().total, 100);DuckDB's window functions (OVER (...) clauses) are implemented entirely in the C++
API layer and have no counterpart in DuckDB's public C extension API. This is not a
gap in quack-rs or in libduckdb-sys — the symbol duckdb_create_window_function
does not exist in the C API.
COPY format handlers were previously in this category, but DuckDB 1.5.0 added
duckdb_create_copy_functionand related symbols. quack-rs wraps them in thecopy_functionmodule (requires theduckdb-1-5feature flag).
If DuckDB exposes the window function API in a future C API version, quack-rs will
add wrappers in the relevant release.
DuckDB v1.5.1 introduced the VARIANT type (Iceberg v3 support); it landed in
the C type enum as DUCKDB_TYPE_VARIANT (41) in DuckDB 1.5.3. The
GEOMETRY type (DUCKDB_TYPE_GEOMETRY, 40) is also present in the C API.
quack-rs exposes these as TypeId::Variant and TypeId::Geometry behind the
duckdb-1-5-3 feature flag. That feature layers on top of duckdb-1-5 and
requires libduckdb-sys >= 1.10503.1 (DuckDB 1.5.3). It is a separate gate
because these type-enum constants postdate the duckdb-1-5 feature's 1.5.0
floor (VARIANT was added in 1.5.3), so keeping them out of duckdb-1-5
preserves compatibility for consumers pinned to libduckdb-sys 1.5.0–1.5.2.
DuckDB 1.5.0 added a conversion family that moves data straight between a
duckdb_data_chunk and the Arrow C Data Interface — duckdb_to_arrow_schema,
duckdb_data_chunk_to_arrow, duckdb_schema_from_arrow,
duckdb_data_chunk_from_arrow and the duckdb_arrow_options accessors.
quack-rs wraps all of them in the arrow module behind the
duckdb-1-5-4 feature, with RAII types for every handle and by-value
ownership where DuckDB claims the input.
There is no arrow crate dependency. The interface is an ABI, not a
library: libduckdb-sys defines the two #[repr(C)] records with arrow-rs's
layout, so an extension that uses arrow-rs bridges across with a pointer cast,
and one that does not pays nothing.
The feature is separate from duckdb-1-5 only because libduckdb-sys declared
those records as opaque zero-sized placeholders until 1.10504.0; the DuckDB
functions themselves are present from 1.5.0.
For the full list of resolved and open limitations, see the Known Limitations reference page.
See CHANGELOG.md for the full version history.
v0.13.0 (2026-05-24) — DuckDB 1.5.3 bump (libduckdb-sys/duckdb
1.10503.1), MSRV corrected to 1.86.0, and six new duckdb-1-5-gated modules
(error_data, expression, file_system, appender, selection_vector,
instance_cache) plus Value/Catalog additions. Adds a new duckdb-1-5-3
feature exposing TypeId::Variant and TypeId::Geometry (the DuckDB 1.5.3
type-enum values, gated separately to preserve libduckdb-sys 1.5.0–1.5.2
compatibility). ErrorData now implements Display + std::error::Error;
DuckDbErrorType implements Display. Fixes a latent panic-across-FFI in
TypeId::from_duckdb_type (it now maps the duckdb-1-5 type values instead of
panicking, e.g. via LogicalType::get_type_id()).
v0.12.1 (2026-05-01) — Security/maintenance patch. Closes nine
GitHub Dependabot alerts (two High, seven Low) across both lockfiles by
bumping rustls-webpki 0.103.10 → 0.103.13 (clears
RUSTSEC-2026-0098,
RUSTSEC-2026-0103,
RUSTSEC-2026-0104)
and rand 0.8.5 → 0.8.6 / 0.9.2 → 0.9.4 (clears
RUSTSEC-2026-0097).
None of those paths are exercised by quack-rs itself but downstream
cargo deny runs would flag them. Also bumps duckdb / libduckdb-sys
1.10501.0 → 1.10502.0 and cc 1.2.59 → 1.2.61. GitHub Actions pins
refreshed (actions/cache, actions/upload-artifact,
actions/upload-pages-artifact). Fixes two clippy::map_unwrap_or /
map_unwrap_or_default sites in src/warning.rs (graduated to stable
clippy in Rust 1.95.0). Adds an informational Clippy (beta) CI job so
future lint promotions surface ~6 weeks before they reach stable.
v0.12.0 (2026-04-09) — Added TypedTableFunctionBuilder<S>, a closure-based
layer on top of TableFunctionBuilder that replaces hand-rolled
unsafe extern "C" fn bind/init/scan trampolines with two safe Rust closures
(TableFunctionBuilder::with_state::<S, _>(...).scan(...).build()?); panics in
user closures are caught via catch_unwind; state is carried from bind
through init into init_data so the scan closure receives &mut S; scans
run serialised (set_max_threads(1)) since S: Send is not Sync.
Added tls module (TlsConfigProvider trait for type-erased TLS client
configuration injection with CWE-coded audit), warning module
(ExtensionWarning, WarningSeverity, WarningCollector for structured
security warnings), secrets module (SecretsManager trait, SecretEntry
with redacted Debug, volatile zeroize on Drop). Added From<io::Error>,
From<NulError>, From<fmt::Error> on ExtensionError. Added
StructWriter::child_list_vector() convenience alias.
v0.11.0 (2026-03-30) — Added StructWriter::child_vector(), StructReader::child_vector()
for nested complex types inside STRUCT fields. Added ChunkWriter::vector(),
ChunkWriter::column_count(), VectorWriter::set_valid(), StructWriter::set_valid().
Added ReplacementScanInfo::add_parameter_raw(), add_i64_parameter(), add_bool_parameter().
table_scan_callback! now reports panic messages to DuckDB via duckdb_function_set_error.
v0.10.0 (2026-03-29) — Added StructWriter, StructReader, ChunkWriter for batched
typed vector I/O. Added scalar_callback! / table_scan_callback! panic-safe callback
wrapper macros. Added Value integer extraction methods. Added temporal/binary vector
methods (date, timestamp, time, blob, uuid). Added DataChunk bridge methods.
v0.9.0 (2026-03-29) — Added Value RAII wrapper, DataChunk wrapper, MapVector
reader/writer helpers, VectorWriter::write_str(), BindInfo::get_parameter_value().
v0.8.0 (2026-03-28) — Added LogicalType introspection (20 methods), complex type
constructors (decimal, array, union, enum), TypeId::from_duckdb_type(), callback info
wrappers (ScalarFunctionInfo, AggregateFunctionInfo, etc.), ArrayVector helper.
v0.7.0 (2026-03-22) — Upgraded to DuckDB 1.5.0. Populated duckdb-1-5 feature with
five new modules: catalog, client_context, config_option, copy_function,
table_description. Added TypeId::TimeNs, scalar function varargs(), volatile(),
bind(), init(). COPY format handlers now supported.
v0.6.0 (2026-03-12) — Fixed InMemoryDb::open() dispatch table initialization
(bundled-test feature). Added bundled_api_init.cpp shim and build.rs compilation.
v0.5.0 (2026-03-10) — Added param_logical(LogicalType) and returns_logical(LogicalType)
on all builders for complex parameterized types. Added per-overload null_handling().
v0.4.0 (2026-03-09) — Added Connection / Registrar trait, init_extension_v2,
entry_point_v2!, duckdb-1-5 feature flag. Broadened libduckdb-sys to >=1.4.4, <2.
v0.3.0 (2026-03-08) — Added TableFunctionBuilder, ReplacementScanBuilder,
CastFunctionBuilder, complex vector types, DbConfig, append_metadata binary.
v0.2.0 (2026-03-07) — Added validate::description_yml, prelude, scaffold improvements,
ScalarFunctionBuilder, entry_point! macro.
v0.1.0 (2025-05-01) — Initial release with all core modules.
See CONTRIBUTING.md for the development guide, quality gates,
and how to run the full test suite.
Quality gates (all required before merge):
cargo test --all-targets # all tests pass
cargo clippy --all-targets -- -D warnings # no clippy warnings
cargo fmt -- --check # code is formatted
cargo doc --no-deps # docs compile without warnings
cargo check # MSRV check (Rust 1.86.0)MIT — see LICENSE.
quack-rs is a community project. It is not affiliated with or endorsed by DuckDB Labs.
Built with care for the open-source and Rust communities.