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 16 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
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.11"
libduckdb-sys = { version = ">=1.4.4, <2", features = ["loadable-extension"] }The latest crate published to crates.io is v0.11.0. Version v0.12.1 is prepared in this repository but has not yet been published. Until the v0.12.1 release workflow runs,
cargo add quack-rsresolves to0.11.0.
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(), ...]
};
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! |
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¹ |
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 with 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::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 | TypeId, LogicalType, NullHandling |
interval |
INTERVAL ↔ microseconds conversion | DuckInterval, interval_to_micros |
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::* |
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 handlers |
CopyFunctionBuilder, CopyBindInfo, CopyGlobalInitInfo, CopySinkInfo, CopyFinalizeInfo |
table_description¹ |
Table metadata (column count, names, types) | TableDescription |
¹ Requires the
duckdb-1-5feature flag (DuckDB 1.5.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; scalar_callback! / table_scan_callback! catch panics with catch_unwind |
| 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 |
| 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 |
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 |
Semver or 7–40 char lowercase hex git hash |
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
ref: mainExtensions 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: lowercase alphanumeric and underscores only
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 all 11 standard platforms or declare explicit exclusions:
linux_amd64 linux_amd64_gcc4 linux_arm64
osx_amd64 osx_arm64
windows_amd64 windows_amd64_mingw windows_arm64
wasm_mvp wasm_eh wasm_threads
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 = "abort" # REQUIRED: panics across FFI are undefined behavior
lto = true # Recommended: reduces binary size
codegen-units = 1 # Recommended: better optimization quality
opt-level = 3 # Recommended: maximum performance
strip = true # Recommended: smaller binaryuse quack_rs::validate::validate_release_profile;
// Validate from parsed Cargo.toml values
let check = validate_release_profile("abort", "true", "3", "1").unwrap();
assert!(check.is_fully_optimized());
assert!(check.panic_abort); // REQUIRED
assert!(check.lto_enabled); // recommended
assert!(check.opt_level_3); // recommended
assert!(check.codegen_units_1); // recommended
// Missing panic=abort is rejected with a descriptive error
let err = validate_release_profile("unwind", "true", "3", "1").unwrap_err();
assert!(err.as_str().contains("panic"));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"]
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. Every unsafe block in this crate has a // SAFETY: comment
explaining the invariants being upheld.
// 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 for Iceberg v3 support. This type is
not yet exposed in the DuckDB C Extension API (DUCKDB_TYPE_VARIANT does not
exist in libduckdb-sys 1.10501.0). quack-rs will add TypeId::Variant when the
C API exposes it.
For the full list of resolved and open limitations, see the Known Limitations reference page.
See CHANGELOG.md for the full version history.
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.84.1)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.