README.md

May 24, 2026 · View on GitHub

quack-rs — The Rust SDK for DuckDB extensions

/ˈkwækərz/  ·  rhymes with crackers  ·  inspired by DuckDB

CI Crates.io Documentation License: MIT MSRV: 1.87.0

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.


Table of Contents


Why quack-rs?

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++.

What extension authors face without quack-rs

ProblemWithout quack-rsWith quack-rs
Entry point boilerplate~40 lines of unsafe extern "C" code1 macro call
State init/destroyRaw Box::into_raw / Box::from_rawFfiState<T> handles all of it
Boolean readsUB if read as bool directlyVectorReader::read_bool uses u8 != 0
NULL outputSilent corruption if ensure_validity_writable skippedVectorWriter::set_null calls it automatically
LogicalType memoryLeak if not freedLogicalType implements Drop
Aggregate combineConfig fields lost on segment-tree mergesTestable with AggregateTestHarness
FFI panicsProcess abort or undefined behaviorinit_extension never panics; scalar_callback! / table_scan_callback! catch panics
Table functions~100 lines of raw bind/init/scan callbacksTableFunctionBuilder 5-method chain, or TypedTableFunctionBuilder<S> with two safe Rust closures
Replacement scansUndocumented vtable + manual string allocationReplacementScanBuilder 4-method chain
Complex types (STRUCT/LIST/MAP/ARRAY)Manual offset arithmetic over child vectorsStructVector, ListVector, MapVector, ArrayVector helpers
Complex param/return typesRaw duckdb_create_logical_type + manual lifecycleparam_logical(LogicalType) / returns_logical(LogicalType) on all builders
Init error propagationForced to panic!() when runtime allocation failsExtensionError has From<io::Error> — use ? directly
TLS configurationNo standard way to inject custom TLS configsTlsConfigProvider trait (type-erased, zero deps)
Security warningsEvery extension re-invents warning infrastructureExtensionWarning + WarningCollector with CWE codes
Secrets managementCustom in-memory secrets + DuckDB bridge per extensionSecretsManager trait + SecretEntry builder
Extension namingRejected by DuckDB CI with no explanationvalidate_extension_name catches issues before submission
description.ymlNo tooling to validate before submissionvalidate_description_yml_str validates the whole file
New project setupHours of boilerplate + reading DuckDB internalsgenerate_scaffold produces all 11 required files

What quack-rs Solves

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.


Quick Start

1. Add the dependency

[dependencies]
quack-rs = "0.13"
libduckdb-sys = { version = ">=1.4.4, <2", features = ["loadable-extension"] }

DuckDB compatibility: quack-rs supports 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 <2 prevents silent adoption of a future major release that may change the C API. When the C API version changes, quack-rs will need to be updated and re-released.

2. Write your extension

// 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));

3. Scaffold a new project

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

4. Append extension metadata

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_amd64

Pitfall P2: The --duckdb-version flag must be v1.2.0 (the C API version), not the DuckDB release version (v1.4.4 or v1.5.0). DuckDB 1.4.x and 1.5.x both use C API version v1.2.0. Use the DUCKDB_API_VERSION constant from quack_rs to avoid hard-coding the wrong value.


Module Reference

