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
| Region | Slots | Guarantee |
|---|---|---|
| Stable | 0 .. 357 | Frozen 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) |
| Unstable | 357 .. | 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:
| DuckDB | Total slots | What moved |
|---|---|---|
| v1.2.0 – v1.2.2 | 408 | baseline |
| v1.3.0 – v1.3.2 | 428 | appended |
| v1.4.0 – v1.4.5 | 459 | duckdb_create_varint → duckdb_create_bignum; appended |
| v1.5.0 – v1.5.1 | 545 | duckdb_appender_clear inserted at slot 410 |
| v1.5.2 – v1.5.6 | 546 | duckdb_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 type | Version field (-dv) means | Accepted by |
|---|---|---|
C_STRUCT | the 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_UNSTABLE | an 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.hfrom theTARGET_DUCKDB_VERSIONmust 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:
| Policy | Behaviour |
|---|---|
Strict (default) | Refuse to load, with a message naming both layouts and the fix |
AllowUnknownEngine | Refuse a layout the table knows is different; allow a release the table has no entry for |
Warn | Print the diagnostic to stderr, then load anyway (set_error would fail the load) |
Trust | Skip 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:
- rebuild against the release you are targeting and set
QUACK_RS_TARGET_DUCKDB_VERSIONto it, which turns the check into a positive match without waiting for a quack-rs release; - upgrade quack-rs to a version whose layout table lists that release;
- if the extension does not need the unstable region, build it without the
duckdb-1-5features: 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.