ABI Compatibility

This page explains DuckDB C Extension API ABI compatibility: which DuckDB releases a quack-rs extension binary can load into, and how quack-rs guards against a layout mismatch. A loadable extension does not link against DuckDB's symbols. DuckDB hands it a pointer to a duckdb_ext_api_v1 struct — an array of function pointers — and the extension calls through it.

Two things have to agree about that struct's layout: the header your extension was compiled against (whichever libduckdb-sys version Cargo resolved), and the DuckDB binary that is loading it. When they disagree, every call lands on the wrong slot.

The struct has two halves

RegionSlotsGuarantee
Stable0 .. 357Frozen since DuckDB v1.2.0 — same slots, same order, same signatures in every release through v1.5.6 (two slots, 114 and 138, were renamed varint → bignum in v1.4.0 with an identical struct layout)
Unstable357 ..DuckDB inserts new entries in the middle, shifting every later slot

The stable prefix is what makes "build once, load anywhere" possible. The unstable tail is not append-only:

DuckDBTotal slotsWhat moved
v1.2.0 – v1.2.2408baseline
v1.3.0 – v1.3.2428appended
v1.4.0 – v1.4.5459duckdb_create_varint → duckdb_create_bignum; appended
v1.5.0 – v1.5.1545duckdb_appender_clear inserted at slot 410
v1.5.2 – v1.5.6546duckdb_geometry_type_get_crs inserted at slot 493

DuckDB v1.5.6 declares all 546 slots stable for extensions that target C API v1.5.6 (earlier releases declared only the first 357 stable). The layout itself is unchanged from v1.5.2, and quack-rs targets C API v1.2.0, so the guard below still applies.

Every family since v1.2 changed the unstable tail, and twice (v1.5.0 and v1.5.2) an insertion in the middle shifted every later slot.

Which half are you using?

Everything quack-rs exposes by default lives in the stable prefix: scalar, aggregate, table and cast functions, vectors, data chunks, values, SQL macros, replacement scans, the query API and the datetime conversions.

The duckdb-1-5, duckdb-1-5-3 and duckdb-1-5-4 features wrap the unstable half — 130 of its 189 functions, covering scalar bind/init, copy functions in both directions, the Arrow C Data Interface bridge, catalog access, ErrorData, FileSystem, Expression, SelectionVector, config options, table descriptions, TIME_NS values and the client context.

abi::uses_unstable_api() reports which half a build uses. The book's own test build enables duckdb-1-5-4 (and therefore duckdb-1-5), so this block is compiled there but not run:

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

// false unless `duckdb-1-5` is enabled.
assert!(!abi::uses_unstable_api());
}

Why DuckDB does not catch this for you

DuckDB validates the ABI metadata in your extension's footer:

ABI typeVersion field (-dv) meansAccepted by
C_STRUCTthe C API version (v1.2.0)any DuckDB whose C API version is at least that — then handed the whole struct, unstable region included
C_STRUCT_UNSTABLEan exact DuckDB release (v1.5.5)that release only

So a C_STRUCT binary that touches the unstable region loads happily into the wrong DuckDB and then mis-dispatches. DuckDB's own extension-template-c says:

WARNING: When set to 1, the duckdb_extension.h from the TARGET_DUCKDB_VERSION must be used, using any other version of the header is unsafe.

Built against v1.5.0's headers and loaded into v1.5.5, an extension calling ClientContext::from_connection invokes duckdb_destroy_client_context on a duckdb_connection. In practice:

double free or corruption (out)
Aborted (core dumped)

What quack-rs does

Two layers.

Build metadata. If you enable duckdb-1-5, stamp the binary C_STRUCT_UNSTABLE with the DuckDB release you built against, so DuckDB refuses the wrong engine at install time. With extension-ci-tools, set in the Makefile:

USE_UNSTABLE_C_API=1
TARGET_DUCKDB_VERSION=v1.5.5

generate_scaffold writes this pairing from a ScaffoldConfig, and rejects a target_duckdb_version that does not match use_unstable_c_api:

#![allow(unused)]
fn main() {
use quack_rs::scaffold::ScaffoldConfig;

let config = ScaffoldConfig {
    name: "my_ext".to_string(),
    use_unstable_c_api: true,
    target_duckdb_version: "v1.5.5".to_string(),
    ..ScaffoldConfig::default()
};
}

Runtime guard. When a duckdb-1-5* feature is enabled, abi::check compares the compiled-in slot count against the layout the running engine uses, resolved from duckdb_library_version() — which lives at stable slot 7 and is therefore always dispatched correctly. (Without those features the check reports StableOnly and always passes.) The entry point applies it according to an AbiPolicy:

PolicyBehaviour
Strict (default)Refuse to load, with a message naming both layouts and the fix
AllowUnknownEngineRefuse a layout the table knows is different; allow a release the table has no entry for
WarnPrint the diagnostic to stderr, then load anyway (set_error would fail the load)
TrustSkip the check

AllowUnknownEngine and Trust are only as safe as your knowledge of the engine: calling into the unstable region of a layout the extension was not built for is undefined behaviour. Before DuckDB v1.4.5 was in quack-rs's table, a v1.5.5 build loaded into v1.4.5 under AllowUnknownEngine and segfaulted.

The two lines below are alternatives: both define the same exported symbol, so they do not compile together.

use quack_rs::abi::AbiPolicy;

// Default: Strict.
quack_rs::entry_point!(my_ext_init_c_api, register);

// Explicit — appropriate when the binary is stamped C_STRUCT_UNSTABLE, because
// DuckDB already refuses to load it into the wrong release.
quack_rs::entry_point!(my_ext_init_c_api, AbiPolicy::Trust, register);

A refused load looks like this (a v1.5.0 build loaded into DuckDB v1.5.5):

DuckDB C extension API layout mismatch: this extension was built against a
duckdb_ext_api_v1 with 545 slots, but DuckDB v1.5.5 provides 546. The extension
uses the unstable region of the C API (quack-rs feature `duckdb-1-5`), whose slot
indices differ between these releases, so loading it would dispatch to the wrong
functions. Rebuild the extension against DuckDB v1.5.5. Stamp every build that
uses the unstable region with `--abi-type C_STRUCT_UNSTABLE --duckdb-version <the
DuckDB release it was built against>` (or `USE_UNSTABLE_C_API=1` with
extension-ci-tools) so DuckDB refuses a mismatched binary at install time.

For an engine older than v1.5.0 the advice changes: such an engine cannot run a duckdb-1-5 build at all, so the message says to build a variant without those features or to upgrade DuckDB.

Unknown DuckDB versions

Strict also refuses a DuckDB release quack-rs has no verified layout for — a newer release, or a -dev build. That is deliberate: DuckDB changed the unstable region in every minor release from 1.3.0 on, and in the 1.5.2 patch release, so "unknown" is not evidence of "compatible". The refusal lists the fixes, best first:

  1. rebuild against the release you are targeting and set QUACK_RS_TARGET_DUCKDB_VERSION to it, which turns the check into a positive match without waiting for a quack-rs release;
  2. upgrade quack-rs to a version whose layout table lists that release;
  3. if the extension does not need the unstable region, build it without the duckdb-1-5 features: the stable prefix is laid out identically in every DuckDB since v1.2.0.

It never suggests AllowUnknownEngine or Trust, for the reason above.

scripts/check-abi-table.py re-derives quack-rs's layout table from every upstream release header and runs in CI, so the table tracks DuckDB.