PyData Amsterdam 2026 · 11 Sep

No GIL,
Real Gains

Porting a C++ Python extension to free-threaded Python 3.14

Auxten Wang · Technical Director @ ClickHouse · Creator of chDB

GIL 3.14t
About
Auxten Wang

Auxten Wang

@auxten · auxten.com

Technical Director @ ClickHouse
Creator of chDB
20 years of C++ and data infrastructure
Part 1
01

The problem

A parallel engine, one lane into Python.

Part 1 · chDB

Local like SQLite. Analytical like ClickHouse.

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')")
In-process. No server, no ports. Just a module.
Same engine. ClickHouse SQL, JOINs, 70+ formats (Parquet, CSV, JSON, Arrow), S3 or Postgres as tables.
Multi-threaded C++ inside. Remember this one.
+ = in your process
Part 1 · chDB

Two places where the engine touches Python

one Python process ClickHouse engine C++ · many worker threads Pandas DataFrame / Arrow table read in place, zero copy def score(row): ... your Python, called per row SELECT … FROM Python(df) @chdb.func

Zero-copy Pandas / Arrow

SELECT … FROM Python(df) queries a live DataFrame where it sits. No export, no copy.

Python UDFs

Your Python function, callable from SQL, row by row. Keep this one in mind too.

Part 1 · Bottleneck 1

Eight engine threads. One gate into Python. Seven wait.

ClickHouse engine · C++ · 8 worker threads T1 waiting for GIL T2 waiting for GIL T3 waiting for GIL T4 → in Python T5 waiting for GIL T6 waiting for GIL T7 waiting for GIL T8 waiting for GIL one gate CPython interpreter GIL T4 running score(row) holds the lock for the call def score(row): ... # your Python, per row one thread inside · seven engine threads idle C++ scan · all 8 in parallel Python UDF · one thread at a time, seven wait T1 T8 ~0.85 s flat: 1, 8 or 32 threads
Part 1 · Bottleneck 2

A string column is one Python object per cell. Reading a cell takes the lock.

ClickHouse engine · C++ · 8 worker threads T1 waiting for GIL T2 waiting for GIL T3 waiting for GIL T4 → in Python T5 waiting for GIL T6 waiting for GIL T7 waiting for GIL T8 waiting for GIL one gate CPython interpreter GIL T4 reads one cell a lock pass per cell object-dtype column PyObject PyObject PyObject PyObject PyObject PyObject PyObject PyObject one thread reading · the rest idle C++ scan · all 8 in parallel Python string reads · one at a time · more threads did not help T1 T8 0.22 s 16 threads · 5M strings
Part 1 · Workarounds

How Python has lived with the GIL

Strategy
Who does it
What it costs
Release the GIL in C loops
NumPy, SciPy · PyTorch, TensorFlow ops
Ends the moment a Python object is touched; per-element callbacks re-take the lock
Move work to another process
multiprocessing, joblib · Dask, Ray · DataLoader num_workers · Gunicorn workers
Copies across by pickle, a model loaded per worker, startup time
Compile Python away
Cython nogil · Numba nogil=True · Rust via PyO3
Only code you rewrite; cannot call back into Python
Threads only for waiting
asyncio · ThreadPoolExecutor · aiohttp, requests
Zero gain for CPU-bound Python
Fewer, bigger calls
NumPy vectorize · Polars · Pandas vectorized ops, not Series.map
Only when the work vectorizes
Subinterpreters PEP 684
interpreters module, 3.12+
One GIL per interpreter; isolated state, no shared objects
All of the above, from C++
chDBchDB (before this port)
Worked until Python was in the middle of the query: per-row UDFs and PyObject strings.
Release the GIL for the whole query: the C++ engine runs on every core while Python waits (that is why a pure-SQL query is 0.161 s on both builds). Python UDFs ran in a separate process. Strings were read one value at a time under the lock.
Part 1 · Workarounds

Most work has a workaround. Two shapes don't.

Applying a Python function per row

e.g. a user-defined function in a database

Release? The callback is Python.
Compile away? It is the user's function.
Batch? SQL calls it row by row.
Process pool? Copy every block out, load the model per worker.

Processing a PyObject string column

e.g. an object-dtype Pandas column read from C++

Release? One PyObject per cell; every read takes the lock.
Vectorize? The cells are not a buffer, they are objects.
Another process? Pickling five million strings costs more than the scan.
Part 1 · Agents

One agent turn: six Python steps, one lock

LLM turn threads + asyncio call external API read session history load context from files run a tool function embed or rerank write back to memory GIL one step at a time dicts, lists, strings I/O: releases the GILCPU: holds itobjects between steps: lock
Part 1 · Agents

So the GIL shapes how Python builds agents

A process pool per tool — pickle every dict across
Memory in a remote service — a network hop per step
Logic out of Python — Rust or Go sidecars
Part 2
02

What we did

What finally changed, and the two steps we took with it.

Part 2 · What changed

Removing the GIL: 29 years of attempts

Guido van Rossum
Guido's rule (2007): single-threaded code must not get slower.
Greg Stein
1996
Greg Stein
Worked. Single thread 2× slower.
AP
2009
Antoine Pitrou
New GIL: fairer, still there.
LH
2016
Larry Hastings
Gilectomy. Atomic refcounts too slow.
Eric Snow
2017–2023
Eric Snow
Per-interpreter GIL. Not a removal.
Sam Gross
2021–2025
Sam Gross
nogil → PEP 703 → 3.14t
Part 2 · What changed

Why nogil passed: no big idea, four small ones

Biased refcounting

obj owner thread: plain ++ other threads: atomic

Owner counts without atomics.

Immortal objects

None True 42

Never refcounted at all.

Deferred refcounting

stack skipped func GC

Stack refs skipped; GC checks.

Per-object locks

dict read: no lock write: this lock only

One tiny lock per container.

Result: ~1–8% single-thread cost. Guido's rule, met.

Part 2 · Step 1 · Flip the switch

One line, one promise: nothing here needs the GIL

PYBIND11_MODULE(_chdb, m, py::mod_gil_not_used())   // pybind11 ≥ 2.13
_chdb · three places the GIL used to protect Shared state type cache · UDF registry engine singleton real locks Object access borrowed references pointers into containers own what you read Engine threads → Python N worker threads each calls your function attach first, every thread
Part 2 · Step 1 · Object access

Touching an object used to be safe. Not anymore.

Thread A Thread B item = PyList_GetItem(L, 0) borrowed: refcount not taken L.clear() refcount → 0, object freed use(item) → freed memory use-after-free, random crash With the GIL this interleaving was impossible: B could not run while A held the lock. On 3.14t it happens all the time.
PyList_GetItem · borrowed PyList_GetItemRef · strong Whole C-API families changed with it.
Part 2 · Step 1 · Shared state

Shared state: every "safe because GIL" spot got a real lock

type cache was: safe because GIL std::call_once UDF registry was: safe because GIL std::shared_mutex engine singleton was: safe because GIL std::mutex Standard C++. The hard part is finding them.
Part 2 · Step 1 · Engine threads

3.14t: a gate per thread. Eight in Python at once.

ClickHouse engine · C++ · 8 worker threads T1 → in Python T2 → in Python T3 → in Python T4 → in Python T5 → in Python T6 → in Python T7 → in Python T8 → in Python CPython interpreter · 3.14t T1 · score(row) T2 · score(row) T3 · score(row) T4 · score(row) T5 · score(row) T6 · score(row) T7 · score(row) T8 · score(row) attach × 8 no shared lock one thread state each 8 calls at once C++ scan · all 8 in parallel Python UDF · eight at once T1 T8 0.15 s 8 threads · 3.14t · was 0.85 s

Same code. py::gil_scoped_acquire on every worker thread: on stock Python one shared lock, on 3.14t per-thread bookkeeping.

Part 2 · Step 1 · Result

Same UDF, 8 threads: 0.85 s0.15 s

STOCK 3.14 · 8 threads · one lane · ~0.85 s (same at 32) FREE-THREADED 3.14t · 8 threads · a lane per thread · 0.15 s
Part 2 · Step 2 · Strings

Strings: stop asking Python for a copy

str one PyObject per cell BEFORE high-level Unicode API one call + one copy, per value AFTER read compact-ASCII bytes in place no per-value call · no copy immutable bytes: any attached thread may read them · non-ASCII falls back to PyUnicode_AsUTF8AndSize engine ColumnString
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
Part 2 · Step 2 · Result

Same scan, same 16 threads: 2.6× faster

5M object-dtype strings, one PyObject per cell · 16 engine threads · CPython 3.14.6 vs 3.14.6t

stock 3.14
16 threads
0.22 s
3.14t
16 threads
0.08 s
Part 2 · Step 2 · Arrow

Or skip Python objects entirely

object dtype · one PyObject per cell PyObject PyObject PyObject PyObject PyObject PyObject a lock per read a refcount per touch Arrow-backed · three plain buffers validity1 1 0 1 1 1 1 … offsets0 5 10 10 14 … datahello world … engine threads zero-copy · every thread reads at once no Python object touched, no lock to take Works on stock Python too.

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.

Part 2 · Step 2 · Result

219 s20 s. Not the lock: zero-copy.

object dtype
one Python object per row
219.2 s
Arrow-backed
plain buffers, no Python objects
20.1 s

Works on stock Python today.

Pandas 3.0 with PyArrow gives you Arrow-backed string columns by default. Other dtypes are still NumPy.

