Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /
  3. Tools
  4. /
  5. GILCheck
Free · Private · Runs in your browser

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.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

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 counter

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

Shared counter (non-atomic +=): Likely race

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-GIL
Shared container mutation: Likely race

A .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 instead
Shared cache write (subscript): Review

A 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 memoisation
Lazy singleton (check-then-set): Likely race

An '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 init
Threads without any lock: Context

The 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  <- flagged

Fix patterns reference

Three tools cover almost every finding. Pick the one that matches how the shared state is actually used.

Free-threading-safe replacements
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

ScenarioWhat to paste
Auditing a threaded data-processing scriptThe module that spawns threading.Thread workers
Pre-flight before PYTHON_GIL=0 in stagingYour app's shared caches and rate-limiter module
Reviewing a Flask/FastAPI app's shared stateThe file holding module-level caches or counters
Prepping a library for free-threading supportInternals that keep module-level or class-level state
Sanity-checking a singleton / connection poolThe get_instance() / lazy-init module
Deciding if async code even needs the auditYour async handlers (likely returns 'probably fine')

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?
multiprocessingFree-threading (no-GIL)
Memory modelSeparate memory per processShared memory across threads
How state is sharedExplicit IPC / picklingOrdinary Python objects
Race conditions on shared objectsNot possible (nothing shared)Possible without locks
OverheadHigh (process + serialisation)Low (threads)
Affected by GIL removalNoYes, 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.

python
# Two threads, no lock, shared counter
counter = 0

def worker():
    global counter
    counter += 1   # LOAD counter, ADD 1, STORE counter

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

💡 Tip

Because it is a text scanner and not a sandbox, you can safely paste proprietary source: nothing is executed and nothing is transmitted.

How do I fix a flagged shared counter?

Wrap every read-modify-write of the shared value in a lock so the load, add, and store cannot be split across threads:

python
import threading

counter = 0
_lock = threading.Lock()

def worker():
    global counter
    with _lock:
        counter += 1   # load-add-store now runs as one atomic block

For pure counting, itertools.count() gives you a thread-safe incrementing source without a manual lock, and for passing values between threads queue.Queue handles all the synchronisation internally. Reach for a lock only when you genuinely need to mutate shared state in place.

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.

Related reading

Guide

Python t-strings: Practical Patterns

A tour of Python 3.14's template strings and the newer language features shipping alongside free-threading.

Guide

Finding Goroutine Leaks in Kubernetes

Concurrency bugs that stay invisible until load hits, the same class of problem free-threading exposes in Python.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.