FAQ

Frequently asked questions about quack-rs and building DuckDB extensions in Rust.


General

What is quack-rs?

quack-rs is a Rust SDK for building DuckDB loadable extensions using DuckDB's pure C Extension API. It provides safe, ergonomic builders for registering scalar functions, aggregate functions, table functions, cast functions, replacement scans, SQL macros, and copy functions (via the duckdb-1-5 feature), along with helpers for reading and writing DuckDB vectors, and utilities for publishing community extensions.

Why does this exist?

Building a DuckDB extension in Rust requires solving a set of undocumented FFI problems that every developer discovers independently. quack-rs encodes solutions to all 16 known pitfalls so you don't have to rediscover them. See the Pitfall Catalog.

What DuckDB version does quack-rs target?

quack-rs requires libduckdb-sys = ">=1.4.4, <2" (DuckDB 1.4.x and 1.5.x). The C API version string passed to the dispatch-table initializer is "v1.2.0", available as quack_rs::DUCKDB_API_VERSION. Both DuckDB 1.4.x and 1.5.x use the same C API version. These are two distinct version identifiers — the crate version and the C API protocol version.

What is the minimum supported Rust version (MSRV)?

Rust 1.84.1 or later. This is enforced in Cargo.toml with rust-version = "1.84.1".

Is quack-rs production-ready?

Yes. It was extracted from duckdb-behavioral, a production DuckDB community extension. All 16 pitfalls it solves were discovered in production.


Functions

Can I expose SQL macros as an extension?

Yes, without any C++ wrapper code. Use quack_rs::sql_macro::SqlMacro:

#![allow(unused)]
fn main() {
use quack_rs::sql_macro::SqlMacro;

// Scalar macro
let m = SqlMacro::scalar("double_it", &["x"], "x * 2")?;
unsafe { m.register(con) }?;

// Table macro
let m = SqlMacro::table("recent_events", &["n"],
    "SELECT * FROM events ORDER BY ts DESC LIMIT n")?;
unsafe { m.register(con) }?;
}

Register them inside your init_extension closure alongside aggregate and scalar functions. See SQL Macros.

Can I register multiple overloads of the same function?

Yes, using AggregateFunctionSetBuilder (for aggregates) or ScalarFunctionSetBuilder (for scalars). Both support complex parameter types via param_logical(LogicalType) and complex return types via returns_logical(LogicalType). See Overloading with Function Sets.

Can I register multiple functions in one extension?

Yes. The init_extension closure receives a duckdb_connection and can call as many register_* functions as needed:

#![allow(unused)]
fn main() {
quack_rs::entry_point::init_extension(info, access, DUCKDB_API_VERSION, |con| {
    unsafe { register_word_count(con) }?;
    unsafe { register_sentence_count(con) }?;
    unsafe {
        SqlMacro::scalar("double_it", &["x"], "x * 2")?
            .register(con)?;
    }
    Ok(())
})
}

Can I use the duckdb crate instead of libduckdb-sys?

No. The duckdb crate's bundled feature embeds its own copy of DuckDB. A loadable extension must link against the DuckDB that loads it, not bundle a separate copy. Use libduckdb-sys with the loadable-extension feature.

Can I have a scalar function with no parameters?

Yes. Pass an empty slice to param:

#![allow(unused)]
fn main() {
ScalarFunctionBuilder::new("current_quack")
    .returns(TypeId::Varchar)
    .function(quack_callback)
    .register(con)?;
}

Testing

Do I need a DuckDB instance to run unit tests?

No. AggregateTestHarness simulates the aggregate lifecycle in pure Rust without any DuckDB dependency. You can run cargo test without loading a DuckDB binary.

My unit tests all pass but the extension crashes. Why?

Unit tests cannot detect FFI wiring bugs. See Pitfall P3 and the Testing Guide. Always run E2E tests by loading the extension into an actual DuckDB process.

How do I test SQL macros?

SqlMacro::to_sql() is pure Rust and requires no DuckDB connection:

