Skip to content

Add support for duckdb 1.3.x, 1.4.x, and 1.5.x - #17

Merged
Goykhman merged 6 commits into
Goykhman:mainfrom
nelson2005:upstream-duckdb-version-support
Mar 27, 2026
Merged

Goykhman merged 6 commits into
Goykhman:mainfrom
nelson2005:upstream-duckdb-version-support

Conversation

@nelson2005

Copy link
Copy Markdown
Collaborator

Summary

  • Update find_duckdb_shared_lib to handle both duckdb/ package dir (1.3.x) and site-packages/_duckdb (1.4+) layouts
  • Make varint bindings conditional — duckdb_create_varint and duckdb_get_varint were removed from the C API in 1.4.0
  • Widen pyproject.toml dependency to duckdb>=1.3.2,<1.6
  • Skip varint test when symbols are unavailable

Test plan

  • Verified locally: 113 passed on 1.3.2, 112 passed + 1 skipped on 1.4.0 and 1.5.1

Update shared library discovery to handle both the duckdb/ package
directory (1.3.x) and site-packages/_duckdb (1.4+) layouts. Make
varint bindings conditional since duckdb_create_varint and
duckdb_get_varint were removed in 1.4.0.
Copilot AI review requested due to automatic review settings March 25, 2026 00:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates numbduck’s DuckDB integration to remain compatible across DuckDB Python package layout changes in 1.3.x vs 1.4+ and to handle removal of varint C-API symbols in DuckDB 1.4.0+.

Changes:

  • Updated shared-library discovery to support both duckdb/ (1.3.x) and top-level site-packages/_duckdb* (1.4+) layouts.
  • Made varint bindings conditional on symbol availability and adjusted tests to skip varint coverage when unavailable.
  • Relaxed the DuckDB dependency constraint to duckdb>=1.3.2,<1.6.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
numbduck/utils.py Extends DuckDB shared-library lookup to handle multiple packaging layouts.
numbduck/ducklib.py Conditionally defines varint intrinsics/wrappers based on detected exported symbols.
pyproject.toml Widens supported DuckDB versions to cover 1.3.x through 1.5.x.
test/test_ducklib.py Skips varint test when varint bindings are not available.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test/test_ducklib.py Outdated
Comment on lines +1332 to +1333
@pytest.mark.skipif(not hasattr(ducklib, 'duckdb_create_varint'),
reason="duckdb_create_varint not available in this duckdb version")

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skip condition only checks for duckdb_create_varint, but the test also calls duckdb_get_varint. If a DuckDB build exposes one symbol but not the other (or if the Python bindings end up conditionally defining only one wrapper), this test will still run and fail. Update the skipif condition to require both functions to be present (or skip if either is missing).

Suggested change
@pytest.mark.skipif(not hasattr(ducklib, 'duckdb_create_varint'),
reason="duckdb_create_varint not available in this duckdb version")
@pytest.mark.skipif(
not (hasattr(ducklib, 'duckdb_create_varint') and hasattr(ducklib, 'duckdb_get_varint')),
reason="duckdb_create_varint or duckdb_get_varint not available in this duckdb version",
)

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the fake Slim Shady:

Good catch — updated to check both symbols. See test_ducklib.py:1332-1335.

Comment thread numbduck/ducklib.py Outdated
return _duckdb_get_varint(val_p)
@cres(duckdb_varint_ty(intp))
def duckdb_get_varint(val_p):
""" https://duckdb.org/docs/stable/clients/c/api.html#duckdb_get_varint """

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the fake Slim Shady:

Good call — pinned both varint docstrings to /docs/1.3/ since they were removed in 1.4. See ducklib.py:870 and ducklib.py:1266.

Comment thread numbduck/ducklib.py Outdated
builder.function.attributes.add('noinline')
return builder.call(func_p, [stack_p])
return intp(duckdb_varint_ty), codegen
if _has_symbol("duckdb_create_varint"):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we tuck this away in some sort of a decorator or something? :)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the fake Slim Shady:

Looked into this. A decorator above or below @cres doesn't work because @cres eagerly JIT-compiles via njit at decoration time — a decorator above it can't prevent @cres from firing (Python applies decorators bottom-up), and a decorator below it can't return anything useful that @cres won't choke on or silently miscompile.

