Python: Multithreading (Thread, Lock, GIL)

Python multithreading, explained from the ground up: understand the Global Interpreter Lock, create threads with threading.Thread, protect shared data with locks, and learn when threading actually helps (I/O-bound tasks) versus when it does not (Central Processing Unit (CPU)-bound tasks).

“Threads are easy. Correct threads are hard.”

Adapted from Brian Goetz, Java Concurrency in Practice

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Advanced | Reading Time: 14 minutes

Here is a question that trips up almost every Python developer at some point. You have a slow calculation, so you split it across four threads expecting it to finish four times faster. You run it. It is not faster. Sometimes it is even a little slower. What just happened?

The answer is the Global Interpreter Lock, or the GIL for short. It is a lock (a mutex) that lets only one thread run Python bytecode at any given moment. So even with four threads and eight CPU cores, your pure-Python number crunching still runs one piece at a time. This one rule is why Python threading behaves so differently from threading in C++ or Java, where four threads really can hammer four cores at once.

That does not mean threading is useless. The trick is what counts as “running Python bytecode.” When your code is just waiting (for a network response, a file read, a database query), the thread lets go of the GIL while it waits. Another thread grabs it and gets work done during that idle time. So threading is fantastic for I/O-bound work (input/output, so lots of waiting) and pointless for CPU-bound work (lots of computing).

Picture the GIL as the single microphone in a team standup. Only the person holding the mic can talk. If everyone has a lot to say at once (CPU-bound), there is a queue and things crawl. But if most people are just listening and only speak in short bursts (I/O-bound), the mic gets passed around constantly and the meeting flows, because nobody hogs it. The rest of this post is about when you get the smooth meeting and when you get the queue.

Holds GILBlockedBlockedReleases GIL duringGIL releasedI/O-bound: GIL not a problemThread A waits for networkThread B runs meanwhileCPU-bound: GIL bottleneckThread A runsThread B waitsGlobal Interpreter Lock(GIL)Thread 1Running Python codeThread 2Waiting for GILThread 3Waiting for GILI/O Operation(network, disk)Python GIL: Why I/O Threads Overlap but CPU Threads Take Turns

The diagram shows how Python’s Global Interpreter Lock (GIL) affects threading. For I/O-bound tasks, threads release the GIL while they wait on network or disk, so they genuinely overlap. For CPU-bound tasks, threads have to take turns holding the GIL, which makes threading no faster than single-threaded code. This one distinction is the most important thing to understand about Python threading, because it decides whether threads will speed your program up or just add complexity for nothing. Short version: use threads for I/O, use processes for CPU.

Prerequisites

You should understand Functions and Memory Management & the GIL before taking on Python multithreading, since the GIL shapes everything that follows.

Creating Threads

Start with the happy path. Say a developer named Viraj needs to download three files. Done one at a time, the program sits and waits for each download to finish before starting the next. With threads, all three downloads start together and wait at the same time. We will fake the network delay with time.sleep(), since a sleeping thread releases the GIL exactly like a thread waiting on a real network does.

📄 basic_threads.py: running functions at the same time

import threading
import time

def download_file(filename, seconds):
    """Simulate downloading a file."""
    print(f"[{threading.current_thread().name}] Downloading {filename}...")
    time.sleep(seconds)  # Simulates network I/O
    print(f"[{threading.current_thread().name}] {filename} complete!")

# Sequential, one after another
start = time.perf_counter()
download_file("report.pdf", 2)
download_file("data.csv", 3)
download_file("image.png", 1)
print(f"Sequential: {time.perf_counter() - start:.1f}s\n")

# Threaded, all at once
start = time.perf_counter()
threads = [
    threading.Thread(target=download_file, args=("report.pdf", 2)),
    threading.Thread(target=download_file, args=("data.csv", 3)),
    threading.Thread(target=download_file, args=("image.png", 1)),
]
for t in threads:
    t.start()
for t in threads:
    t.join()  # Wait for all threads to finish
print(f"Threaded: {time.perf_counter() - start:.1f}s")

▶ Output

[MainThread] Downloading report.pdf...
[MainThread] report.pdf complete!
[MainThread] Downloading data.csv...
[MainThread] data.csv complete!
[MainThread] Downloading image.png...
[MainThread] image.png complete!
Sequential: 6.0s

[Thread-1 (download_file)] Downloading report.pdf...
[Thread-2 (download_file)] Downloading data.csv...
[Thread-3 (download_file)] Downloading image.png...
[Thread-3 (download_file)] image.png complete!
[Thread-1 (download_file)] report.pdf complete!
[Thread-2 (download_file)] data.csv complete!
Threaded: 3.0s

What happened here: The sequential run took 6 seconds, which is just 2 plus 3 plus 1 added up. The threaded run took 3 seconds, the length of the single longest download. While Thread-1 was asleep (standing in for network I/O), it let go of the GIL, so Thread-2 and Thread-3 ran during that same wait. Three waits happened on top of each other instead of one after another. That overlap is the entire point of threading for I/O-bound work.

Two small things to notice: Python 3.14.6 names the worker threads Thread-1 (download_file), tacking on the target function so logs are easier to read, and the “complete!” lines come back in finish order (shortest first), not the order you started them, so do not count on a fixed order.

Locks: Protecting Shared Data

When two threads read and write the same variable, you can get a race condition: two threads read the same old value, both add one, and both write back, so one of the two increments quietly vanishes. A lock fixes this by letting only one thread into the critical section at a time. Think of a single-stall restroom with a key on a hook by the door. You take the key, go in, do your thing, and hang the key back. Nobody else can get in until the key is back on the hook. A threading.Lock is that key. Race conditions are the point where Python multithreading stops being free speed and starts demanding discipline.

📄 race_condition.py: the lock that keeps the count correct

import threading

# WITHOUT a lock: counter += 1 is read, add, write (three steps, not one)
counter = 0

def increment_without_lock():
    global counter
    for _ in range(100_000):
        counter += 1  # Looks atomic, but it is read-modify-write

