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
Greg Stein removed the GIL from Python 1.4 with fine-grained locks.
✗ ~2× slower single-threaded — shelved. (PyThreadState survived!)
Adam Olsen: objects owned per-thread, restricted sharing between threads.
✗ Too restrictive — faded away.
Larry Hastings, fine-grained locking again. Refcounts thrashed CPU caches.
✗ Single-thread perf collapsed — abandoned.
Sam Gross: biased refcounting, immortal objects, mimalloc.
✓ Single-thread cost finally small → became PEP 703.
The GIL becomes optional. Experimental build, ~40% single-thread penalty.
Officially supported. Specializing interpreter enabled; penalty down to ~5–10%.
A separate binary: python3.14t. Wheels get a t suffix: cp314t.
$ python3.14t -c "import sys; print(sys._is_gil_enabled())"
False # threads now run Python bytecode in parallel
Single-core speed plateaued years ago — machines got wider instead. Free-threading lets one Python process use the width.
Parallel tool calls & subagents in one process — threads share models, caches, vector indexes. No pickle, no IPC.
Responsive UI thread + truly parallel background work. No multiprocessing spawn/pickle/packaging pain.
Thread-per-request finally uses all cores. One copy of the model — not 16 workers × 16 copies.
$ 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.
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 C++ engine releases the GIL and scales fine — but every stream queues on it to touch anything 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);
}
One line — and a promise you now have to keep. Without it, importing your module quietly re-enables the GIL for everyone.
// chDB: gate it behind the build flag
#ifdef CHDB_FREE_THREADING
PYBIND11_MODULE(_chdb, m, py::mod_gil_not_used())
#else
PYBIND11_MODULE(_chdb, m)
#endif
// raw C API equivalent (multi-phase init):
static PyModuleDef_Slot slots[] = {
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
{0, NULL},
};
First NULL in a string column → scan thread lazily resolves pandas.NaT… with no Python thread state.
Segfault inside _PyObject_Malloc on CPython 3.12+ — deep in the allocator, nowhere near the actual bug.
The fatal-signal handler then deadlocks. No traceback, no exit. The query just hangs, forever.
/// The fix — in prepareColumnCache(), while still holding the GIL:
/// pre-resolve everything GIL-free scan threads will compare against.
import_cache.pandas.NaT();
import_cache.pandas.NA();
Analytics = millions of strings crossing the C++/Python border per query. Our regular wheel compiles with Py_LIMITED_API (one abi3 wheel, cp39+) — which hides the fast Unicode internals.
// Stable-ABI build: hot loop calls into a shim .so
// (libpybind11nonlimitedapi_chdb_3.X.so)
pybind11::non_limited_api::getPyUnicodeUtf8(
obj, data, length, kind, ...);
// one non-inlinable call per string value
The free-threaded build can't use the Limited API anyway — so it drops to direct CPython Unicode access:
#ifdef CHDB_FREE_THREADING // full C API available
if (PyUnicode_IS_COMPACT_ASCII(obj)) {
data = (const char *) PyUnicode_1BYTE_DATA(obj); // direct pointer, no copy
len = PyUnicode_GET_LENGTH(obj);
}
else if (unicode->utf8) // CPython's cached UTF-8
{ data = unicode->utf8; len = unicode->utf8_length; }
else if (PyUnicode_IS_COMPACT(obj))
kind = PyUnicode_KIND(obj); // 1/2/4-byte units -> utf8proc
else {
py::gil_scoped_acquire acquire; // rare legacy strings only
data = PyUnicode_AsUTF8AndSize(obj, &len);
}
#endif
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×
Striped bar = placeholder — final scaling curve to be measured on the 16-core box before the talk.
The declaration is one line; the promise covers every global you own.
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!