ModulePurposeKey types / functions
entry_pointExtension initialization entry pointinit_extension, init_extension_v2, entry_point!, entry_point_v2!
connectionVersion-agnostic extension registration facadeConnection, Registrar
callbackSafe extern "C" callback wrapper macrosscalar_callback!, table_scan_callback!
aggregateAggregate function registrationAggregateFunctionBuilder, AggregateFunctionSetBuilder, AggregateFunctionInfo
aggregate::stateGeneric FFI state managementAggregateState, FfiState<T>
aggregate::callbacksCallback type aliasesUpdateFn, CombineFn, FinalizeFn, …
scalarScalar function registrationScalarFunctionBuilder, ScalarFunctionSetBuilder, ScalarOverloadBuilder, ScalarFunctionInfo, ScalarBindInfo¹, ScalarInitInfo¹
castCustom type cast functionsCastFunctionBuilder, CastFunctionInfo, CastMode
tableTable function registration (bind/init/scan)TableFunctionBuilder, TypedTableFunctionBuilder, BindInfo, FfiBindData, FfiInitData
replacement_scanSELECT * FROM 'file.xyz' replacement scansReplacementScanBuilder
sql_macroSQL macro registration (no FFI callbacks)SqlMacro, MacroBody
data_chunkErgonomic wrapper for DuckDB data chunksDataChunk
chunk_writerAuto-sizing chunk writer for scan callbacksChunkWriter
valueRAII wrapper for DuckDB values with typed extractionValue
vectorSafe reading/writing of DuckDB vectorsVectorReader, VectorWriter, ValidityBitmap, vector_size()
vector::complexSTRUCT / LIST / MAP / ARRAY child vector accessStructVector, ListVector, MapVector, ArrayVector
vector::struct_writerBatched typed writer for STRUCT output vectorsStructWriter
vector::struct_readerBatched typed reader for STRUCT input vectorsStructReader
vector::string16-byte DuckDB string formatDuckStringView, read_duck_string
typesDuckDB type system wrappersTypeId, LogicalType, NullHandling
intervalINTERVAL ↔ microseconds conversionDuckInterval, interval_to_micros
errorFFI-safe error typeExtensionError, ExtResult<T>
tlsType-erased TLS config provider for HTTP-capable extensionsTlsConfigProvider
warningStructured security warning APIExtensionWarning, WarningSeverity, WarningCollector
secretsSecrets manager bridge traitSecretsManager, SecretEntry
configRAII wrapper for DuckDB database configurationDbConfig
validateCommunity extension complianceAll validators below
validate::description_ymldescription.yml parsing and validationparse_description_yml, DescriptionYml
validate::extension_nameExtension naming rulesvalidate_extension_name
validate::function_nameSQL identifier rulesvalidate_function_name
validate::semverSemantic versioningvalidate_semver, ExtensionStability
validate::spdxSPDX license identifiersvalidate_spdx_license
validate::platformDuckDB build targetsvalidate_platform, DUCKDB_PLATFORMS
validate::release_profileCargo release profilevalidate_release_profile
scaffoldProject generatorgenerate_scaffold, ScaffoldConfig
testingMock vectors, aggregate harness, and registrarAggregateTestHarness<S>, MockVectorWriter, MockVectorReader, MockRegistrar
preludeCommon re-exportsuse quack_rs::prelude::*
appender¹Bulk row appenderAppender
catalog¹Catalog entry lookupCatalogEntry, Catalog, CatalogEntryType
client_context¹Client context access (catalog, config, connection ID)ClientContext
config_option¹Extension-defined configuration optionsConfigOptionBuilder, ConfigOptionScope
copy_function¹Custom COPY TO handlersCopyFunctionBuilder, CopyBindInfo, CopyGlobalInitInfo, CopySinkInfo, CopyFinalizeInfo
error_data¹Structured error data + UTF-8 validationErrorData, DuckDbErrorType, check_valid_utf8
expression¹Bound expression inspection / constant foldingExpression
file_system¹DuckDB virtual file system accessFileSystem, FileHandle, FileOpenOptions, FileFlag
instance_cache¹Shared database instance cacheInstanceCache
selection_vector¹Zero-copy row-index selection vectorsSelectionVector
table_description¹Table metadata (column count, names, types)TableDescription

¹ Requires the duckdb-1-5 feature flag (DuckDB 1.5.0+).


FFI Pitfalls Reference

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.

Logic Pitfalls (L)

IDNameSymptomquack-rs Solution
L1COMBINE config propagationAggregate returns wrong results under parallelismTestable with AggregateTestHarness
L2Double-free in destroyHeap corruption / SIGABRTFfiState<T>::destroy_callback nulls pointer after free
L3Panic across FFIProcess abort / UBinit_extension propagates Result; scalar_callback! / table_scan_callback! catch panics with catch_unwind
L4Missing ensure_validity_writableSegfault / silent NULL corruptionVectorWriter::set_null calls it automatically
L5Boolean undefined behaviorNon-deterministic bool semanticsVectorReader::read_bool reads u8 != 0
L6Function set name on each memberSilent registration failureAggregateFunctionSetBuilder and ScalarFunctionSetBuilder set name on every member
L7LogicalType memory leakRSS grows with each extension loadLogicalType implements Drop

