Is your Python code actually safe without the GIL?
Paste your code. Find the shared state, counters, and lazy singletons that were fine under the GIL and won't be once you flip it off with free-threaded Python 3.13t/3.14t. Every finding explains the why, not just the where.
Why GIL-safe code becomes GIL-unsafe code
The Global Interpreter Lock lets only one thread execute Python bytecode at a time. That accidental serialisation is why a lot of thread-unsafe code has worked for years. The classic example is a shared counter. The line looks like one operation, but it is three:
counter = 0
def worker():
global counter
counter += 1 # LOAD counter -> ADD 1 -> STORE counterUnder the GIL, the interpreter almost never switches threads between LOAD, ADD, and STORE, so the increment behaves as if it were atomic. Under free-threaded Python the three steps can interleave across cores: two threads LOAD the same value, both ADD, both STORE, and one increment vanishes. No exception, no crash, just a count that is quietly wrong under load.
The same trap applies to any compound operation on shared state: a dict used as a cache, a shared list you append to, a lazy singleton guarded by if x is None. GILCheck finds those patterns so you know where to look first.
How GILCheck works
- 1
Paste a Python file
Drop a single module into the box. GILCheck reads the raw source as text; it never runs your code, so pasting proprietary internals is safe.
- 2
It maps your shared state
A lightweight pass tracks indentation and block structure to find names assigned at module level and class level (dicts, lists, sets, counters, None-initialised singletons). These are the objects every thread would share.
- 3
It looks for GIL-dependent mutations inside functions
A second pass finds where that shared state is mutated from inside a function: compound assignments (+=, -=), container methods (.append, .pop, .update), subscript writes (cache[key] = ...), and check-then-set singleton patterns.
- 4
It checks for missing synchronisation
It detects whether the file starts threads (threading.Thread, ThreadPoolExecutor) and whether any lock primitive (Lock, RLock, Semaphore, Queue) appears at all. Threads with no synchronisation anywhere get their own flag.
- 5
Each finding is explained, not just listed
For every hit you get the line, why it was safe under the GIL, what specifically breaks without it, and a copyable fix (wrap in a Lock, switch to queue.Queue, or use lru_cache/contextvars).
- 6
Clean files get a 'probably fine' verdict
Async-only or multiprocessing code, or code with no shared-mutation patterns, returns a green summary explaining why free-threading does not obviously affect it.
What each finding means
GILCheck groups what it finds into four pattern classes. Each one is safe today only because the GIL happens to serialise it, and each breaks in a different way once that serialisation is gone.
A compound assignment (+=, -=, *=) on a module-level or class-level value from inside a function. The read-modify-write is three bytecodes that the GIL serialises for free; without it, concurrent increments are lost.
hits = 0
def track():
global hits
hits += 1 # <- flagged: lost updates under no-GILA .append(), .pop(), .update(), or similar method call on a shared list/dict/set inside a function. Individual operations stay internally safe, but any sequence you rely on (check-then-append, pop-then-use) is no longer atomic as a group.
queue_items = []
def enqueue(x):
queue_items.append(x) # <- flagged: use queue.Queue insteadA cache[key] = value write to a shared dict inside a function. The typical 'if key not in cache: cache[key] = compute()' pattern runs in two threads at once, computing the value twice and clobbering one write.
cache = {}
def memo(k):
if k not in cache:
cache[k] = compute(k) # <- flagged: racey memoisationAn 'if x is None: x = ...' initialisation. Two threads can both see None and both build the object, so you get two 'singletons' and later code uses whichever won. A classic GIL-reliant pattern.
_conn = None
def get_conn():
global _conn
if _conn is None:
_conn = connect() # <- flagged: double initThe file starts threads (threading.Thread or ThreadPoolExecutor) but no Lock, RLock, Semaphore, or Queue appears anywhere. This is a signal that shared-state synchronisation was left to the GIL to handle implicitly.
# threading.Thread(...) used
# but no Lock/Queue anywhere in the file <- flaggedFix patterns reference
Three tools cover almost every finding. Pick the one that matches how the shared state is actually used.
import threading, queue
from functools import lru_cache
# 1. Read-modify-write on shared state -> guard with a Lock
_lock = threading.Lock()
with _lock:
counter += 1
# 2. Passing work between threads -> use a Queue (no manual lock)
work = queue.Queue()
work.put(item) # producer thread
item = work.get() # consumer thread
# 3. Memoisation / lazy compute -> lru_cache is thread-safe
@lru_cache(maxsize=None)
def expensive(key):
return compute(key)
# 4. Per-thread state that should NOT be shared -> contextvars
import contextvars
current_user = contextvars.ContextVar("current_user")Rule of thumb: if threads must mutate the same object in place, use a Lock. If they only need to hand values back and forth, use a Queue. If the state should really be per-thread, it should not be module-level at all, move it to contextvars or thread-local storage.
When to use GILCheck
| Scenario |
|---|
| Auditing a threaded data-processing script |
| Pre-flight before PYTHON_GIL=0 in staging |
| Reviewing a Flask/FastAPI app's shared state |
| Prepping a library for free-threading support |
| Sanity-checking a singleton / connection pool |
| Deciding if async code even needs the audit |
Frequently Asked Questions
What does GILCheck do?
GILCheck is a static scanner for the free-threaded Python migration. You paste a Python file and it flags the patterns that work today because the Global Interpreter Lock (GIL) serialises bytecode execution, but become real, silent race conditions once you run on a free-threaded build (Python 3.13t or 3.14t, PEP 703).
It looks for four things: non-atomic read-modify-write on shared state (counter += 1), unsynchronised mutation of module-level or class-level containers (cache[key] = ..., items.append(...)), lazy if x is None: x = ... singleton initialisation, and threads started without any lock primitive anywhere in the file. Each finding explains why the pattern was safe under the GIL, what specifically breaks without it, and a concrete fix.
Does async/await code need this check?
Generally no. async/await runs coroutines cooperatively on a single event loop. A coroutine only yields control at an await point, so a plain statement like counter += 1 never gets interrupted halfway through by another coroutine on the same loop. The atomicity your async code relies on comes from the single-threaded event loop, not from the GIL, and free-threading does not change that.
The exception is when you mix async with real threads, for example calling loop.run_in_executor() with a thread pool that touches shared state, or running multiple event loops on multiple threads. That shared state is subject to the same races as any other threaded code, and GILCheck will still flag it.
What's the difference between free-threading and multiprocessing?
| multiprocessing | Free-threading (no-GIL) | |
|---|---|---|
| Memory model | Separate memory per process | Shared memory across threads |
| How state is shared | Explicit IPC / pickling | Ordinary Python objects |
| Race conditions on shared objects | Not possible (nothing shared) | Possible without locks |
| Overhead | High (process + serialisation) | Low (threads) |
| Affected by GIL removal | No | Yes, this is the whole point |
multiprocessing sidesteps the GIL by running separate interpreters in separate processes that share nothing by default, so removing the GIL changes nothing about it. Free-threading keeps one process with shared memory and lets threads run Python bytecode truly in parallel, which is faster but exposes every shared-state assumption the GIL used to hide.
Why does code that works today break without the GIL?
Because operations that look atomic in Python source are not atomic in bytecode. A single counter += 1 compiles to three separate steps: load the current value, add one, store it back.
# Two threads, no lock, shared counter
counter = 0
def worker():
global counter
counter += 1 # LOAD counter, ADD 1, STORE counterUnder the GIL, the interpreter almost never switches threads between those three steps, so the increment appears atomic and the final count is correct. Under free-threading, two threads can both LOAD the same value, both ADD, and both STORE, so one increment is lost. Run a million increments across eight threads and you will reliably end up short, with no exception and no crash to tell you.
Is my code uploaded or sent to a server?
No. GILCheck runs entirely as JavaScript in your browser. Your source is scanned locally with pattern matching and never leaves your machine. There is no backend, no API call, and no logging of what you paste. Once the page has loaded you can even disconnect from the network and it still works.
Will GILCheck catch problems in C extensions or third-party packages?
No. GILCheck only scans the pure-Python source you paste. It cannot see into compiled C extensions, and whether a package like NumPy, pydantic, or bcrypt is free-threading-safe depends on that package's own C code, not on how you call it.
For dependency-level readiness, check whether each package publishes free-threaded (cp313t / cp314t) wheels and consult its release notes. GILCheck answers a different, complementary question: is your own application code making atomicity assumptions the GIL used to guarantee?
Does a finding mean I definitely have a bug?
Not necessarily. GILCheck is a heuristic pattern scanner, not a data-flow analyser. A flagged counter += 1 is only an actual race if that function is really called from multiple threads on shared state at the same time. If the object is created fresh per thread, guarded by a lock elsewhere, or only ever touched by one thread, the finding is a false positive.
Treat findings as a prioritised list of places to look, not a verdict. The only way to confirm a real race is to run your test suite under a free-threaded interpreter (python3.14t or PYTHON_GIL=0) with genuinely concurrent coverage.