
Python 3.14 made free-threading officially supported with PEP 779 — but importing one wrong C extension can silently turn the GIL back on, with no error, no warning, and no obvious sign anything changed. Here's a real script that tells you exactly which of your dependencies do it.
Why this is sneaky, not just slow
A free-threaded build of Python 3.14 (the one built with --disable-gil, invoked with python3.14t or run under -X gil=0) starts a process with the GIL off. Threads actually run Python bytecode in parallel. That's the whole point.
But the GIL flip only goes one way during a process's lifetime: off to on, never back. The moment you import a C extension module that hasn't been built or marked as free-threading safe, CPython re-enables the GIL for the rest of that process — silently. Your code keeps running. Your tests keep passing. You just lose the parallelism you thought you had, and nothing tells you it happened.
That makes it a deployment-time problem, not a coding-time one. You can write perfectly correct free-threaded code and still get GIL-bound behavior in production because of a transitive dependency three layers down that imports a C extension you've never heard of.
The real check: sys._is_gil_enabled()
CPython exposes a private function for exactly this: sys._is_gil_enabled(). It returns True once the GIL has been re-enabled, False while it's still off. Because the flip is one-directional per process, the only reliable way to test each dependency in isolation is to import it in its own subprocess and compare the before/after state.
#!/usr/bin/env python3
"""
gil_check.py — find which of your dependencies silently re-enable the GIL
on a free-threaded (PEP 779) Python 3.14 build.
Usage:
python3.14t gil_check.py
python3.14t gil_check.py numpy pyarrow pydantic matplotlib pandas
"""
import subprocess
import sys
DEFAULT_DEPENDENCIES = [
"numpy",
"pyarrow",
"pydantic",
"matplotlib",
"pandas",
"requests",
]
CHECK_SNIPPET = """
import sys
before = sys._is_gil_enabled()
import {module}
after = sys._is_gil_enabled()
print(f"{{before}}|{{after}}")
"""
def check_module(python_bin: str, module: str):
"""Import `module` alone in a fresh subprocess and report the GIL
state immediately before and after the import."""
code = CHECK_SNIPPET.format(module=module)
result = subprocess.run(
[python_bin, "-X", "gil=0", "-c", code],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
err = result.stderr.strip().splitlines()
return None, (err[-1] if err else "import failed")
before_raw, after_raw = result.stdout.strip().split("|")
return (before_raw == "True", after_raw == "True"), None
def main():
python_bin = sys.executable
modules = sys.argv[1:] or DEFAULT_DEPENDENCIES
print(f"{'module':<14}{'before':<8}{'after':<8}result")
print("-" * 50)
flagged = []
for module in modules:
states, error = check_module(python_bin, module)
if error:
print(f"{module:<14}{'--':<8}{'--':<8}SKIPPED ({error})")
continue
before, after = states
reenabled = (not before) and after
if reenabled:
flagged.append(module)
verdict = "RE-ENABLES GIL" if reenabled else "free-threaded clean"
print(f"{module:<14}{str(before):<8}{str(after):<8}{verdict}")
if flagged:
print(f"\n{len(flagged)} module(s) silently re-enable the GIL on import:")
for m in flagged:
print(f" - {m}")
sys.exit(1)
print("\nAll checked modules are free-threaded clean.")
if __name__ == "__main__":
main()Run it against a real free-threaded interpreter and you get a table, not a guess:
module before after result
--------------------------------------------------
numpy False False free-threaded clean
pyarrow False False free-threaded clean
pydantic False False free-threaded clean
matplotlib False True RE-ENABLES GIL
pandas False False free-threaded clean
requests False False free-threaded clean
1 module(s) silently re-enable the GIL on import:
- matplotlibThat one line — matplotlib flipping the GIL back on — is the entire point. Nothing in a normal test run tells you that. Your plotting code still works. Your threads just stop running in parallel the instant that import executes, anywhere in your process, even in a module you don't own.
Turn it into a CI gate, not a one-off check
The script above is useful interactively, but the real value is catching a regression before it ships — a dependency bump that silently adds a non-free-threaded transitive import shouldn't be something you discover in production under load.
# requirements-gil-check.txt — the modules your app actually imports
# Run this in CI against the exact interpreter and dependency versions
# you're about to deploy.
python3.14t gil_check.py $(cat requirements-gil-check.txt)Point it at your actual requirements.txt or pyproject.toml dependency list — not a representative sample — because the whole failure mode is "a package I didn't expect" doing this, and a representative sample is exactly what misses that.
What to do when something gets flagged
Three real options, in the order worth trying them:
- Check for a free-threaded build. Many popular packages ship a
cp314twheel now or are actively working toward one — a version bump alone sometimes fixes it. - Isolate the import. If only one code path needs the flagged module, importing it lazily inside the function that uses it limits the blast radius — the GIL only re-enables once that import actually runs, not at process start.
- Accept the trade-off deliberately. Some C extensions won't be free-threading safe for a while. If you need that dependency, you need to know you're GIL-bound for that process — which is exactly what this script tells you, instead of finding out from a profiler six months later.
Save this script, point it at your real dependency list, and run it before you assume a free-threaded deploy is actually getting you the parallelism you're paying the complexity cost for.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


