Porting a C++ Python extension to free-threaded Python 3.14
Auxten Wang · Technical Director @ ClickHouse · Creator of chDB
@auxten · auxten.com
A parallel engine, one lane into Python.
chDB is the ClickHouse engine as a pip install, running inside your Python process.
pip install chdb
import chdb
chdb.query(
"SELECT count() FROM file('hits.parquet')")
=
SELECT … FROM Python(df) queries a live DataFrame where it sits. No export, no copy.
Your Python function, callable from SQL, row by row. Keep this one in mind too.
chDB (before this port)e.g. a user-defined function in a database
e.g. an object-dtype Pandas column read from C++
What finally changed, and the two steps we took with it.
Owner counts without atomics.
Never refcounted at all.
Stack refs skipped; GC checks.
One tiny lock per container.
Result: ~1–8% single-thread cost. Guido's rule, met.
PYBIND11_MODULE(_chdb, m, py::mod_gil_not_used()) // pybind11 ≥ 2.13
Same code. py::gil_scoped_acquire on every worker thread: on stock Python one shared lock, on 3.14t per-thread bookkeeping.
if (PyUnicode_IS_COMPACT_ASCII(obj)) { // logs, ids, keys
data = PyUnicode_1BYTE_DATA(obj); // pointer, no copy
len = PyUnicode_GET_LENGTH(obj);
} else data = PyUnicode_AsUTF8AndSize(obj, &len); // everything else
5M object-dtype strings, one PyObject per cell · 16 engine threads · CPython 3.14.6 vs 3.14.6t
Background: since Pandas 3.0 the default string dtype is Arrow-backed when PyArrow is installed (PDEP-14). Other dtypes are still NumPy; the move to Arrow is not finished.
Works on stock Python today.
Pandas 3.0 with PyArrow gives you Arrow-backed string columns by default. Other dtypes are still NumPy.
import chdb.datastore as pd
df = pd.read_parquet("events.parquet")
top = (df[df.status == 200]
.groupby("user_id").amount.sum()
.sort_values(ascending=False)
.head(10))
SELECT user_id, sum(amount) AS amount
FROM file('events.parquet')
WHERE status = 200
GROUP BY user_id
ORDER BY amount DESC
LIMIT 10
Ten chained ops → one plan. No intermediate copies.
Pandas computes on one core. The engine fills them all.
Top-10 of 10M rows keeps a 10-row heap, never the sorted table.
Below ~1M rows, keep Pandas.
With the GIL off, .map(python_fn) inside that chain runs as an in-process UDF on every engine thread.
Build tools ready: Cython 3.1, pybind11 3, nanobind 2, cibuildwheel (cpython-freethreading). No stable ABI on 3.14t: every extension needs its own cp314t wheel.
$ python3.14t -c "import yourstack"
RuntimeWarning: The global interpreter lock (GIL) has been enabled
to load module 'X', which has not declared that it can run safely
without the GIL.
3.14t is an official build with the same release cadence. You install it on purpose.
The warning names the package. PYTHON_GIL=0 forces the GIL off and silences it: check first, force second.
Free-threading is a per-process property decided by the weakest dependency. One issue on that package unblocks everyone above it.
The t is for threads.
uv python install 3.14t && uv venv -p 3.14t
False = GIL off. A warning names the package that turned it back on.
python -c "import sys, yourstack; print(sys._is_gil_enabled())"
pip install chdb. Time it on both builds.
@chdb.func([INT64], INT64)
def slow(x): return sum(range(x % 500))
chdb.query("SELECT sum(slow(number)) FROM numbers_mt(2e6) "
"SETTINGS max_threads=8")
3.14t removes it. The bugs it was hiding were always ours.
Run your stack on 3.14t. File the issue. Send the PR.
github.com/chdb-io/chdb · auxten.com · @auxten
py-free-threading.github.io
Dank jullie wel, Amsterdam.
slides + speaker notes