Python 3.12 vs 3.14 vs 3.14t: GIL and free-threading benchmark

Generated 2026-09-12T12:41:05 on Apple M5 (10 cores), macOS-26.6.2-arm64-arm-64bit. 160 measurements, median of 10 repeats.

3 columns, one interpreter. 3.14 (GIL on), 3.14t (free-threaded) and 3.14t (PYTHON_GIL=0 forced) are the same binary run with different GIL settings - not different builds. No stock (GIL-enabled) build of that version was measured here, so a gap between any of them and 3.12 mixes the version change with the free-threaded build's own overhead.

Key findings

Summary

Interpreter configurations

labelversionfree-threaded buildGIL enabled at runenvnote
3.123.12.13noyes-Stock CPython 3.12 (GIL). Baseline.
3.14 (GIL on)3.14 GIL3.14.6yesyesPYTHON_GIL=1Free-threaded 3.14 binary run with PYTHON_GIL=1: same code paths, GIL re-enabled.
3.14t (free-threaded)3.14t3.14.6yesno-Free-threaded 3.14 binary, GIL disabled (default for this build).
3.14t (PYTHON_GIL=0 forced)3.14t GIL=03.14.6yesnoPYTHON_GIL=0GIL forced off even for extension modules that do not declare free-threading support. Unsupported by those libraries; shown for comparison only.

Speed-up at a glance

workload3.123.14 (GIL on)3.14 GIL3.14t (free-threaded)3.14t3.14t (PYTHON_GIL=0 forced)3.14t GIL=0
cpu_primes CPU-bound0.9x 0.220 s0.9x 0.276 s3.9x 0.054 s-
cpu_float CPU-bound0.9x 0.276 s1.0x 0.310 s4.1x 0.075 s-
io_sleep I/O-bound1.0x 0.241 s1.0x 0.244 s1.0x 0.242 s-
contended_list_append Contended shared state1.0x 0.108 s0.8x 0.230 s0.7x 0.284 s-
contended_dict_update Contended shared state1.0x 0.356 s1.0x 0.294 s0.3x 0.847 s-
mp_primes Multiprocessing reference2.0x 0.155 s1.9x 0.146 s2.0x 0.142 s-
numpy_small_ops Library workloads1.0x 0.279 s1.0x 0.293 s4.2x 0.067 s-
numpy_matmul Library workloads1.8x 0.053 s1.8x 0.055 s1.7x 0.057 s-
pandas_groupby Library workloads4.7x 0.030 s4.5x 0.032 s4.8x 0.030 s-
duckdb_query Library workloads6.1x 0.036 s5.6x 0.034 s5.0x 0.042 s 5.3x 0.036 s
sklearn_fit Library workloads5.4x 0.238 s5.3x 0.252 s6.2x 0.214 s-
fastapi_sync_cpu Library workloads1.0x 0.271 s1.0x 0.306 s3.9x 0.078 s-
fastapi_async_json Library workloads1.9x 0.043 s1.6x 0.054 s2.0x 0.050 s-

Cells: speed-up from 1 to 8 workers, and wall time at 8 workers. = the free-threaded run actually had the GIL re-enabled by an extension module (hover, focus or tap any symbol for its meaning).

Bare Python workloads (stdlib only)

CPU-bound

Pure-bytecode number crunching split across threads. This is the case the GIL serialises: expect flat lines on GIL builds and genuine parallel speed-up on the free-threaded build.

cpu_primes

Trial-division prime counting below 300k, range split across threads. Pure bytecode: integer arithmetic and loops, no I/O.

cpu_primes00.10.20.30.41248threads / workersmedian wall time (s) - lower is better3.14 GIL on3.123.14t no GIL00.10.20.30.41248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.199 s ±0.39 ms0.199 s ±1.31 ms0.200 s ±5.93 ms0.220 s ±0.027 s ~0.9x
3.14 (GIL on)3.14 GIL0.252 s ±0.013 s0.258 s ±3.75 ms0.258 s ±0.012 s0.276 s ±0.030 s ~0.9x
3.14t (free-threaded)3.14t0.209 s ±0.60 ms0.133 s ±0.59 ms0.075 s ±0.95 ms0.054 s ±3.13 ms3.9x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

