Python bindings for DuckDB 2.0, bound only to DuckDB's v2 C API (duckdb_v2_*). bareduckdb
does not vendor or build DuckDB. It loads the official libduckdb shared library from DuckDB's
release, so the engine is upstream's release build.
pip install bareduckdbThe first import downloads libduckdb; see "The DuckDB library".
import bareduckdb
import polars as pl
with bareduckdb.connect() as conn:
conn.register("t", pl.DataFrame({"a": [1, 2, 3]}))
conn.execute("SELECT sum(a) AS s FROM t").pl() # polars frame, s = 6, no pyarrow
conn.execute("SELECT 42 AS answer").fetchall() # [(42,)]- Registration is explicit. Pass a DataFrame, Arrow table or
pl.LazyFrameinline withconn.execute(sql, data={"t": df}), or register it on the connection withconn.register("t", df). A query sees only objects named one of these ways. duckdb-python can also find variables in the caller's scope; bareduckdb leaves that out. - Input is zero-copy for Arrow and polars data. It is read in place and on demand, so a
LIMITreads only the batches it needs and apl.LazyFrameis streamed, never collected. - polars goes in through its own Arrow C stream and comes out through
.pl()the same way, without importing pyarrow. CI runs a leg with pyarrow uninstalled.
Wheels ship for free-threaded 3.14t and 3.15t. Every engine call runs with the GIL released, and every extension module declares itself free-threading compatible.
Wheels are under 400 KB and have no required Python dependencies; pyarrow, polars and pandas are optional and imported on demand. The DuckDB library, 20 to 40 MB compressed, is downloaded from DuckDB's release server on first import and cached.
import bareduckdb
with bareduckdb.connect() as conn: # or connect("file.db")
conn.execute("CREATE TABLE t AS SELECT * FROM range(100) AS r(a)")
conn.execute("SELECT * FROM t WHERE a > ?", [10]).fetchall() # positional
conn.execute("SELECT * FROM t WHERE a > $x", {"x": 10}).fetchall() # named
conn.execute("SELECT * FROM t").pl() # polars
conn.execute("SELECT * FROM t").arrow_table() # pyarrow
conn.execute("SELECT * FROM t").df() # pandas, ArrowDtype columnsregister() names an object on the connection until unregister() or the connection closes.
import bareduckdb
import polars as pl
import pyarrow as pa
orders = pl.DataFrame({"id": [1, 2, 3], "amount": [10.0, 25.0, 5.0]})
with bareduckdb.connect() as conn:
conn.register("orders", orders)
conn.execute("SELECT sum(amount) AS total FROM orders").pl()
conn.register("events", pl.scan_parquet("events/*.parquet")) # streamed, not collected
conn.execute("SELECT count(*) FROM events WHERE kind = 'click'").pl()
conn.unregister("orders")execute(..., data=...) names objects for that one call, and the names are gone when the
call returns.
customers = pa.table({"id": [1, 2, 3], "name": ["a", "b", "c"]})
with bareduckdb.connect() as conn:
conn.execute(
"SELECT c.name, o.amount FROM o JOIN c USING (id) WHERE o.amount > ?",
[8],
data={"o": orders, "c": customers},
).fetchall()
# [('a', 10.0), ('b', 25.0)]A variable named neither way is not found, even if it is in scope: SELECT * FROM customers
above raises a catalog error. "Registered sources" below lists what both accept and how each
source type is read.
A registration can be scanned once per query, so a self-join or a UNION ALL over the same
name raises. Read it through a materialized CTE instead:
WITH c AS MATERIALIZED (SELECT id, amount FROM orders)
SELECT count(*) FROM c x JOIN c y USING (id)The registration is scanned once, and the CTE holds a copy of the selected columns until the
query ends. Write MATERIALIZED explicitly. Without it DuckDB may inline the CTE, which it
does when the query has a LIMIT, and the second scan raises. A CTE does not help concurrent
queries from other cursors.
reader = conn.execute("SELECT * FROM range(10_000_000)").arrow_reader(batch_size=100_000)
for batch in reader:
...A reader holds its query open until it is read to the end, it is deleted, or the connection
runs its next statement. After that, a reader still referenced raises ArrowInvalid on its
next read, where duckdb-python ends it without an error. reader.close() does not release the
query. Use conn.cursor() to keep a reader open while running other queries.
import threading
from bareduckdb import QueryCancelled, enable_progress, poll_progress
with bareduckdb.connect() as conn:
threading.Timer(5.0, conn.interrupt).start()
try:
conn.execute("SELECT ... FROM big").fetchall()
except QueryCancelled:
pass
conn.execute("SELECT 42").fetchall() # the connection is still usable
enable_progress(conn)
with poll_progress(conn, lambda p: print(f"{p.percentage:.0f}%")):
conn.execute("SELECT ... FROM big").pl()interrupt() works from any thread. On a GIL build it cannot stop .pl() mid-read, because
polars holds the GIL while it reads. max_execution_time still applies, and
arrow_table() and arrow_reader() can be interrupted. See
README_PROGRESS.md.
The async pool is built on anyio. Each query runs on a cursor in a worker thread, and
cancelling the awaiting task interrupts the query. Results come back as pyarrow.Table, so
the pool needs pyarrow as well as the aio extra.
pip install "bareduckdb[aio,arrow]"from bareduckdb.aio import AsyncConnectionPool
async with AsyncConnectionPool(":memory:", pool_size=4) as pool:
table = await pool.execute("SELECT * FROM range(?)", parameters=(10,))A cancelled execute returns once the interrupt has stopped the worker thread, about 2 ms
after the cancel (see README_PROGRESS.md), and the cursor is free by
then. anyio's move_on_after and fail_after work on either backend, and
asyncio.wait_for works under asyncio.
bareduckdb.register_as_duckdb() aliases the duckdb module to bareduckdb. CI runs
SQLAlchemy (duckdb-engine), jupysql and magic-duckdb this way.
import bareduckdb
bareduckdb.register_as_duckdb()
import duckdb
print(duckdb.__version__) # bareduckdb's version
with duckdb.connect() as conn:
print(conn.execute("PRAGMA version").arrow_table())
print(conn.execute("select * from range(10)").df())In a container, mount the cache directory (see "The DuckDB library"), or the download repeats on every run. For an air-gapped host, fetch the library elsewhere and point bareduckdb at it:
python -m bareduckdb.install_duckdb --dir /opt/duckdb # on a machine with network access
export BAREDUCKDB_DUCKDB_LIB=/opt/duckdb # on the target
export BAREDUCKDB_NO_DOWNLOAD=1 # fail instead of downloadingWith BAREDUCKDB_NO_DOWNLOAD set and nothing found, import raises
DuckDBLibraryNotFoundError, listing every path it tried.
bareduckdb.connect(database=None, config=None, read_only=False, *, parse_cache_size=128)
opens a connection; None is an in-memory database. config takes DuckDB settings, for
example {"threads": "4", "memory_limit": "1GB"}. The connection is a context manager that
closes on exit.
| Method | Effect |
|---|---|
execute(sql, parameters=None, *, data=None) |
Runs a statement and returns the connection, whose result methods read that statement's result. data names objects for this call only. |
register(name, obj) |
Names an object until unregister() or close. Returns the connection. |
unregister(name) |
Removes a name. An unknown name is a no-op. Returns the connection. |
cursor() |
A new connection to the same database, with its own result state. |
interrupt() |
Cancels the running query from any thread; it raises QueryCancelled. |
close() |
Closes the connection. |
Each connection keeps the last parse_cache_size parsed single-statement SELECTs, keyed by
SQL text, so a repeated query skips the parse. Bind and execute still run every time, and any
other statement clears the cache. 0 turns it off.
These are called on the connection after execute(), as in conn.execute(sql).pl().
| Method | Returns | Needs |
|---|---|---|
fetchone(), fetchmany(n), fetchall() |
tuples | nothing |
pl() |
pl.DataFrame |
polars |
pl_lazy() |
pl.LazyFrame over the stream |
polars and pyarrow |
arrow_table() |
pa.Table |
pyarrow |
arrow_reader(batch_size=None) |
pa.RecordBatchReader |
pyarrow |
df(arrow_dtyped=True) |
pd.DataFrame |
pandas and pyarrow |
description, columns, rowcount |
metadata | nothing |
A result is consumed once. rowcount is the changed-row count for INSERT, UPDATE and DELETE,
and -1 otherwise.
register() and data= accept polars DataFrames and LazyFrames, pyarrow Tables, Datasets,
Scanners and RecordBatchReaders, pandas DataFrames, and any object with __arrow_c_stream__.
- Arrow and polars data is read in place. Columns the query does not name are never converted.
- pandas frames are converted with
pyarrow.Table.from_pandas, which needs pyarrow and copies numpy-backed columns. - A
pa.RecordBatchReaderis read fully at registration. - A registration is re-read on every query, so a
pl.LazyFramere-runs its pipeline each time. For a frame that fits in memory and is queried repeatedly,.collect()it first. - Filters are not pushed into the source. A
pl.LazyFrameis asked for every row and DuckDB applies theWHERE. - The planner gets a row count only for a source with a
len(): apa.Table,pl.DataFrameor pandas frame. Without one, DuckDB can pick a full sort over a top-N for a largeORDER BY ... LIMIT. - Insertion order is preserved by default, as in DuckDB, and scans stay parallel.
SET preserve_insertion_order=falseopts into the unordered, lower-memory sink.
| Variable | When | Effect |
|---|---|---|
BAREDUCKDB_DUCKDB_LIB |
import | Path to libduckdb, or to a directory holding it. Checked first. |
BAREDUCKDB_NO_DOWNLOAD |
import | Raise instead of downloading libduckdb. |
BAREDUCKDB_LOG_LEVEL |
import | Configures logging at this level, for example DEBUG. |
BAREDUCKDB_COLLECT_LAZYFRAME |
query | 1 collects a pl.LazyFrame instead of streaming it. A measurement toggle, off by default. |
BAREDUCKDB_DUCKDB_DIR |
build | Use a local libduckdb directory instead of downloading one. |
BAREDUCKDB_DUCKDB_CHANNEL |
build | stable (default) or preview, DuckDB's rolling preview build. |
BAREDUCKDB_DUCKDB_VERSION |
build | Stable release tag to fetch. Empty uses the pinned release. |
BAREDUCKDB_DUCKDB_ARTIFACT |
build | Override the platform artifact name, for example linux-arm64-musl. |
| Linux glibc (manylinux_2_28) | Linux musl (musllinux_1_2) | macOS | Windows | |
|---|---|---|---|---|
| x86_64 | yes | yes | no | yes |
| arm64 | yes | yes | yes | no |
One cp312-abi3 limited-API wheel covers GIL builds of CPython 3.12 and newer. cp314t and
cp315t wheels cover free-threaded CPython. GraalPy 25.3 (Python 3.13) is built for Linux
x86_64 only; see README_GRAALPY.md. PyPy is not supported.
Every CPython wheel runs the core test suite before upload, and the GraalPy wheel runs a
subset. For thread safety, the suite also runs under pytest-run-parallel on 3.14 and 3.15t
on Linux, macOS and Windows, and TSAN and ASAN builds run nightly on free-threaded 3.14.
The wheel does not contain libduckdb. On first import, bareduckdb looks for it in this order:
BAREDUCKDB_DUCKDB_LIB, a path to the library file or to a directory holding it.bareduckdb/_libs/inside the package, which only a source build fills.- The user cache:
~/.cache/bareduckdb($XDG_CACHE_HOME),~/Library/Caches/bareduckdb, or%LOCALAPPDATA%\bareduckdb\Cache. - A download of the pinned release, currently
v2.0.0, frominstall.duckdb.org/v2.0.0/duckdb-shared-libs-<platform>.tar.gz, into that cache.
The first import therefore needs network access, once per user and DuckDB version. The download is checked for CPU architecture but not against a checksum or signature. It relies on HTTPS.
SELECT * FROM pragma_version() reports DuckDB's own version and source id. Extensions load
from DuckDB's extension directory (~/.duckdb/extensions/v2.0.0/...). duckdb and
bareduckdb can be imported in the same process.
Defaults follow DuckDB, with these differences.
execute()does not run a SELECT. The firstfetchall(),df(),pl()or similar runs it. A SELECT you never read never runs.- A SELECT is checked when you read it, not at
execute(). An error such as an unknown column appears at the first read. A wrong number of parameters still fails atexecute(). - Reading
descriptionbefore any other read runs a SELECT as rows. A laterpl()orarrow_table()then builds the table from those rows. - A result can be read once, by one method. Calling
pl()and thenfetchall()raises, and the error names the first method.arrow_table()anddf()are the exceptions: you can still fetch rows after them. - After an Arrow read,
descriptionderives the types from the Arrow schema. Eight types get a different name: HUGEINT, UHUGEINT, TIMETZ, UUID, BIT, JSON, ENUM and TUPLE. WithSET arrow_lossless_conversion = true, only ENUM and TUPLE differ. - Parameters are typed from the Python value. An int that fits in 32 bits is BIGINT, not INTEGER. An int of
2^63 or more is HUGEINT, not UBIGINT. A dict is a MAP, not a STRUCT, so it cannot be inserted
into a STRUCT column. A nested list that mixes types, such as
[[1], ['a']], raises. - A registered name can appear once per query. A self-join, or a
UNION ALLof the name with itself, raises. See "Use a registration twice in one query". - Registered names do not appear in
SHOW TABLES. A real table with the same name wins. - A SELECT whose CTE writes data keeps the write only if you read every row.
df()returns pandas ArrowDtype columns, not NumPy columns. Usedf(arrow_dtyped=False)for NumPy.- VARINT values are
int, notstr. - TIMESTAMPTZ values have a different
tzinfo. They compare equal. fetchall()cannot read UNION, VARIANT or GEOMETRY columns. Read them as Arrow. See "DuckDB C API 2.0 limitations".- PRAGMA, CALL and
RETURNINGstatements run atexecute(). Reading them as Arrow (df(),pl(),arrow_table()) builds the table from the fetched rows, which needs pyarrow and is slower. Some values cannot be rebuilt exactly, such as INTERVAL, BIT, nanosecond timestamps, infinite dates and the time 24:00:00. Those raise, andfetchall()still works. To get Arrow for them, passoutput_type="arrow_table"toexecute().
executemanyand the module-level functions (bareduckdb.sql,read_csv, ...). These raiseNotImplementedError.- Python table functions and replacement scans.
register_udtf(), a non-emptyconnect(udtf_functions=...)andconnect(enable_replacement_scan=True)raiseNotImplementedError. Useregister()instead. - The relation API. Use SQL, or a frontend such as ibis or narwhals.
- Python scalar UDFs and fsspec filesystems.
- Filter pushdown into registered sources.
- Some Arrow tables built from rows (see the PRAGMA bullet above). An ENUM column's dictionary
holds only the values present. TIMETZ with
arrow_lossless_conversionand a MAP with a nested key raise. Memory use is about 2.7 times DuckDB's.
- The v2 C API is unstable in DuckDB 2.0.0.
- Rows cannot be converted to Arrow. PRAGMA, CALL and
RETURNINGmust go through Python -> Arrow. (#25801 removed the old one). - An Arrow result has no DuckDB types.
descriptionafter an Arrow read is derived from the Arrow schema. See "Differences from duckdb-python". - VARIANT, UNION and GEOMETRY values cannot be read as rows.
fetchall()raises on them. See #27570. - A PRAGMA or CALL that returns VARIANT raises on every read, including
PRAGMA storage_info. Use the workarounds below. - Some VARIANT contents cannot be exported to Arrow: INTERVAL, TIMETZ, BIT, TIMESTAMP_S, TIMESTAMP_MS, and integers larger than int64.
- v2 C API does not have support appending Use
register()withINSERT ... SELECT, orINSERTwith parameters.
Workarounds:
conn.execute("select * from pragma_storage_info('t')").arrow_table() # PRAGMA as a SELECT
conn.execute("select * from duckdb_settings()").df() # CALL as a SELECT
conn.execute("pragma storage_info('t')", output_type="arrow_table") # Arrow from the startA SELECT waits for its first consumer. DuckDB fixes a result's format, rows or Arrow, when
the query is submitted, and offers no conversion afterwards. Submitting at execute() would
mean guessing the format, and running the query twice whenever the guess was wrong.
A registration is scanned once per query. The scan converts one Arrow batch at a time per worker and discards each converted chunk as soon as it is emitted. Peak memory for a scan stays at about one chunk per thread whatever the source's size, and there is nothing left over for a second scan of the same name to replay. A materialized CTE makes that copy explicitly, for one query.
libduckdb is downloaded, not bundled. Linking DuckDB's own release build keeps the wheels small and the engine identical to upstream's. Moving to a new DuckDB release is a re-pin, not a rebuild of DuckDB.
Some features wait on the C API. The v2 C API has no appender yet, and its parse_sql
exposes no table references or function-call sites, so Python table functions and
replacement scans cannot be discovered from SQL. Filter pushdown, the appender and table
functions existed in bareduckdb 0.12 on the 1.x API and need porting. Scalar UDFs and
filesystems need DuckDB threads to call back into Python, which needs design work first.
Selective filters are slower than in duckdb-python. Without filter pushdown, every row of the named columns is converted before DuckDB filters it. Filtering in the source wins below roughly 20% selectivity and loses above it.
uv pip install -e . # scikit-build-core + CMake; fetches libduckdb on first build
uv run pytest tests/capi -o addopts= -q # fast subset
uv run pre-commit run --all-filesA source build compiles five Cython modules and downloads libduckdb at build time, unless
BAREDUCKDB_DUCKDB_DIR points at a local copy. Windows needs MSVC. Editing a .pyx or
.pxd requires a rebuild. polars and vortex-data are in the dev-gil group, which is not
installed by default. On a GIL build, add them with
uv pip install --group dev-gil --only-binary :all:.
The binding uses only released, non-deprecated duckdb_v2_* functions. Upstream marks all of
them unstable: v2.0.0, so a signature can change between releases.
tests/capi/pxd_prototypes.txt pins the header prototype of every function the binding
declares, and a re-pin that changes one fails until the snapshot is regenerated.
libduckdb is loaded with RTLD_LOCAL, which keeps its symbols apart from the official
duckdb wheel's statically linked copy when both are imported. pyarrow's from_batches
reader has a no-op close(), which is why reader.close() does not release a query.
Not affiliated with DuckDB Labs or the DuckDB Foundation.
MIT. See LICENSE.