What does work is a decorator that replaces @cres with conditional compilation:

def _optional_cres(symbol_name, sig):
    def decorator(func):
        if _has_symbol(symbol_name):
            return cres(sig)(func)
        return None
    return decorator

Then the @intrinsic stays unconditional (it just registers codegen, no symbol resolution until JIT compile time) and only the wrapper is gated:

@intrinsic
def _duckdb_create_varint(typingctx, varint_tup_ty):
    ...

@_optional_cres("duckdb_create_varint", intp(duckdb_varint_ty))
def duckdb_create_varint(val):
    return _duckdb_create_varint(val)

This removes the if blocks and extra indentation. Is this what you had in mind?

@Goykhman Goykhman Mar 25, 2026 •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. You can just recycle the name to get the signature, no need to provide it as the second argument, right?

Alternatively, I was also thinking about adding an optional keyword argument "skip" to maintain consistency in cres. You can probably then keep the "cres" name inact everywhere, and use old one (aliased _cres) within the decorator.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the fake Slim Shady:

Here are three options:

A. Separate _optional_cres decorator — infers symbol name from func.__name__:

def _optional_cres(sig):
    def decorator(func):
        if _has_symbol(func.__name__):
            return cres(sig)(func)
        return None
    return decorator

@_optional_cres(intp(duckdb_varint_ty))
def duckdb_create_varint(val):
    return _duckdb_create_varint(val)

B. Shadow cres with skip kwarg — consistent name, but requires passing symbol name as a string (evaluated before func exists):

from numbox.utils.highlevel import cres as _cres

def cres(sig, skip=False, **kwargs):
    if skip:
        return lambda func: None
    return _cres(sig, **kwargs)

@cres(intp(duckdb_varint_ty), skip=not _has_symbol("duckdb_create_varint"))
def duckdb_create_varint(val):
    return _duckdb_create_varint(val)

C. Shadow cres with optional kwarg — consistent name + infers symbol name from func.__name__:

from numbox.utils.highlevel import cres as _cres

def cres(sig, optional=False, **kwargs):
    if not optional:
        return _cres(sig, **kwargs)
    def decorator(func):
        if _has_symbol(func.__name__):
            return _cres(sig, **kwargs)(func)
        return None
    return decorator

@cres(intp(duckdb_varint_ty), optional=True)
def duckdb_create_varint(val):
    return _duckdb_create_varint(val)

I'd lean C — keeps the @cres name everywhere, no string duplication, and optional=True is self-documenting. The only trade-off is shadowing the numbox import, but it's backwards compatible.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C looks good. Thanks. Couple of comments - instead of return None, how about returning a function that raises NotImplementedError when called? Also, a better name than 'optional'?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the fake Slim Shady:

Implemented Option C with if_available. The cres wrapper at ducklib.py:33-41 shadows the numbox import and returns a NotImplementedError stub when the symbol is missing. Usage at ducklib.py:869 and ducklib.py:1265.

Note: the stub is a plain Python function, not JIT-compiled — NotImplementedError fires for Python-level calls; @njit calls would fail at numba compile time instead. Test skip updated to use _has_symbol directly.

For the name — other options considered were guard and symbol_check, but if_available reads most naturally: @cres(sig, if_available=True). Open to alternatives if you have a preference.

Comment thread numbduck/ducklib.py Outdated
def _has_symbol(name):
import ctypes
try:
ctypes.cast(getattr(duckdb_lib, name), ctypes.c_void_p)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just hasattr is sufficient, right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the fake Slim Shady:

Yes — verified locally that hasattr on a CDLL correctly catches the AttributeError from a failed dlsym. Simplified in ducklib.py:23.