Part 2 · Scorecard

Scorecard: a lane per thread

ONE PYTHON PROCESS · 3.14t engine threads Python UDF · 8 threadsobject strings · 16 threadsArrow columns · stock too 0.850.15 s0.220.08 s21920 s
Wrap-up · Agents

One agent turn on 3.14t: six steps, no lock

LLM turn threads + asyncio call external API read session history load context from files run a tool function embed or rerank write back to memory all six at once dicts, lists, strings no lock between steps one model loaded once, in memory the chDB call or Pandas job is one of the six I/O: as beforeCPU: now parallelobjects between steps: no lock
Wrap-up · Pandas

A Pandas chain becomes one SQL query

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

One compiler

Ten chained ops → one plan. No intermediate copies.

Every core

Pandas computes on one core. The engine fills them all.

Streaming

Top-10 of 10M rows keeps a 10-row heap, never the sorted table.

Wrap-up · Pandas

How it runs: lazy chain → compiler → segments

Your Pandas code unchanged, except the import import chdb.datastore as pd df = pd.read_parquet(f) df[df.status == 200] .groupby("user_id") .amount.sum() .map(score) .sort_values() .head(10) Lazy op chain recorded, not run read_parquet filter groupby · sum map(score) sort · head runs when you look: print · len to_pandas() Compiler one plan, cut into segments SQL → ClickHouse engine filter · groupby · sort · join · head all cores · streaming · no copies SQL + Python UDF .map(fn) called from the engine bottleneck 1, now on every thread Pandas segment no SQL translation: mask, where … real Pandas runs it, hands it back
Segments run in order and pass results along. What comes back is a normal Pandas DataFrame, same dtypes and index. Engine segments are the green bars on the next slide. A Pandas segment mid-chain costs a round-trip through the engine, and that is the two red bars.
Wrap-up · Pandas

Measured: 10M rows, 16 everyday ops. Faster on 14 of 16; bulk value-replace stays with Pandas

Sort (single col)
×51.7
Sort (multi col)
×39.0
Mixed Filter+Sort
×35.2
Filter+Select+Sort
×21.7
Chain 5 filters
×12.9
Head / limit
×6.8
Filter+Sort+Head
×6.6
Multi-filter (4x)
×4.9
Filter+Sort+Select
×4.2
Filter (multi AND)
×4.2
Ultra-complex chain
×4.0
Complex pipeline
×4.0
Filter (single)
×3.3
GroupBy agg ×3
×2.8
Where (replace)
×0.15
Mask (replace)
×0.12
log scale · parquet → answer · best of 2 · 16/16 outputs identical · one laptop
1,059440 MB
peak RSS, median over the 16 ops · ⅓–½ the RAM on every op we win

Same filter+sort, growing data

100K rows ×0.5 · 1M ×1.4
10M ×28 · 100M ClickBench ×52.7

Below ~1M rows, keep Pandas.

Design argument, not measured

With the GIL off, .map(python_fn) inside that chain runs as an in-process UDF on every engine thread.

Wrap-up · Ecosystem

Where the ecosystem is on 3.14t

60%
216 of the top 360 most-downloaded compiled packages ship cp314t wheels
hugovk.github.io/free-threaded-wheels
10 Sep 2026

Ships cp314t wheels

✓ NumPy✓ Pandas✓ PyArrow✓ SciPy✓ scikit-learn✓ PyTorch✓ jaxlib✓ numba✓ Pillow✓ matplotlib✓ cryptography✓ pydantic-core✓ SQLAlchemy✓ tiktoken✓ aiohttp✓ uvloop✓ chDB (this talk)

Not yet: importing one re-enables the GIL

✗ Polars✗ DuckDB✗ grpcio✗ protobuf✗ tokenizers✗ safetensors✗ orjson✗ TensorFlow

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.

Wrap-up · Ecosystem

One undeclared import re-locks the whole process

$ 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.

PEP 779: supported, not default

3.14t is an official build with the same release cadence. You install it on purpose.

The warning, and the override

The warning names the package. PYTHON_GIL=0 forces the GIL off and silences it: check first, force second.

Why your report matters

Free-threading is a per-process property decided by the weakest dependency. One issue on that package unblocks everyone above it.

Wrap-up · Try it

Try it yourself

1 Install

The t is for threads.

uv python install 3.14t && uv venv -p 3.14t

2 Check

False = GIL off. A warning names the package that turned it back on.

python -c "import sys, yourstack; print(sys._is_gil_enabled())"

3 Try

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")
No install at all? The same engine runs in your browser at wasm.chdb.io — SQL only, no Python, single-threaded. Good for a first look, not for the threading test.
Wrap-up · Close

The GIL was a shortcut from the single-core era.
Today it costs more than it saves.

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