threads = [threading.Thread(target=increment_without_lock) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(f"Without lock: {counter} (expected 500,000)")

# WITH a lock: only one thread runs the increment at a time
counter = 0
lock = threading.Lock()

def increment_with_lock():
    global counter
    for _ in range(100_000):
        with lock:  # Only one thread enters this block at a time
            counter += 1

threads = [threading.Thread(target=increment_with_lock) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(f"With lock: {counter} (expected 500,000)")

▶ Output

Without lock: 500000 (expected 500,000)
With lock: 500000 (expected 500,000)

What happened here: Wait, the version without the lock also printed 500000? Run it on a normal Python 3.14.6 build and it usually does. The GIL only ever lets one thread run bytecode at a time, and on this build these short loops finish each counter += 1 without getting interrupted in the middle, so no updates get lost. That is luck, not a guarantee.

The instant you make the loop longer, run it on different hardware, or move to a free-threaded build, those lost updates appear and the count drops below 500000. The lock removes the luck: it makes “read, add, write” one indivisible step, so the answer is always 500000 everywhere. Do not skip the lock just because the buggy version happened to look right today.

Why the GIL is not your safety net. It is tempting to think “the GIL means only one thread runs at a time, so I never need locks.” That is wrong. The GIL makes a single bytecode instruction atomic, not a sequence of them. counter += 1 is three instructions (load the value, add one, store it back), and the GIL can switch threads between them. On the regular build, the switch usually does not land in that tiny gap for a short loop, so you get away with it. On the free-threaded build (no GIL at all), there is nothing serializing anything and the race shows up immediately. Bottom line: if two threads touch the same mutable data, use a Lock. Do not bet on GIL timing.

ThreadPoolExecutor, the Modern Way

Creating, starting, and joining threads by hand gets tedious fast. ThreadPoolExecutor from the concurrent.futures module does that bookkeeping for you. Think of a tiffin service kitchen with five cooks: you pin your orders to the board, whichever cook is free picks up the next one, and finished dishes come out as they are ready, not in the order you wrote them down. You never manage the cooks, you just place orders and collect food. That is exactly the deal here: you hand the executor tasks, it runs them on a fixed pool of worker threads, and it hands back the results. Here we fetch five URLs at once instead of one at a time.

📄 thread_pool.py: simpler threading with concurrent.futures

from concurrent.futures import ThreadPoolExecutor, as_completed
import requests

urls = [
    "https://jsonplaceholder.typicode.com/posts/1",
    "https://jsonplaceholder.typicode.com/posts/2",
    "https://jsonplaceholder.typicode.com/posts/3",
    "https://jsonplaceholder.typicode.com/posts/4",
    "https://jsonplaceholder.typicode.com/posts/5",
]

def fetch_url(url):
    resp = requests.get(url, timeout=10)
    return {"url": url, "title": resp.json()["title"][:40]}

# max_workers=5 creates a pool of 5 threads
with ThreadPoolExecutor(max_workers=5) as executor:
    futures = {executor.submit(fetch_url, url): url for url in urls}

    for future in as_completed(futures):
        result = future.result()
        print(f"  {result['title']}...")

▶ Output

  sunt aut facere repellat provident occae...
  qui est esse...
  ea molestias quasi exercitationem repell...
  eum et est occaecati...
  nesciunt quas odio...

What happened here: All five requests went out together over five worker threads, and each one released the GIL while it waited for the server to reply. ThreadPoolExecutor handled the thread creation, reuse, and cleanup, so there is no manual start() or join() to write. The as_completed() helper hands you each result the moment that request finishes, not in the order you submitted them, which is why the titles above will land in a different order each time you run it (the five titles themselves come straight from the public JSONPlaceholder API, an Application Programming Interface). For everyday I/O-bound work, this is the way to reach for threads in modern Python.

Free-Threaded Python (PEP 703 and PEP 779)

This is the big one. Python ships a second build called “free-threaded” (you may also hear “no-GIL”). It first arrived as an experiment in 3.13 under PEP 703 (Python Enhancement Proposal). In Python 3.14, PEP 779 promoted it to an officially supported build, no longer experimental. With this build the GIL is gone, so threads really can run Python code on several CPU cores at the same time. The microphone from the start of the post is replaced by one mic per person. For CPU-bound work, that finally means real speedup from threads.

The free-threaded build is a separate download, and its interpreter is named python3.14t (note the t for “threaded”) on every platform. The regular build you almost certainly have keeps the GIL. So step one is just knowing which build you are running.

📄 Terminal: check whether your build still has the GIL

python -c "import sys; print('GIL enabled:', sys._is_gil_enabled())"

▶ Output (on a regular Python 3.14.6 build)

GIL enabled: True

What happened here: sys._is_gil_enabled() returns True on the normal build (the GIL is on) and False on a free-threaded build with the GIL turned off. Use this exact call. There is no sys.flags.free_threading attribute, so reaching for that would just raise an AttributeError on a regular build. If you want to check the build itself rather than the live state, import sysconfig; sysconfig.get_config_var("Py_GIL_DISABLED") returns 1 for a free-threaded build and 0 otherwise.

So should you switch your production app to it today? Usually not yet. Now that it is officially supported, the build is stable, but a lot of C extension packages still need updates and recompiling to run safely without the GIL, and some are slower under it. For CPU-bound work that has to ship right now, multiprocessing (covered in the multiprocessing tutorial) is still the safe, boring, reliable choice. Free-threaded Python is clearly the direction the language is heading, so it is worth trying on a side project, just check that your key libraries support it before you bet a deadline on it.

Common Mistakes

There is really one mistake that matters in Python multithreading, and almost everyone makes it once: reaching for threads to speed up heavy computation.

📄 ❌ Mistake: using threads for CPU-bound work

import threading

def compute():
    return sum(i * i for i in range(10_000_000))

# Four threads, but on the GIL build they take turns on one core.
# No reliable speedup over running compute() four times in a row.
threads = [threading.Thread(target=compute) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

📄 ✅ Fix: use multiprocessing for CPU-bound work

from multiprocessing import Pool

# Each process gets its own interpreter and its own GIL, so this
# genuinely runs on four cores. See the multiprocessing tutorial
# for the full compute_chunk / data_chunks pattern.
with Pool(4) as p:
    results = p.map(compute_chunk, data_chunks)

Why this matters: on the regular GIL build, the four threads cannot run Python bytecode at the same time, so splitting a pure computation across threads buys you nothing. At best it matches the single-threaded time, and the extra thread switching can even make it a touch slower. The fix is multiprocessing: separate processes each carry their own GIL, so they truly run in parallel across cores. (The free-threaded build from the previous section is the other way out, once your libraries support it.) The second snippet is a fragment: it leaves compute_chunk and data_chunks for you to fill in, and the full working version lives in the multiprocessing tutorial.

Conclusion

You now know the one rule that decides everything about Python multithreading: the GIL lets only one thread run Python bytecode at a time, so threads shine for I/O-bound work (waiting on networks, disks, databases) and do nothing for CPU-bound work. You created threads with threading.Thread, saw why counter += 1 needs a Lock even with the GIL around, moved to ThreadPoolExecutor for real-world jobs, and got a look at the free-threaded 3.14 build that drops the GIL entirely. Next up is the other half of the story: multiprocessing, the tool that gives CPU-bound code true parallelism today. For everything else in this series, head over to the Python + AI/ML tutorial series home.

Frequently Asked Questions

What is the GIL in Python?

The Global Interpreter Lock (GIL) is a mutex that allows only one thread to execute Python bytecode at a time. It exists to protect CPython’s reference counting memory management from race conditions. It limits CPU-bound threading but does not affect I/O-bound threading.

When should I use threading vs multiprocessing?

Use threading for I/O-bound tasks (network calls, file reads, database queries) where threads spend most time waiting. Use multiprocessing for CPU-bound tasks (math, image processing) that need true parallelism across cores.

Is threading safe in Python?

The GIL prevents Python code corruption, but it does NOT prevent logical race conditions. If two threads read-modify-write the same variable, you need a Lock. The GIL makes single operations atomic, not sequences of operations. Locks are what make Python multithreading safe in practice.

What is ThreadPoolExecutor?

A high-level interface from concurrent.futures that manages a pool of worker threads. It handles thread creation, reuse, and cleanup. Use submit() to queue tasks and as_completed() to process results.

Will the GIL be removed from Python?

Python ships a separate free-threaded build that runs without the GIL. It started as an experiment in 3.13 (PEP 703) and became officially supported in 3.14 (PEP 779). It is not the default yet, and many C extension libraries still need updates to run safely without the GIL, so for CPU parallelism today, multiprocessing is still the reliable path.

Try It Yourself

Build a concurrent URL checker that takes a list of 20 URLs and checks which ones are reachable (return HTTP 200). Use ThreadPoolExecutor with 10 workers. Print each result as it completes, including response time in milliseconds. Compare total time with sequential execution.

Interview Questions on Python Multithreading

Interviewers rarely ask for definitions. They ask what happens in situations like these.

Q: You split a pure-Python data crunching job across 8 threads on a standard Python 3.14.6 build and it finished slightly slower than the single-threaded version. Why, and what do you do instead?

The job is CPU-bound, so the GIL forces the 8 threads to take turns running bytecode on one core, and the constant thread switching adds overhead on top. The fix is multiprocessing (or ProcessPoolExecutor), where each process has its own interpreter and its own GIL and can use a separate core. The free-threaded build is the longer-term alternative once your dependencies support it.

Q: A shared counter in your production service is occasionally lower than expected, but the bug never reproduces on your laptop. What do you check first?

Look for shared mutable state that threads update without a lock. counter += 1 is a read-modify-write sequence, and under production load the interpreter is far more likely to switch threads in the middle of it than during a short local test, so updates get silently lost. Wrap every update in a with lock: block, or push updates through a queue.Queue so only one thread owns the counter. Timing-dependent bugs that vanish locally are the classic signature of a race condition.

Q: The GIL only lets one thread run at a time, so why do you still need locks?

The GIL makes a single bytecode instruction atomic, not a sequence of instructions. An operation like counter += 1 compiles to load, add, and store, and the interpreter can switch threads between those steps, so two threads can both load the same old value and one increment disappears. A Lock makes the whole sequence indivisible. On the free-threaded build there is no GIL at all, so unlocked shared writes break immediately rather than occasionally.

Q: What is a deadlock, and how do you avoid one when using threading.Lock?

A deadlock is when thread A holds lock 1 and waits for lock 2 while thread B holds lock 2 and waits for lock 1, so both wait forever. Prevent it by always acquiring multiple locks in the same fixed order everywhere in the codebase, keeping critical sections tiny, and using with lock: so a lock is never accidentally left held after an exception. lock.acquire(timeout=...) is a defensive fallback that lets a thread give up instead of hanging.

Q: What does thread.join() do, and what happens to daemon threads when the main program exits?

join() blocks the calling thread until the target thread finishes, which is how the examples in this post wait for all downloads before printing the total time. Regular (non-daemon) threads keep the process alive until they complete. A thread started with daemon=True is killed abruptly when the main program exits, with no cleanup, so daemon threads are only appropriate for background work you can afford to lose, never for writing files or committing data.

Q: NumPy-heavy code sometimes gets real speedup from threads even on the GIL build. How is that possible?

The GIL only guards Python bytecode. Well-written C extensions like NumPy release the GIL while they run long native computations, exactly the way a thread releases it while waiting on I/O. So while one thread is inside a big matrix operation in C, another thread can run Python code or its own native work in parallel. The rule “threads never help CPU-bound work” really means “pure-Python CPU-bound work.”

Further reading: the official Python documentation is the authoritative source on this.

Previous: FastAPI Authentication: OAuth2, JSON Web Token (JWT), and Sessions Done Right

Next: Python: Multiprocessing for Parallel CPU-bound Tasks

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *