Skip to main content

gpudb — plain DuckDB SQL on the GPU

New in v0.7 — plain DuckDB SQL runs on the GPU. No gpu_* calls, no query changes: write the SQL you already write, and the GPU answers it when that is measured faster — DuckDB answers everything else, with the same rows either way. Apple Silicon Metal and NVIDIA CUDA, from one extension.

Ordinary DuckDB SQL, unchanged: statements the GPU answers faster are answered on the device, everything else runs on DuckDB exactly as before, and the rows, names and types are identical either way.

Install

pip install duckdb-gpudb        # the distribution is duckdb-gpudb; the import is gpudb

There are two pieces: this package is the wrapper — the gpudb command and gpudb.connect() — and the GPU code itself is a DuckDB extension. On Apple Silicon (macOS 15 or later) and x86-64 Linux (glibc 2.34 or newer, e.g. Ubuntu 22.04 and later) the wheel carries both: the v0.7.0 extension binary travels inside the package, so that one line is the whole install. No INSTALL, no build, no environment variable. One binary, and it has been run under both DuckDB 1.4.5 and 1.5.5. The Linux wheel carries the CUDA-enabled build and its own libgomp.so.1, so it needs nothing installed and reaches an NVIDIA GPU with a driver R525 or newer, falling back to the CPU backend where there is none.

Anywhere else pip installs the pure-Python wheel and the extension comes from DuckDB's own install:

INSTALL gpudb FROM community;   -- in any DuckDB >= 1.5.5 client
LOAD gpudb;

The wrapper looks for the extension in this order: an explicit extension= path, the GPUDB_EXTENSION_PATH environment variable, a build-macos/ or build-linux/ directory next to a source checkout, the copy bundled in this package, and finally the extension DuckDB itself has installed. A checkout's own build comes before the bundled copy deliberately — someone who has just built the extension is testing that binary. If it finds none — or finds one older than this client — con.extension_note says so in one sentence and every statement runs on DuckDB.

Requires Python >= 3.9 and the duckdb module >= 1.4. A bundled or downloaded binary needs only DuckDB >= 1.2, because the loadable extension is built against the stable C API v1.2.0; the registry builds gpudb separately for each DuckDB version from 1.5.5 on. Apple silicon for the Metal backend, an NVIDIA GPU for the CUDA one; plain SQL runs on the GPU by default on both, and GPUDB_CUDA_EXACT=0 turns the CUDA path off without a rebuild. On Linux the extension needs libgomp.so.1 at load time (apt install libgomp1), which the wheel bundles and a registry install does not; a binary installed from the community registry there reports compiled=cpu and carries no CUDA at all — SELECT gpu_build_info(); says which one you have.

The gpudb shell

The first way in, and the one that shows its work. This is one session on an M4 Max over TPC-H SF1, opened read-only, on the v0.7.0 release build of 2026-09-20 (the three [gpudb] registered … lines are the extension announcing itself on stderr as DuckDB loads it):

$ gpudb data/tpch_sf1/tpch.duckdb --readonly
[gpudb] registered gpu_inner_join + gpu_join_rows_resident
[gpudb] registered gpu_groupby_{sum,sum_f64,count,exact}_resident[_having|_topk] + gpu_topk_resident[_f64]
[gpudb] registered gpu_sum / gpu_min / gpu_max streaming aggregates + resident-column functions (gpu_upload, gpu_*_resident) (backend=Metal)
gpudb 0.7.0
backend:       Metal · Apple M4 Max · 51.8 GiB device memory
transparent:   available — every statement goes through the wrapper
database:      data/tpch_sf1/tpch.duckdb
Enter .help for usage.

gpudb> SELECT l_partkey, sum(l_quantity) AS qty FROM lineitem GROUP BY l_partkey ORDER BY qty DESC LIMIT 5;
┌───────────┬───────────────┐
│ l_partkey │      qty      │
│   int64   │ decimal(38,2) │
├───────────┼───────────────┤
│    125009 │       1642.00 │
│    140633 │       1562.00 │
│     49981 │       1553.00 │
│    149443 │       1537.00 │
│     10426 │       1513.00 │
└───────────┴───────────────┘

DuckDB (not_resident: the resident set is not ready yet) · 25.7 ms

The first ask is on DuckDB on purpose: the columns are uploaded in short row-id segments taken only while the connection is idle, so an upload never runs a long scan beside a query you are waiting for. Ten seconds later, the same statement, nine runs each way in the same session:

gpudb> .gpu off
GPU path off — statements go straight to DuckDB.
… DuckDB · 30.4, 24.5, 16.5, 19.5, 20.9, 22.3, 21.6, 22.1, 23.0 ms
gpudb> .gpu on
GPU path on — residency: background.
… GPU (topk: the resident GROUP BY) · 12.9, 47.3, 10.0, 11.4, 14.1, 14.4, 14.4, 14.5, 14.8 ms

Median 22.1 ms against 14.4 ms — 1.53×, with both series printed whole so the warm-up runs and the wrapper's own measuring run stay visible. The nine GPU runs spread from 10.0 to 14.8 ms with nothing changed between them: a statement this short has more than one speed on Apple silicon depending on what else is waking, which is why the wrapper measures in your process instead of trusting a published ratio. Expect your own numbers rather than these.

The banner's backend: line names the runtime, the device as the driver reports it, and the device memory the budget plans against; a build without a GPU backend names no device, and transparent: says what is missing instead.

Options and dot-commands

gpudb my.duckdb -c "SELECT …"     # one statement
gpudb -f script.sql               # a file (or: gpudb < script.sql)
python -m gpudb                   # the same entry point
Option
-c SQL run a statement and exit, repeatable
-f FILE, --file FILE run a file and exit, repeatable; -c and -f run in the order given
--readonly, --read-only open read-only
--no-gpu every statement on DuckDB
--residency background|eager|manual when tables become resident (default background)
--memory-budget SIZE e.g. 16GB, or unlimited
--timer / --no-timer force the footer line on or off
--debug show tracebacks instead of one-line errors
--version the gpudb and duckdb versions
-h, --help the same list, from the program

A statement that fails ends a -c / -f / piped run with a non-zero exit code; at the terminal the session keeps going. The footer is on at a terminal and off in scripted output unless --timer says otherwise; colour follows NO_COLOR and whether the output is a terminal.

.help the list
.quit, .exit leave (Ctrl-D does too)
.timer on|off the footer line
.gpu the last statement's whole last_rewrite(), detail included
.gpu on|off the transparent path, live
.residents the resident sets, then the columns behind them with rows and width
.memory the device-memory budget and what holds it
.read FILE, .open [DATABASE] run a file, open another database
.tables, .schema [TABLE] plain SQL underneath (SHOW TABLES, DESCRIBE)
.version gpudb and duckdb versions

Statements may span lines and end at ;; Ctrl-C stops the running statement (or clears what you were typing) and Ctrl-D leaves. History is kept in ~/.gpudb_history when the Python build has readline.

.residents prints two tables: the resident SETS — what a statement is waiting on — and, under them, the COLUMNS those sets are views over. A lane is kept at the narrowest signed width its values fit, so a column of small integers costs one or two bytes a row rather than eight. Same session as above:

gpudb> .residents
table          columns               state  bytes     estimated  worth
main.lineitem  l_partkey,l_quantity  ready  80.1 MiB  161.7 MiB  3.99
1 set · 80.1 MiB held · worth is ms saved per second per GiB · `.memory` for the budget

table     column      dtype  rows       width  bytes     state
lineitem  l_partkey   I64    6,001,215  4 B    68.7 MiB  ready
lineitem  l_quantity  I64    6,001,215  2 B    11.4 MiB  preparing
2 resident columns · width is the bytes a row of the lane is stored at · `-` where the backend does not say

worth is the DuckDB time a set saves per second of wall time, per GiB it holds — what the memory budget compares when it has to choose. On a column line, state means something narrower: whether that lane has a sort cache. Only a key lane ever needs one, so a payload lane reads preparing and stays there; the set's own state is what a statement waits on. Every dot-command and option, and the session these came from, are in the shell guide.

There is a shell because the transparent path cannot live in the extension. DuckDB's stable C extension API — the one the loadable extension uses on purpose, so that one binary keeps working across DuckDB versions — has no hook that sees a statement before it is planned. LOAD gpudb in the stock duckdb CLI therefore gives the explicit gpu_* functions and nothing more. This shell is a client that can look first: it splits statements with DuckDB's own tokenizer, hands each one to gpudb.connect(), and prints the result with DuckDB's own box renderer.

Python

import gpudb
con = gpudb.connect("my.duckdb")          # same surface as duckdb.connect
con.execute("SELECT k, sum(v) FROM t GROUP BY k").fetchall()
con.last_rewrite()                        # what happened to that statement

connect() takes database, read_only and config as duckdb.connect does, plus extension= (an explicit path to the .duckdb_extension), transparent= (default True), residency= (background | eager | manual), floor_rows= (default 1,000,000), idle_ms= (default 20.0), thresholds= (default True; False rewrites every exact shape regardless of the predicted win — for parity testing only), memory_budget= (bytes or "16GB"; 0 / "unlimited" removes the cap) and log= (a callable that receives the wrapper's decisions as text).

Anything the wrapper does not define itself is delegated to the underlying duckdb.DuckDBPyConnection. What it does define:

con.last_rewrite() rewritten, reason, detail, form, tag, sql, statement, engine, round_trip_ms, fallback, error
con.memory() budget, evictions, evictions_wasted, sets
con.residents() {identity tag: state} — missing, pending, uploading, ready, stale, failed
con.store_columns() one dict per resident column: table, column, dtype, rows, width, bytes, prepared
con.transparent readable and settable; False leaves every statement on DuckDB without dropping anything
con.residency read-only: the mode passed to connect()
con.cursor() / con.duplicate() another connection sharing the resident sets, with its own last_rewrite()
con.interrupt() DuckDB's interrupt, on this connection's handle only
con.extension_note empty while the loaded extension can serve the client; one sentence saying why not otherwise

Give each thread its own con.cursor(): last_rewrite() is one record per connection object, so two threads sharing one connection overwrite each other's.

Why a statement did not go to the GPU

The footer and last_rewrite()["reason"] carry a short code; detail is the sentence behind it.

Reason What it means
not_resident the columns are not on the device yet — the background uploader is working on it
threshold a measured bound says DuckDB is faster for this shape and size, or this machine measured it slower and the template went back to DuckDB
shape not a shape the rewrite expresses: a window function, median, ROLLUP, a set operation, a subquery in the select list
double a sum / avg over DOUBLE or FLOAT — never rewritten, because native's own answer depends on the order the values are added
ties a pushed ORDER BY … LIMIT k found two of the first k rows equal on the ordering value — which rows come back, and in what order, is DuckDB's to choose, and it answered the original. Decided against the data on every execution; a tie that keeps happening shows as threshold with the tie named in detail, until the 60-second re-measure. Through sql() — and so through the shell — the check runs on a side cursor inside the call, so the first statement of a data version asks the device once more and the verdict is remembered until the data changes
backend this build has no GPU backend to rewrite for, or the installed extension is older than this client
memory the set does not fit the device-memory budget; it is refused before the upload
transaction a BEGIN is open, so the resident sets cannot be trusted
params the statement takes prepared-statement parameters
multi more than one statement in the call
too_long the statement is longer than the 16 KB the wrapper parses
error the rewritten statement raised and DuckDB answered the original — the text is in error
off / manual the path is off (.gpu off, --no-gpu, transparent=False), or residency is manual and this set was not uploaded by hand
nulls overflow decimal collation view temp ambiguous not_found a narrower refusal, each with its own sentence in detail

What those look like in the shell's footer — these are the shapes, collected from different statements and different sessions, not one run:

GPU (plain: the resident GROUP BY) · 3.3 ms
GPU (topk: a key join materialised on the device) · 8.1 ms
DuckDB (threshold: 7 groups < 1000) · 5.0 ms
DuckDB (threshold: measured 4.20 ms rewritten vs 3.10 ms native (re-measured in 60 s)) · 3.2 ms
DuckDB (not_resident: the resident set is not ready yet) · 12.0 ms
DuckDB (ties: two of the first 5 rows tie on qty, so which rows come back — and in what order — is DuckDB's to choose, and DuckDB answered the original) · 49.9 ms
DuckDB (off: the transparent path is off on this connection) · 12.0 ms

The third way in: explicit gpu_* functions

The shell and gpudb.connect() are two of three routes. The third needs no wrapper at all: INSTALL gpudb FROM community; LOAD gpudb; in any DuckDB client, in any language, and then call the functions by name — gpu_upload / gpu_upload_pair to make a column resident once, then gpu_sum_resident, gpu_groupby_sum_resident (with _having and _topk forms that filter on the device), gpu_topk_resident, gpu_inner_join and the exact gpu_groupby_exact_* family to read it back. gpu_last_stats() says which processor ran and for how long. v0.7 registers 65 of these; v0.6.0 registered 38, and every one of those is still there unchanged.

It is full manual control, and it is the only route from the stock duckdb CLI. The repository's README documents the whole surface.

Going deeper

Metadata

Release files for duckdb-gpudb 0.7.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for duckdb-gpudb 0.7.1
File Size Uploaded Provenance Actions
duckdb_gpudb-0.7.1.tar.gz 253.2 kB Without provenance Details for duckdb_gpudb-0.7.1.tar.gz

Built distributions (wheels)

Table of built distributions (wheels) for duckdb-gpudb 0.7.1
File Interpreter ABI Platform Actions
duckdb_gpudb-0.7.1-py3-none-manylinux_2_34_x86_64.whl Python 3 none Linux glibc 2.34+ x86-64 Details for duckdb_gpudb-0.7.1-py3-none-manylinux_2_34_x86_64.whl
duckdb_gpudb-0.7.1-py3-none-macosx_15_0_arm64.whl Python 3 none macOS 15.0+ ARM64 Details for duckdb_gpudb-0.7.1-py3-none-macosx_15_0_arm64.whl
duckdb_gpudb-0.7.1-py3-none-any.whl Python 3 none any Details for duckdb_gpudb-0.7.1-py3-none-any.whl

Total release size: 9.3 MB

Release files / duckdb_gpudb-0.7.1.tar.gz

Download URL duckdb_gpudb-0.7.1.tar.gz
Size 253.2 kB
Tags Source
SHA-256 checksum
How to use checksums
c0f6c1a20c576323010f42fda173cf2e0d6d4d3aec40a0f0887688e1957b40b2
BLAKE2b-256 checksum
How to use checksums
91428d786bf1df5b4b4c52ca2d2d6c10aa52a4999f9a0fa61a94c0c7b01876a0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.9

Release files / duckdb_gpudb-0.7.1-py3-none-manylinux_2_34_x86_64.whl

Download URL duckdb_gpudb-0.7.1-py3-none-manylinux_2_34_x86_64.whl
Size 8.1 MB
Tags Linux glibc 2.34+ x86-64 Python 3
SHA-256 checksum
How to use checksums
dee7b8c434cf428b4df26e13d6e5d9bde4c7a7ad175449d55caf8221bd2593b3
BLAKE2b-256 checksum
How to use checksums
aada63bca15a83ef5a7ea8af79a729cc334248a125e65c63df1071f7a19d366f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.9

Release files / duckdb_gpudb-0.7.1-py3-none-macosx_15_0_arm64.whl

Download URL duckdb_gpudb-0.7.1-py3-none-macosx_15_0_arm64.whl
Size 782.1 kB
Tags Python 3 macOS 15.0+ ARM64
SHA-256 checksum
How to use checksums
48e216da65aff63853b741beb6729f0b29b87c1a7745b1ef4f90825f2f966d32
BLAKE2b-256 checksum
How to use checksums
2adf61bd5e6bec6f7faa44e7a11ad204b77f296619871297b34626e7e3b8bd7e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.9

Release files / duckdb_gpudb-0.7.1-py3-none-any.whl

Download URL duckdb_gpudb-0.7.1-py3-none-any.whl
Size 179.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e0fd26ae27474173afc89409749837a91101892f15075129564d4079095676c8
BLAKE2b-256 checksum
How to use checksums
351386ec132b3c867ca3336adb751bf34da2587f027d11b706d78b680ff343b0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.9

Security for duckdb-gpudb 0.7.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.7.1 This release

4 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page