zig-duckdb-ext

March 24, 2026 · View on GitHub

A DuckDB extension written in Zig, targeting the DuckDB v1.2.0 C extension API.

Demonstrates how to build scalar and table functions for DuckDB using Zig's @cImport for zero-cost C interop, defer/errdefer for safe resource management, and comptime generics for zero-cost function registration.

Functions

zig_hello(name VARCHAR) -> VARCHAR

Returns a greeting string.

SELECT zig_hello('Zig');
-- Hello, Zig!

NULL-propagating: returns NULL when the input is NULL.

zig_add_numbers(a BIGINT, b BIGINT) -> BIGINT

Adds two 64-bit integers.

SELECT zig_add_numbers(2, 3);
-- 5

NULL-propagating: returns NULL when either input is NULL.

zig_tokenize(input VARCHAR, delim VARCHAR) -> TABLE(token_pos BIGINT, token VARCHAR)

Splits a string by a delimiter and returns one row per token with a 0-based position ordinal.

SELECT * FROM zig_tokenize('a,b,c', ',');
token_postoken
0a
1b
2c

Empty tokens are preserved:

SELECT * FROM zig_tokenize('a,,b', ',');
token_postoken
0a
1
2b

Supports multi-character delimiters:

SELECT * FROM zig_tokenize('x::y::z', '::');

Comptime Function Framework

The extension includes a comptime registration framework that generates vectorized callbacks and DuckDB registration boilerplate from simple per-row Zig functions. This eliminates ~40 lines of boilerplate per scalar function with zero runtime overhead.

Scalar functions

Define a per-row function; the framework generates the vectorized callback, NULL propagation, and type registration:

const comptime_scalar = @import("lib/comptime_scalar.zig");

fn add(a: i64, b: i64) i64 {
    return a + b;
}

const AddFn = comptime_scalar.Function("my_add", add);

pub fn register(conn: db.Connection) !void {
    return AddFn.register(conn);
}

20 lines vs. 60 for the hand-written equivalent. The generated code benchmarks identically — see ADR-0002.

Supported types: i8, i16, i32, i64, f32, f64, bool.

Table functions

Provide a config struct with bind/init/exec callbacks; the framework handles registration boilerplate:

const comptime_table = @import("lib/comptime_table.zig");

const MyTable = comptime_table.Function(struct {
    pub const name: [:0]const u8 = "my_table";
    pub const param_types = [_]DuckDBType{ .VARCHAR, .VARCHAR };
    pub const bind = myBind;
    pub const init = myInit;
    pub const exec = myExec;
});

Benchmark Results

task bench over 10M rows (ReleaseSafe, macOS arm64):

FunctionHand-writtenComptimeDelta
add_numbers (10M rows)0.024s0.025s~0%
tokenize (500K rows)0.001s0.001s0%
Binary size278K278K0%

The comptime layer compiles away completely — identical machine code, zero binary bloat. Details in ADR-0002.

Prerequisites

  • Zig 0.15.2+
  • DuckDB CLI (for running the extension)
  • Task (optional, for convenience commands)

Build

# Debug build
zig build

# Release build
zig build -Doptimize=ReleaseSafe

The build produces zig-out/lib/zig_ext.duckdb_extension.

With Task:

task build            # debug
task build:release    # release-safe

Usage

Load the extension in DuckDB with the -unsigned flag (required for extensions not signed by DuckDB Labs):

duckdb -unsigned
LOAD 'zig-out/lib/zig_ext.duckdb_extension';

SELECT zig_hello('world');
SELECT zig_add_numbers(40, 2);
SELECT * FROM zig_tokenize('hello world', ' ');

Testing

# Unit tests
task test:unit

# End-to-end SQL smoke tests (builds the extension first)
task test:e2e

# All quality gates (format + build + unit tests)
task check:all

# Benchmark hand-written vs comptime (release build)
task bench

Project Structure

src/
  ext/                Extension entry point and function registration
  functions/          Function implementations (hand-written and comptime)
  lib/                DuckDB C API bindings + comptime frameworks
build.zig             Build configuration
build.zig.zon         Package manifest
duckdb_capi/          Vendored DuckDB C headers (v1.2.0)
test/sql/             End-to-end SQL test scripts
docs/rationale/       Architecture decision records

Platform Support

PlatformArchitecture
macOSarm64, amd64
Linuxarm64, amd64
Windowsamd64

Available Tasks

CommandDescription
task buildCompile the extension (debug)
task build:releaseCompile the extension (release)
task test:unitRun unit tests
task test:e2eRun end-to-end SQL tests
task check:allRun all quality gates
task benchBenchmark hand-written vs comptime
task formatFormat Zig source files
task demo:helloDemo the zig_hello function
task demo:add-numbersDemo the zig_add_numbers function
task demo:tokenizeDemo the zig_tokenize function
task auditCheck for compiler warnings
task cleanRemove build artifacts
task locCount lines of code

Design Decisions

  • Zig over C++: Direct C header consumption via @cImport, no ABI coupling to DuckDB's C++ internals. See ADR-0001.
  • Comptime registration: Zero-cost comptime generics generate vectorized callbacks from per-row functions — same performance as hand-written code with 3x less boilerplate. See ADR-0002.
  • Vectorized execution: All functions process entire DataChunks rather than row-at-a-time, matching DuckDB's execution model.
  • No external dependencies: Only the Zig standard library and vendored DuckDB C headers.
  • Explicit memory management: Allocators are passed explicitly; defer and errdefer ensure cleanup on all paths.