cpu_float

3M iterations of sin*cos accumulation, range split across threads. Float-heavy bytecode with C-level math calls.

cpu_float00.20.40.61248threads / workersmedian wall time (s) - lower is better3.14 GIL on3.123.14t no GIL00.20.40.61248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.258 s ±0.018 s0.259 s ±0.013 s0.261 s ±0.014 s0.276 s ±0.027 s0.9x
3.14 (GIL on)3.14 GIL0.307 s ±0.043 s ~0.308 s ±0.26 ms0.308 s ±0.95 ms0.310 s ±0.30 ms1.0x
3.14t (free-threaded)3.14t0.309 s ±1.39 ms0.155 s ±6.17 ms0.084 s ±5.87 ms0.075 s ±8.71 ms ~4.1x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

I/O-bound

Threads spend their time waiting. The GIL is released while blocked, so all builds overlap the waits equally - free-threading was never needed here.

io_sleep

Each thread performs 20 x 10ms sleeps (simulated network/disk waits), so 8 threads do 8x the waiting of 1 - unlike every other workload here, the work is fixed per thread, not in total. The GIL is released while sleeping, so all builds should overlap the waits: a flat line (1.0x) is the good outcome here.

io_sleep00.10.20.31248threads / workersmedian wall time (s) - lower is better00.10.20.31248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.240 s ±5.76 ms0.238 s ±2.78 ms0.241 s ±2.86 ms0.241 s ±2.95 ms1.0x
3.14 (GIL on)3.14 GIL0.240 s ±3.78 ms0.240 s ±3.46 ms0.242 s ±2.64 ms0.244 s ±1.77 ms1.0x
3.14t (free-threaded)3.14t0.240 s ±6.08 ms0.239 s ±4.09 ms0.241 s ±2.33 ms0.242 s ±3.28 ms1.0x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

Contended shared state

All threads hammer one shared object. Without the GIL, every operation still takes a lock: the object's own critical section, plus - in contended_dict_update - an explicit threading.Lock that every build pays for. This is the cost of fine-grained locking.

contended_list_append

4M appends to one shared list, split across threads. On free-threaded builds each append takes the list's per-object critical section, so more threads means more lock hand-offs.

contended_list_append00.10.20.30.41248threads / workersmedian wall time (s) - lower is better3.14t no GIL3.14 GIL on3.1200.10.20.30.41248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.106 s ±6.30 ms0.105 s ±0.012 s ~0.108 s ±0.015 s ~0.108 s ±3.95 ms1.0x
3.14 (GIL on)3.14 GIL0.192 s ±7.04 ms0.196 s ±2.89 ms0.211 s ±5.88 ms0.230 s ±5.71 ms0.8x
3.14t (free-threaded)3.14t0.192 s ±5.32 ms0.248 s ±0.013 s0.313 s ±8.81 ms0.284 s ±9.11 ms0.7x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

contended_dict_update

2M dict increments guarded by one threading.Lock, split across threads. Measures lock hand-off cost when the GIL is not serialising threads.

contended_dict_update00.511.51248threads / workersmedian wall time (s) - lower is better3.14t no GIL00.511.51248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.359 s ±0.025 s0.380 s ±8.31 ms0.308 s ±0.025 s0.356 s ±0.040 s ~1.0x
3.14 (GIL on)3.14 GIL0.290 s ±3.51 ms0.292 s ±5.28 ms0.293 s ±6.02 ms0.294 s ±1.51 ms1.0x
3.14t (free-threaded)3.14t0.292 s ±4.85 ms0.608 s ±0.039 s0.753 s ±0.108 s ~0.847 s ±0.139 s ~0.3x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

Multiprocessing reference

The classic GIL workaround: processes instead of threads. Includes process start-up and pickling overhead, and the same work as cpu_primes for direct comparison.

mp_primes

Same prime counting as cpu_primes, but using a multiprocessing.Pool (spawn). The classic GIL workaround; includes process start-up cost.