#![allow(unused)]
fn main() {
let m = SqlMacro::scalar("triple", &["x"], "x * 3").unwrap();
assert_eq!(m.to_sql(), "CREATE OR REPLACE MACRO triple(x) AS (x * 3)");
}

For E2E testing, include the macro in your SQLLogicTest file:

query I
SELECT double_it(21);
----
42

Publishing

How do I publish to the DuckDB community extensions registry?

  1. Scaffold your project with generate_scaffold
  2. Push to GitHub
  3. Submit a pull request to the community-extensions repo with your description.yml

See Community Extensions for the full workflow.

My extension name is taken. What should I do?

Use a vendor-prefixed name: myorg_analytics instead of analytics. Extension names must be globally unique across the entire DuckDB ecosystem. Check community-extensions.duckdb.org first.

Do I need to set up CI manually?

No. generate_scaffold produces .github/workflows/extension-ci.yml which builds and tests your extension on Linux, macOS, and Windows automatically.

Can my extension be installed with INSTALL ... FROM community?

Yes, once your pull request is merged into the community-extensions repository. Until then, users load the .duckdb_extension binary directly:

LOAD './path/to/libmy_extension.duckdb_extension';

Troubleshooting

My aggregate returns wrong results with no error.

The most common cause is Pitfall L1: your combine callback is not propagating all configuration fields. See Pitfall L1 and test with AggregateTestHarness::combine.

I'm getting a SEGFAULT when writing NULL.

You are likely calling duckdb_vector_get_validity without first calling duckdb_vector_ensure_validity_writable. Use VectorWriter::set_null instead. See Pitfall L4.

My function is not found in SQL after LOAD.

Most likely cause: the function was not registered (Pitfall L6 — function set name not set on each member), or the entry point symbol name does not match the extension name. The symbol must be {extension_name}_init_c_api (all lowercase, underscores).

make configure fails with a missing file error.

The extension-ci-tools submodule is not initialized:

git submodule update --init --recursive

My SQLLogicTest fails in CI but passes locally.

SQLLogicTest does exact string matching. The most common issue is a difference in NULL representation, decimal places, or line endings. Run the query in the same DuckDB version used by CI and copy the output verbatim.

How do I read a VARCHAR that is longer than 12 bytes?

VectorReader::read_str handles both the inline (≤ 12 bytes) and pointer (> 12 bytes) formats automatically. No special handling needed.

What happens if I read from a NULL row?

You get garbage data from the vector's data buffer. Always check is_valid before reading. See NULL Handling & Strings.


Architecture

Why use libduckdb-sys with loadable-extension instead of the duckdb crate?

The duckdb crate is designed for embedding DuckDB, not for extending it. Its bundled feature includes a statically linked DuckDB binary, which conflicts with the DuckDB runtime that loads your extension. libduckdb-sys with loadable-extension provides lazy-initialized function pointers that are populated by DuckDB at extension load time.

Why not use duckdb-loadable-macros?

duckdb-loadable-macros relies on extract_raw_connection which uses the internal Rc<RefCell<InnerConnection>> layout. This is fragile and causes SEGFAULTs when the layout changes between duckdb crate versions. init_extension uses the correct C API entry sequence directly.

Why is panic = "abort" required?

Panics cannot unwind across FFI boundaries in Rust. A panic in an unsafe extern "C" callback is undefined behavior. panic = "abort" converts panics to process termination, which is still bad but not undefined behavior. Always use Result and ? in your callbacks instead.

Can I use async Rust in my extension?

Not directly in FFI callbacks. DuckDB's callbacks are synchronous C functions. You can run a Tokio or async-std runtime and block on async tasks inside callbacks (using Runtime::block_on), but the callbacks themselves must return synchronously.

How does FfiState<T> prevent double-free?

FfiState<T> stores the Box<T> as a raw pointer in inner. When destroy_callback is called, it reconstitutes the Box (which drops T and frees memory) and then sets inner to null. A second call to destroy_callback on the same state sees a null inner and returns without freeing.