Practical Pitfalls (P)

IDNameSymptomquack-rs Solution
P1Library name mismatchExtension fails to loadDocumented; scaffold sets it correctly
P2C API version ≠ release versionWrong -dv flag corrupts extension metadataDUCKDB_API_VERSION = "v1.2.0" constant; append_metadata binary ships with the crate
P3Missing E2E testsCommunity submission rejectedScaffold generates SQLLogicTest skeleton
P4Uninitialized submodulemake fails with missing filesDocumented; scaffold generates .gitmodules
P5SQLLogicTest format mismatchTests fail with exact-match errorsDocumented with format reference
P6Registration failure not checkedFunction silently not registeredBuilders check and propagate return values
P7DuckDB 16-byte string formatGarbled or truncated stringsDuckStringView, read_duck_string
P8INTERVAL layout misunderstoodINTERVAL computed incorrectlyDuckInterval with interval_to_micros
P9loadable-extension dispatch table uninitialised in cargo testInMemoryDb::open() panics with "DuckDB API not initialized"InMemoryDb::open() calls CreateAPIv1() shim to populate dispatch table before opening connection

Community Extension Compliance

quack-rs enforces every requirement from the DuckDB community extensions development guide.

description.yml validation

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:

FieldRule
extension.name^[a-z][a-z0-9_-]*$, max 64 chars
extension.versionSemver or 7–40 char lowercase hex git hash
extension.licenseRecognized SPDX identifier
extension.excluded_platformsSemicolon-separated list of known DuckDB platforms
extension.maintainersAt least one maintainer required
repo.githubMust contain / (owner/repo format)
repo.refNon-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: main

Extension naming

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: 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 rejected

Platform targets

DuckDB 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);

Extension versioning

DuckDB extensions use a three-tier versioning scheme:

TierFormatExampleMeaning
UnstableShort git hash690bfc5No stability guarantees
Pre-releaseSemver 0.y.z0.1.0Working toward stability
StableSemver x.y.z (x ≥ 1)1.0.0Full 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);

Release profile requirements

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 binary
use 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"));

Architecture

Module dependency graph

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&lt;T&gt;"]
        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, &lt;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&lt;T&gt;"]
        TST["**testing**<br/>AggregateTestHarness&lt;S&gt;<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

Design principles

  1. Thin wrapper mandate: Every abstraction must pay for itself in reduced boilerplate or improved safety. When in doubt, prefer simplicity over cleverness.

  2. No panics across FFI: unwrap() and panic!() are forbidden in any code path that crosses the FFI boundary. All errors propagate as Result<T, ExtensionError>.

  3. 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-rs will be updated.

  4. Testable business logic: Aggregate state structs have zero FFI dependencies. They can be tested in isolation using AggregateTestHarness<S> without a DuckDB runtime.

  5. Enforce community standards: The validate module makes it impossible to accidentally submit a non-compliant extension. Validation errors are caught at build time, not submission time.

Safety model

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
}

Architecture Decision Records

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.


Testing strategy

quack-rs uses four layers of tests:

1. Unit tests (in every source file)

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.

2. Property-based tests (proptest)

The interval module and AggregateTestHarness include proptest-based tests:

  • interval_to_micros overflow is detected for all possible field combinations
  • AggregateTestHarness::combine associativity holds for sum aggregates
  • combine identity element property: combining with empty state is idempotent

3. Integration tests (tests/integration_test.rs)

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.

4. Example extension (examples/hello-ext)

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.

Testing aggregate logic without DuckDB

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);

Known Limitations

Window functions are not available

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_function and related symbols. quack-rs wraps them in the copy_function module (requires the duckdb-1-5 feature flag).

If DuckDB exposes the window function API in a future C API version, quack-rs will add wrappers in the relevant release.

VARIANT and GEOMETRY types

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.

For the full list of resolved and open limitations, see the Known Limitations reference page.


Changelog

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.87.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.


Contributing

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.87.0)

License

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.