mp_primes00.10.20.30.41248threads / workersmedian wall time (s) - lower is better00.10.20.30.41248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.314 s ±0.017 s0.237 s ±7.92 ms0.174 s ±0.011 s0.155 s ±0.016 s ~2.0x
3.14 (GIL on)3.14 GIL0.280 s ±3.47 ms0.203 s ±1.86 ms0.148 s ±2.13 ms0.146 s ±0.017 s ~1.9x
3.14t (free-threaded)3.14t0.281 s ±7.03 ms0.207 s ±1.40 ms0.155 s ±3.76 ms0.142 s ±7.85 ms2.0x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

Library workloads (optional tier)

Third-party extension modules. Some release the GIL in C already (so they scale on every build), some hold it, and some re-enable it for the whole process on import.

Free-threading support check (check_gil_support.py, probed on 3.14t)

packageversionwheel ABIstatusdetail
duckdb1.5.5cp314tRe-enables GIL REENABLES_GIL'_duckdb' has not declared Py_MOD_GIL_NOT_USED, so CPython re-enabled the GIL for the whole process (override with PYTHON_GIL=0 at your own risk)
fastapi0.141.1pure (none-any)Supported SUPPORTED1 extension module(s) loaded, GIL stayed off
numpy2.5.3cp314tSupported SUPPORTED2 extension module(s) loaded, GIL stayed off
pandas3.0.5cp314tSupported SUPPORTED55 extension module(s) loaded, GIL stayed off
sklearn1.9.1cp314tSupported SUPPORTED145 extension module(s) loaded, GIL stayed off
uvicorn0.52.4pure (none-any)Pure Python PURE_PYTHONno compiled extension modules were loaded

numpy_small_ops

8 tasks x 20k tiny (256-element) array ops. Python dispatch overhead dominates and holds the GIL, so GIL builds should not scale.

numpy_small_ops00.10.20.30.41248threads / workersmedian wall time (s) - lower is better3.14t no GIL00.10.20.30.41248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.274 s ±2.57 ms0.279 s ±2.79 ms0.279 s ±2.24 ms0.279 s ±3.96 ms1.0x
3.14 (GIL on)3.14 GIL0.284 s ±2.09 ms0.287 s ±3.32 ms0.291 s ±1.93 ms0.293 s ±8.81 ms1.0x
3.14t (free-threaded)3.14t0.284 s ±4.34 ms0.203 s ±1.82 ms0.088 s ±0.98 ms0.067 s ±1.21 ms4.2x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

numpy_matmul

8 tasks x 8 matmuls of 512x512 float64 (BLAS threads pinned to 1). numpy releases the GIL inside BLAS, so all builds should scale.

numpy_matmul00.050.10.151248threads / workersmedian wall time (s) - lower is better00.050.10.151248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.098 s ±3.70 ms0.077 s ±0.80 ms0.069 s ±4.19 ms0.053 s ±2.13 ms1.8x
3.14 (GIL on)3.14 GIL0.097 s ±0.28 ms0.078 s ±0.69 ms0.072 s ±0.79 ms0.055 s ±1.20 ms1.8x
3.14t (free-threaded)3.14t0.097 s ±1.16 ms0.077 s ±1.14 ms0.070 s ±1.65 ms0.057 s ±2.09 ms1.7x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

pandas_groupby

8 tasks: build a 1M-row DataFrame and groupby-sum. Most of the wall time is spent in C/Cython with the GIL released, so this scales on every build - the GIL is not the bottleneck here.

pandas_groupby00.050.10.150.21248threads / workersmedian wall time (s) - lower is better00.050.10.150.21248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.143 s ±1.35 ms0.076 s ±2.17 ms0.045 s ±2.13 ms0.030 s ±2.18 ms4.7x
3.14 (GIL on)3.14 GIL0.143 s ±2.83 ms0.078 s ±1.42 ms0.044 s ±2.28 ms0.032 s ±1.56 ms4.5x
3.14t (free-threaded)3.14t0.144 s ±3.48 ms0.077 s ±5.77 ms0.043 s ±3.55 ms0.030 s ±0.012 s ~4.8x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

duckdb_query

