Porting a C++ Python extension to free-threaded Python 3.14.
Auxten Wang · Technical Director @ ClickHouse · Creator of chDB
PyData PyCon Yerevan · 24 Jul 2026
@auxten · auxten.com
One big lock → a small lock on every mutable structure. Python 1.4.
One survivor: PyThreadState — still in CPython today.
=== comp.lang.python · 1996 ===
From: Greg Stein
Subject: free threading patches
for Python 1.4
A patch set that removes the
global interpreter lock and
guards dicts, lists, modules…
with fine-grained locks.
> benchmark: ~2x slowdown_
Sidestep the locks: every object is owned by one thread. Sharing only through monitors.
Make every refcount atomic. Now each Py_INCREF is a fight between CPU cores.
18.9× slower on 7 cores. Abandoned.
Larry Hastings · PyCon 2016, Portland
Don't make refcounts atomic — redesign them so the common case never leaves the thread.
Sam Gross · author of the nogil fork & PEP 703
GIL becomes optional. Experimental, ~40% single-thread penalty.
Officially supported. Specializing interpreter on; penalty ~5–10%.
Separate binary python3.14t; wheels get a cp314t suffix.
$ python3.14t -c "import sys; print(sys._is_gil_enabled())"
False # threads now run Python bytecode in parallel
Single-core speed plateaued; machines got wider. Free-threading lets one process use the width.
Parallel subagents in one process — shared models. No pickle, no IPC.
Responsive UI + truly parallel background work.
One copy of the model — not 16 workers × 16 copies.
NumPy & SciPy: Py_BEGIN_ALLOW_THREADS around every kernel.
TensorFlow & PyTorch: C++ thread pools, CUDA streams — the graph runs outside Python.
multiprocessing, joblib, DataLoader workers — every value crosses by copy.
with nogil: # Cython
crunch(buf)
@njit(nogil=True) # Numba
def crunch(buf): ...
Leave the interpreter entirely — then threads are free.
$ python3.14t -c "import _chdb"
RuntimeWarning: The global interpreter lock (GIL) has been enabled to load
module '_chdb', which has not declared that it can run safely without the GIL.
Two counters. The hot one never leaves its core.
The hottest objects opt out of counting entirely.
Mutations lock one object, not the world — deadlock-proof critical sections.
dict/list reads stay wait-free on the hot path.
of the top 360 compiled packages on PyPI now ship free-threaded wheels
219 / 360 · hugovk.dev/free-threaded-wheels · 15 Jul 2026
The scientific stack led the way. Chances are your daily imports already run free-threaded:
The ClickHouse engine, embedded in your Python process — SQLite-simple, built to scan millions of rows.
# pip install chdb
import chdb
chdb.query("SELECT count() FROM file('hits.parquet')")
Parquet, CSV, JSON, Arrow, ORC — read & write, no converters.
Query a live DataFrame in place: SELECT … FROM Python(df)
Your Python functions callable from SQL — keep this one in mind too.
The engine scales fine — every stream just queues on the GIL to touch Python.
Kill the subprocess pipeline first.
Py_mod_gil_not_used — then survive the audit it implies.
On hot paths, the friendly Unicode APIs had to go.
Plus the honest part: what broke, what didn't scale, and where the stable ABI stopped being an option.
The old decorator wrote a script + XML config; the engine forked Python and piped rows through it as text.
C++ holds your py::function and calls it from the engine pipeline. Typed args, zero subprocesses.
import chdb
from chdb.sqltypes import INT64
@chdb.func([INT64, INT64], INT64) # in-process
def add(a, b):
return a + b
chdb.query("SELECT add(x, y) FROM t")
// C++: PythonScalarUDF::executeImpl
// (called from engine worker threads)
py::gil_scoped_acquire acquire; // <-- THE lock
for (size_t row = 0; row < rows; ++row)
{
py::tuple py_args = box_arguments(row);
py::object out = func(*py_args); // your fn
insert_into_column(result, out);
}
4M-row scalar UDF, 16-core x86_64, best of 3 — post-fix numbers, landing in 26.5.1.
PYBIND11_MODULE(_chdb, m, py::mod_gil_not_used()) // that's the whole declaration
The line is easy. It's a claim about every global in your codebase — so the real work was a state audit:
First NULL in a string column → a scan thread lazily imports pandas.NaT… with no Python thread state.
Segfault deep inside the allocator — nowhere near the actual bug.
Then the fatal-signal handler deadlocks. No traceback, no exit. The query just hangs, forever.
Millions of strings cross the C++/Python border per query. Friendly Unicode APIs cost a call per value — so on 3.14t we read CPython's memory directly:
if (PyUnicode_IS_COMPACT_ASCII(obj)) { // most analytical data
data = (const char *) PyUnicode_1BYTE_DATA(obj); // direct pointer, no copy
len = PyUnicode_GET_LENGTH(obj);
}
// ...cached-UTF8 / compact / legacy fallbacks omitted
pandas 3.x str columns are Arrow-backed: plain C buffers, scanned directly on all streams — no GIL at all.
ClickBench, 50M-row hits DataFrame, all queries, pandas 3.x, hot run · 10.9× total · geomean 4.5× · string-heavy up to 64×
The free-threading win, isolated: an object-dtype scan (5M rows) that must read one PyObject per value — exactly where the GIL used to serialize every stream.
chdb-core 26.5.1-rc.2 cp314t wheel, CPython 3.14.6 vs 3.14.6t, 56-vCPU Xeon E5-2690v4, min of 6 runs. Both rc sharp edges are fixed for 26.5.1: the FT UDF collapse past 8 threads (chdb-core#128 — per-row tuples were feeding stop-the-world GC storms) and the cold-start hang (#131). That's what benchmarks are for.
# 1 · install
uv python install 3.14t
uv venv --python 3.14t && uv pip install -r requirements.txt
# 2 · run YOUR tests, unchanged — watch for the GIL warning
python -m pytest
# 3 · trust, but verify
python -c "import sys, yourapp; print(sys._is_gil_enabled())"
Your job isn't porting — it's testing on 3.14t, verifying with sys._is_gil_enabled(), and reporting what breaks.
In-process beats out-of-process; zero-copy beats lock-free; then free-threading multiplies what's left.
3.14t revokes it. The bugs it exposes were already yours.
pip install chdb # try the engine
python3.14t your_tests.py # try the future
github.com/chdb-io/chdb
auxten.com · @auxten
py-free-threading.github.io
PEP 703 · PEP 779 · chDB docs: clickhouse.com/docs/chdb
github.com/chdb-io/chdb · auxten.com
No GIL, real gains — thank you, Yerevan!