Repository navigation
Add support for duckdb 1.3.x, 1.4.x, and 1.5.x - #17
Conversation
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.
There was a problem hiding this comment.
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-levelsite-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.
| @pytest.mark.skipif(not hasattr(ducklib, 'duckdb_create_varint'), | ||
| reason="duckdb_create_varint not available in this duckdb version") |
There was a problem hiding this comment.
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).
| @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", | |
| ) |
There was a problem hiding this comment.
From the fake Slim Shady:
Good catch — updated to check both symbols. See test_ducklib.py:1332-1335.
| 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 """ |
There was a problem hiding this comment.
Do we want to pin this to a version, like https://duckdb.org/docs/1.3/clients/c/api#duckdb_get_varint ?
There was a problem hiding this comment.
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.
| builder.function.attributes.add('noinline') | ||
| return builder.call(func_p, [stack_p]) | ||
| return intp(duckdb_varint_ty), codegen | ||
| if _has_symbol("duckdb_create_varint"): |
There was a problem hiding this comment.
Can we tuck this away in some sort of a decorator or something? :)
There was a problem hiding this comment.
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 decoratorThen 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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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'?
There was a problem hiding this comment.
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.
| def _has_symbol(name): | ||
| import ctypes | ||
| try: | ||
| ctypes.cast(getattr(duckdb_lib, name), ctypes.c_void_p) |
There was a problem hiding this comment.
Just hasattr is sufficient, right?
There was a problem hiding this comment.
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.
| name = "numbduck" | ||
| dependencies = [ | ||
| "duckdb~=1.3.2", | ||
| "duckdb>=1.3.2,<1.6", |
There was a problem hiding this comment.
Has this been tested for duckdb==1.5.1?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- File a DuckDB issue requesting C API symbols be exported in macOS ARM64 wheels
- Cap the version range at
<1.5until it's fixed - Both — cap now, widen when DuckDB ships a fix
There was a problem hiding this comment.
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):
filereports Mach-O bundle (not dylib), built withSTRIP_STATIC_SYMSnm -m,nm -a(all symbols including debug): only 1 duckdb_ symbol (duckdb_adbc_init)stringson the 38 MB binary:duckdb_open,duckdb_query, etc. not found as strings at all — the C API shim code wasn't linked indlsym(RTLD_DEFAULT)afterimport _duckdb: nothing resolvesotool -L: only linkslibc++andlibSystem— no bundledlibduckdb.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:
-
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.
-
Cap at
<1.5until upstream fixes it. Simple, but limits users. -
Tiny C extension shim — ship a small
.sowith numbduck that:- Uses pybind11 conduit to extract the
DuckDBPyConnection*from a Python connection object - Navigates from
ConnectionGuard→shared_ptr<DuckDB>→ rawduckdb::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.
- Uses pybind11 conduit to extract the
-
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?
There was a problem hiding this comment.
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.
|
From the fake Slim Shady: Did more research on the symbol stripping. It's intentional — not a packaging regression. The # 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:
Verified locally on Linux x86_64: duckdb 1.5.0 has 4,053 This changes the picture:
Also checked
1.4.0 is the sole gap — likely added back in 1.4.1. Manageable with 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. |
|
From the fake Slim Shady: One more finding — the C API is available on macOS, just not from the Python wheel. DuckDB ships standalone 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 For numbduck, the simplest fix is to have |
|
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 |
|
From the fake Slim Shady: Sounds good. One question on the download mechanism — wheels don't execute code at install time, so
I'd lean #1. Any preference? |
|
From the fake Slim Shady: Filed an issue with the DuckDB Python team requesting the C API symbols be exported. |
Leaning towards this one. Can you throw in a prototype please? |
|
From the fake Slim Shady: Prototype working. On first import, if
If not found on macOS, it prompts to download the version-matched CI run: 64/64 jobs passed, including macOS ARM64 (Python 3.13, duckdb 1.5.1) — 112 passed, 1 skipped. Implementation: |
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.
|
Thanks for contributing, merging this. Hopefully, the need for an extra step to get NumbDuck working for the macOS users will be alleviated soon. |
Summary
find_duckdb_shared_libto handle bothduckdb/package dir (1.3.x) andsite-packages/_duckdb(1.4+) layoutsduckdb_create_varintandduckdb_get_varintwere removed from the C API in 1.4.0duckdb>=1.3.2,<1.6Test plan