8 tasks: SELECT sum(i*i) FROM range(5M) on a per-task connection with SET threads=1. DuckDB releases the GIL during execution. NOTE: importing duckdb re-enables the GIL on free-threaded builds unless PYTHON_GIL=0 is forced.

GIL re-enabled. On the free-threaded build an extension module imported by this workload re-enabled the GIL, so the "3.14t (free-threaded)" line ran with the GIL on. See the support table above.

duckdb_query00.10.20.31248threads / workersmedian wall time (s) - lower is better00.10.20.31248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)3.14t (PYTHON_GIL=0 forced)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.221 s ±1.01 ms0.120 s ±0.79 ms0.065 s ±1.48 ms0.036 s ±9.53 ms ~6.1x
3.14 (GIL on)3.14 GIL0.191 s ±1.59 ms0.108 s ±1.17 ms0.062 s ±0.75 ms0.034 s ±0.59 ms5.6x
3.14t (free-threaded)3.14t0.211 s ±0.011 s 0.117 s ±0.014 s ~ 0.066 s ±2.81 ms 0.042 s ±7.50 ms ~ 5.0x
3.14t (PYTHON_GIL=0 forced)3.14t GIL=00.190 s ±1.25 ms0.107 s ±1.79 ms0.061 s ±0.57 ms0.036 s ±1.63 ms5.3x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

sklearn_fit

8 tasks: make_classification(4000x20) + RandomForestClassifier(20 trees, n_jobs=1).fit + predict. Cython tree building releases the GIL in hot loops.

sklearn_fit01231248threads / workersmedian wall time (s) - lower is better01231248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.121.291 s ±5.58 ms0.668 s ±4.64 ms0.360 s ±3.42 ms0.238 s ±9.35 ms5.4x
3.14 (GIL on)3.14 GIL1.324 s ±0.019 s0.692 s ±0.010 s0.365 s ±4.30 ms0.252 s ±0.010 s5.3x
3.14t (free-threaded)3.14t1.328 s ±0.352 s ~0.668 s ±5.99 ms0.365 s ±0.033 s0.214 s ±0.012 s6.2x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

fastapi_sync_cpu

32 requests to a sync def endpoint (prime count to 30k) from N concurrent clients; uvicorn runs in a subprocess on the same interpreter. def endpoints run on a thread pool, so free-threading can parallelise them.

fastapi_sync_cpu00.10.20.30.41248threads / workersmedian wall time (s) - lower is better3.14 GIL on3.123.14t no GIL00.10.20.30.41248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.279 s ±1.43 ms0.272 s ±1.86 ms0.272 s ±0.65 ms0.271 s ±1.35 ms1.0x
3.14 (GIL on)3.14 GIL0.307 s ±0.99 ms0.304 s ±1.41 ms0.302 s ±1.22 ms0.306 s ±7.57 ms1.0x
3.14t (free-threaded)3.14t0.304 s ±0.73 ms0.154 s ±4.61 ms0.083 s ±2.72 ms0.078 s ±0.014 s ~3.9x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

fastapi_async_json

400 requests to a trivial async endpoint from N concurrent clients. Event-loop bound; measures per-request overhead of each build.

fastapi_async_json00.050.10.151248threads / workersmedian wall time (s) - lower is better00.050.10.151248threads / workersmedian wall time (s) - lower is better
3.123.14 (GIL on)3.14t (free-threaded)
configuration1 thr2 thr4 thr8 thrspeed-up 1→8
3.120.080 s ±0.91 ms0.055 s ±0.80 ms0.046 s ±0.27 ms0.043 s ±0.36 ms1.9x
3.14 (GIL on)3.14 GIL0.088 s ±3.73 ms0.060 s ±1.50 ms0.055 s ±0.66 ms0.054 s ±0.012 s ~1.6x
3.14t (free-threaded)3.14t0.099 s ±0.017 s ~0.057 s ±1.25 ms0.052 s ±0.95 ms0.050 s ±3.16 ms2.0x

median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).

Implementation differences: what actually changed inside CPython

All statements below about this machine are measured (see the facts table); the rest describes CPython's design as documented in PEP 703 (free-threaded CPython), PEP 779 (free-threading officially supported in 3.14), PEP 683 (immortal objects) and the CPython free-threading HOWTOs.

1. Timeline

2. Reference counting: biased, deferred, immortal

With the GIL, Py_INCREF is a plain non-atomic increment. Without it, every increment from every thread would have to be atomic - a large slowdown. Free-threaded CPython uses three tricks:

The price is a bigger object header: ob_tid, a one-byte ob_mutex, GC bits and the two counters. Measured here: sys.getsizeof(object()) is 16 bytes on 3.12 and 32 on 3.14t; a small int and a short str each grow by 16 bytes.

3. Memory allocator and garbage collector

4. Per-object locks and critical sections

The GIL used to make list.append, dict.__setitem__ and friends safe by accident. The free-threaded build makes them safe on purpose: each object has a one-byte lock (ob_mutex) and C code wraps mutating operations in critical sections (Py_BEGIN_CRITICAL_SECTION). Critical sections are automatically suspended when a thread blocks (e.g. on another lock), which avoids lock-ordering deadlocks. Most reads of lists and dicts are lock-free, protected by quiescent-state-based reclamation (QSBR) so memory is never freed under a reader.

What this guarantees is memory safety of the interpreter, not atomicity of your program: d[k] += 1 is still a read-modify-write race, exactly as it was with the GIL - only the interleavings become more frequent. That is why contended_dict_update above uses an explicit threading.Lock, and why contended_list_append shows the cost of taking the list's lock on every call.

5. The interpreter loop

6. Runtime knobs and semantics visible from Python

7. C extensions and packaging

8. Measured facts (every configuration on this machine)

measured fact3.123.14 (GIL on)3.14 GIL3.14t (free-threaded)3.14t3.14t (PYTHON_GIL=0 forced)3.14t GIL=0
Python version3.12.133.14.63.14.63.14.6
ABI flags (sys.abiflags)ttt
SOABIcpython-312-darwincpython-314t-darwincpython-314t-darwincpython-314t-darwin
Free-threaded build (Py_GIL_DISABLED)FalseTrueTrueTrue
GIL enabled at runTrueTrueFalseFalse
sys.flags.gil (None = no override; absent before 3.13)None1None0
sys.flags.thread_inherit_contextNone111
sys.flags.context_aware_warningsNone111
JIT available (sys._jit)NoneFalseFalseFalse
gc.get_threshold()[700, 10, 10][2000, 10, 10][2000, 10, 10][2000, 10, 10]
compilerClang 22.1.3 Clang 22.1.3 Clang 22.1.3 Clang 22.1.3
sys.getsizeof(object()) bytes16323232
sys.getsizeof(1) bytes28444444
sys.getsizeof(2**40) bytes32484848
sys.getsizeof("abc") bytes44606060
sys.getsizeof((1, 2)) bytes56646464
sys.getsizeof([]) bytes56565656
sys.getsizeof({}) bytes64646464
sys.getsizeof(set()) bytes216216216216
sys.getsizeof(class instance) bytes48484848
sys.getsizeof(__slots__ instance) bytes48484848
sys.getsizeof(lambda) bytes160168168168

Methodology & how to reproduce

./setup.sh                                  # uv installs 3.12 + 3.14t, creates .venv312 / .venv314t
.venv312/bin/python -m bench.run            # bare-Python workloads (default)
.venv312/bin/python -m bench.run --libs     # + numpy / pandas / duckdb / scikit-learn / fastapi
.venv312/bin/python -m bench.compare_lib numpy   # one library: support check -> warn -> bench -> report
.venv312/bin/python -m bench.report         # regenerate results/report.html
.venv314t/bin/python check_gil_support.py numpy duckdb   # does a package support free-threading?

Appendix: interpreter GIL warnings captured during the run

3.14t (free-threaded) / duckdb_query
<frozen importlib._bootstrap>:491: RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module '_duckdb', which has not declared that it can run safely without the GIL. To override this behavior and keep the GIL disabled (at your own risk), run with PYTHON_GIL=0 or -Xgil=0.