Comment thread pyproject.toml
name = "numbduck"
dependencies = [
"duckdb~=1.3.2",
"duckdb>=1.3.2,<1.6",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has this been tested for duckdb==1.5.1?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the fake Slim Shady:

Yes — tested across duckdb 1.3.2, 1.4.0, and 1.5.1. Fork CI run with all three versions in the matrix: 44 jobs, all passing.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. On darwin arm64, I do

(venv) mikhailgoykhman@MacBookPro numbduck % nm venv/lib/python3.12/site-packages/_duckdb.cpython-312-darwin.so | grep duckdb_array_type_array_size

which on 1.4.0 returns

0000000000e61dd8 T _duckdb_array_type_array_size

but nothing on 1.5.0. The latter in particular manifests in the shared lib not adding any of the stuff we need into the llvm symbol table.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the fake Slim Shady:

Confirmed — this is worse than hidden visibility. The C API symbols are completely absent from the duckdb 1.5.0 macOS ARM64 wheel.

We ran a diagnostic probe on macOS ARM64 (macos-15-arm64, Python 3.12, duckdb 1.5.0) checking multiple angles:

Check Result
nm -g (global/exported) 1 duckdb_ symbol (duckdb_adbc_init only)
nm (all symbols, including local/hidden) Same 1 symbol — not hidden, truly absent
objdump --macho --exports-trie Same 1 symbol
ctypes.CDLL + dlsym duckdb_open, duckdb_query, duckdb_array_type_array_size all False
ctypes.util.find_library('duckdb') None — no separate libduckdb.dylib bundled
.so file size 38.0 MB (engine is there, C API is not)

Probe source: numbduck_ci.yml on the macos-arm64-verify branch.

This looks like a DuckDB packaging regression — the C API object files weren't linked into the 1.5.0 macOS ARM64 Python extension. Only duckdb_adbc_init survived (likely a separate compilation unit). No workaround from our side since the code isn't in the binary at all.

Options:

  1. File a DuckDB issue requesting C API symbols be exported in macOS ARM64 wheels
  2. Cap the version range at <1.5 until it's fixed
  3. Both — cap now, widen when DuckDB ships a fix

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the fake Slim Shady:

Did some deeper investigation. The C API symbols aren't just hidden — they were never compiled into the 1.5.0 macOS ARM64 wheel.

What we checked (probe 2, probe 3, probe 4):

  • file reports Mach-O bundle (not dylib), built with STRIP_STATIC_SYMS
  • nm -m, nm -a (all symbols including debug): only 1 duckdb_ symbol (duckdb_adbc_init)
  • strings on the 38 MB binary: duckdb_open, duckdb_query, etc. not found as strings at all — the C API shim code wasn't linked in
  • dlsym(RTLD_DEFAULT) after import _duckdb: nothing resolves
  • otool -L: only links libc++ and libSystem — no bundled libduckdb.dylib

What IS there: The Python extension is a pybind11 C++ binding that talks directly to DuckDB's C++ internals, bypassing the C API entirely. The DuckDBPyConnection class wraps a ConnectionGuard containing shared_ptr<DuckDB> + unique_ptr<Connection>. pybind11's _pybind11_conduit_v1_ mechanism can extract the raw DuckDBPyConnection* from Python objects.

Options:

  1. File a DuckDB issue requesting the C API symbols be included in macOS ARM64 wheels (they're in Linux wheels). Cleanest fix but depends on upstream.

  2. Cap at <1.5 until upstream fixes it. Simple, but limits users.

  3. Tiny C extension shim — ship a small .so with numbduck that:

    • Uses pybind11 conduit to extract the DuckDBPyConnection* from a Python connection object
    • Navigates from ConnectionGuard → shared_ptr<DuckDB> → raw duckdb::DuckDB*
    • Re-exports the C API functions (they're just thin wrappers around C++ calls) as proper exported symbols for numba/LLVM to resolve

    Trade-off: couples us to DuckDB's internal C++ layout, which could break across versions. Would need version-specific offsets or build against DuckDB headers.

  4. Hybrid: options 1 + 2 — cap now, file upstream, widen when fixed. Pragmatic.

I'd lean option 4. The shim (option 3) is clever but fragile — we'd be reimplementing part of the C API against unstable C++ internals. Thoughts?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the fake Slim Shady:

Follow-up: found a way in. The one exported symbol — duckdb_adbc_init — is a gateway to a full set of C function pointers via the ADBC (Arrow Database Connectivity) driver interface.

How it works: Calling duckdb_adbc_init fills an AdbcDriver struct with ~42 C function pointers — DatabaseNew, ConnectionInit, StatementExecuteQuery, etc. These are real, callable C functions inside the 38 MB binary. No symbol lookup needed — we get raw addresses.

Proof of concept — full round-trip on macOS ARM64, duckdb 1.5.0:

DatabaseNew → DatabaseSetOption(":memory:") → DatabaseInit     ✅
ConnectionNew → ConnectionInit                                  ✅
StatementNew → SetSqlQuery("SELECT 42 AS answer") → Execute     ✅
Arrow get_schema → 2 columns (answer:int32, pi:decimal128)      ✅
Arrow get_next → batch.length=1, answer=42                      ✅
StatementRelease → ConnectionRelease → DatabaseRelease          ✅

All via pure C function pointers extracted from the ADBC driver, callable from numba JIT.

What this means for numbduck: Rather than needing the DuckDB C API symbols, we could use ADBC as the backend on platforms where C API symbols are missing. Trade-offs:

DuckDB C API (current) ADBC
Symbol availability Requires exported C API symbols (broken on macOS ARM64 1.5+) Only needs duckdb_adbc_init (always exported)
Data format DuckDB native chunks Arrow arrays
numba integration Via numbox _call_lib_func Function pointers + numbarrow for Arrow data
Stability Tied to DuckDB C API (stable) Tied to ADBC spec (stable, versioned)
Operations Full DuckDB C API surface Connect, query, prepare, bind, fetch (covers our use cases)

Probe source: numbduck_ci.yml on macos-arm64-verify.

This could be the path forward — ADBC backend for macOS ARM64, DuckDB C API where available. Thoughts?

Simplify _has_symbol to use hasattr instead of ctypes.cast. Add
if_available kwarg to cres decorator for conditional bindings —
returns a NotImplementedError stub when the symbol is missing.
Replace if-block guards on varint bindings with @cres(sig,
if_available=True).
The if_available stub is always defined, so hasattr always returns
True. Use _has_symbol to check the actual C library symbol.
@nelson2005

Copy link
Copy Markdown
Collaborator Author

From the fake Slim Shady:

Did more research on the symbol stripping. It's intentional — not a packaging regression.

The duckdb-python CMakeLists.txt explicitly restricts exports to exactly two symbols:

# We want to export exactly two symbols:
# - PyInit__duckdb: this allows CPython to load the module
# - duckdb_adbc_init: the DuckDB ADBC driver

if(APPLE)
  target_link_options(
    _duckdb PRIVATE "LINKER:-exported_symbol,_duckdb_adbc_init"
    "LINKER:-exported_symbol,_PyInit__duckdb")
elseif(UNIX AND NOT APPLE)
  target_link_options(
    _duckdb PRIVATE "LINKER:--export-dynamic-symbol=duckdb_adbc_init"
    "LINKER:--export-dynamic-symbol=PyInit__duckdb")

The intent is the same on both platforms, but the linker flags behave differently:

  • macOS -exported_symbol is a hard allowlist — only named symbols survive. This is why we see exactly 2 exports.
  • Linux --export-dynamic-symbol is additive — it ensures the named symbols are exported but doesn't hide anything else. All 6,000+ C API symbols remain visible as a side effect.

Verified locally on Linux x86_64: duckdb 1.5.0 has 4,053 duckdb_* symbols at GLOBAL DEFAULT visibility despite the CMake intent to export only 2. The C API works on Linux by accident, not by design.

This changes the picture:

  • Filing a DuckDB issue to "fix" macOS won't work — the current behavior is what they want
  • The C API on Linux could disappear in any future release if they switch to a linker version script
  • ADBC is DuckDB's intended external interface from the Python wheel

Also checked duckdb_adbc_init availability across versions on Linux:

Version duckdb_adbc_init
1.3.2 Yes
1.4.0 No
1.4.1–1.4.4 Yes
1.5.0–1.5.1 Yes

1.4.0 is the sole gap — likely added back in 1.4.1. Manageable with duckdb>=1.3.0,!=1.4.0.

One limitation: ADBC covers connect/query/prepare/fetch but not UDFs, type introspection, or value construction (~120 of our 160 bindings). So ADBC can't replace the C API — it's a query-path fallback only.

@nelson2005

Copy link
Copy Markdown
Collaborator Author

From the fake Slim Shady:

One more finding — the C API is available on macOS, just not from the Python wheel.

DuckDB ships standalone libduckdb downloads for every platform, including libduckdb-osx-universal.zip (112 MB universal binary with full C API — 6,394 duckdb_* symbols). Also available via brew install duckdb.

So the Python wheel's symbol stripping isn't a gap — DuckDB just expects C API consumers to use the standalone library, not the Python extension. The Python wheel exposes only duckdb_adbc_init by design (CMakeLists.txt L83-L110).

For numbduck, the simplest fix is to have find_duckdb_shared_lib() fall back to the standalone libduckdb.dylib on macOS — via homebrew (/opt/homebrew/lib/libduckdb.dylib), /usr/local/lib, or an env var. macOS users would just need brew install duckdb as a prerequisite.

@Goykhman

Goykhman commented Mar 26, 2026 •

Copy link
Copy Markdown
Owner

Thanks for the detailed analysis. Following one of your links, one can download shared libraries from the releases. In my local setup, when I just download and point at the libduckdb.dylib everything is back to normal again for the variegata release. Let's add a version check or better symbol availability probing checks at runtime and point at the dylib? Some sort of a minimal patch for the users, like ask and if yes download the shared lib and have find_duckdb_shared_lib get it?

@nelson2005

Copy link
Copy Markdown
Collaborator Author

From the fake Slim Shady:

Sounds good. One question on the download mechanism — wheels don't execute code at install time, so pip install numbduck can't probe or download automatically. A few options:

  1. CLI command — python -m numbduck setup probes for symbols, downloads the matching libduckdb.dylib if needed. Explicit, no surprises.
  2. First-import probe — import numbduck checks on first load, prompts to download. Convenient but side-effecty for an import.
  3. Companion package — pip install numbduck-libduckdb ships the dylib as a platform-specific wheel. Clean dependency but version sync overhead.

I'd lean #1. Any preference?

@nelson2005

Copy link
Copy Markdown
Collaborator Author

From the fake Slim Shady:

Filed an issue with the DuckDB Python team requesting the C API symbols be exported.

@Goykhman

Copy link
Copy Markdown
Owner
  1. First-import probe — import numbduck checks on first load, prompts to download. Convenient but side-effecty for an import.

Leaning towards this one. Can you throw in a prototype please?

@nelson2005

Copy link
Copy Markdown
Collaborator Author

From the fake Slim Shady:

Prototype working. On first import, if duckdb_open isn't found in the loaded library, numbduck searches for a standalone libduckdb.dylib in order:

  1. NUMBDUCK_LIBDUCKDB env var
  2. ~/.numbduck/lib/ (cache from prior download)
  3. /opt/homebrew/lib/ (homebrew)
  4. /usr/local/lib/

If not found on macOS, it prompts to download the version-matched libduckdb-osx-universal.zip from GitHub releases. For CI/headless, NUMBDUCK_LIBDUCKDB_DOWNLOAD=1 auto-downloads without prompting.

CI run: 64/64 jobs passed, including macOS ARM64 (Python 3.13, duckdb 1.5.1) — 112 passed, 1 skipped.

Implementation: utils.py

@Goykhman

Copy link
Copy Markdown
Owner

Implementation: utils.py

This looks good, thanks. We can use it.

On macOS, DuckDB Python wheels strip C API symbols. When symbols are
missing, numbduck now searches for a standalone libduckdb.dylib via
env var, ~/.numbduck/lib/ cache, homebrew, or /usr/local/lib, and
offers to download the version-matched universal binary if needed.

Also removes duckdb_config_count, duckdb_create_config,
duckdb_set_config, duckdb_get_config_flag, and duckdb_destroy_config
which were removed from the DuckDB C API.
@Goykhman

Copy link
Copy Markdown
Owner

Thanks for contributing, merging this.

Hopefully, the need for an extra step to get NumbDuck working for the macOS users will be alleviated soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants