šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.
HTML MASTER CLASS /// LEARN TAGS /// BUILD STRUCTURE /// SEMANTIC WEB /// HTML MASTER CLASS /// LEARN TAGS ///

Python Threading

Python's threading module, the GIL's real impact on it, and exactly when threads speed up your code versus when they silently do nothing.

⚔ Total XP: 0|šŸ’» python XP: 0

Skill Matrix

UNLOCK NODES BY LEARNING NEW TAGS.

System Hub

Core logic.

Quick Quiz //

Why does running cpu_heavy() on 4 threads take roughly the same time as running it 4 times sequentially?


šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.

threading is the most misunderstood concurrency tool in Python, precisely because of the GIL. This lesson draws the line clearly: threads are excellent for I/O-bound work and nearly useless for CPU-bound work on the standard interpreter — and shows you the tools (locks, thread safety) needed once you do use them correctly.

1The GIL Draws a Hard Line: I/O-Bound vs CPU-Bound

Python's Global Interpreter Lock (introduced in the What's New in Python 3.13+ lesson) means that, on the standard CPython interpreter, only one thread can execute Python bytecode at any given instant — regardless of how many CPU cores the machine has. This single fact determines almost everything about when threading helps and when it doesn't.

I/O-bound work — waiting on a network response, a disk read, a database query — spends most of its time *not* executing Python bytecode at all; it's blocked, waiting for something external. During that wait, the GIL is released (Python's I/O operations are specifically implemented to release it), letting another thread run. This is exactly why the fetch() example genuinely speeds up: three threads each spend most of their time blocked on requests.get(), and while one thread waits, another can be actively running.

CPU-bound work — the cpu_heavy() sum-of-squares loop — spends essentially all its time executing Python bytecode, never releasing the GIL voluntarily. Four threads all competing for the one GIL produce, at best, the same total throughput as one thread running sequentially, plus the real overhead of context-switching between threads — which is why the benchmark shows no speedup, and can occasionally show a measurable slowdown.

āœ•
—
+
import threading
import requests

def fetch(url):
    response = requests.get(url)
    print(f"{url}: {response.status_code}")

urls = ["https://a.com", "https://b.com", "https://c.com"]
threads = [threading.Thread(target=fetch, args=(u,)) for u in urls]
for t in threads: t.start()
for t in threads: t.join()
# All three requests happen CONCURRENTLY while waiting on network I/O
localhost:3000
Benchmark
I/O-bound (network): genuine speedup
CPU-bound (math loop): no speedup, same GIL contention

2Race Conditions and Locks: The Real Risk of Shared State

Even though the GIL prevents two threads from executing Python bytecode *simultaneously*, it does not make compound operations atomic. counter += 1 compiles to multiple separate bytecode instructions (load the current value, add one, store the result), and the GIL can switch to a different thread *between* any of those instructions — meaning two threads can both read the same starting value before either has written back its increment, and one of the two increments is silently lost. This is a genuine race condition, and it's exactly as real a bug in Python's threaded code as in any other language's.

threading.Lock, used as a context manager (with lock:), is the standard fix: it guarantees that only one thread can be inside the protected block at a time, making the read-modify-write sequence effectively atomic from the perspective of other threads competing for the same lock. Every thread attempting to acquire an already-held lock blocks until the holding thread releases it (automatically, when the with block exits) — this is precisely the guarantee context managers, covered earlier in this module, exist to provide reliably.

The professional discipline this motivates: any time multiple threads read *and write* the same mutable state, that access needs to be protected by a lock (or another synchronization primitive like threading.RLock, Semaphore, or Event) — 'the GIL protects me' is a common and incorrect assumption that leads directly to intermittent, hard-to-reproduce bugs that only manifest under specific timing conditions.

āœ•
—
+
import threading, time

def cpu_heavy():
    total = sum(i * i for i in range(20_000_000))

start = time.time()
threads = [threading.Thread(target=cpu_heavy) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(time.time() - start)  # roughly the SAME as running cpu_heavy() 4 times sequentially
localhost:3000
Correct Concurrent Access
with lock: counter += 1
400000 — correct, because the increment is now effectively atomic

3When to Actually Reach for threading

The practical decision rule follows directly from the GIL's behavior: use threading specifically for I/O-bound concurrency — multiple simultaneous network requests, multiple file reads, waiting on several slow external resources at once — where the wall-clock speedup comes from overlapping *waiting* time, not from genuine parallel computation. For CPU-bound work that needs real parallelism, multiprocessing (the next lesson) or the experimental free-threaded build are the correct tools, not threading on the standard interpreter.

It's also worth noting that threading predates asyncio (covered in the next-but-one lesson) as Python's answer to I/O-bound concurrency, and for new code, asyncio is frequently the more scalable choice — a single-threaded event loop handling thousands of concurrent I/O operations, versus one OS thread per concurrent operation with threading, which has real memory and context-switching overhead at high concurrency counts. threading remains the right tool specifically when integrating with existing synchronous, blocking libraries that have no async equivalent, or for genuinely simple, small-scale concurrent I/O.

A useful mental checklist before reaching for threading: is the bottleneck waiting on something external (I/O-bound — threading can help), or is it computing something (CPU-bound — threading on the standard interpreter will not help, and multiprocessing is the tool to reach for instead)?

āœ•
—
+
import threading

counter = 0
lock = threading.Lock()

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

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)  # 400000 -- correct, thanks to the lock
localhost:3000
Decision Rule
I/O-bound → threading or asyncio
CPU-bound → multiprocessing

4Step-by-Step Breakdown

Adding threads to a CPU-bound loop can make it SLOWER, not faster. Understanding why is the single most important thing to know before using Python threads at all.

Threads shine when work is I/O-bound: waiting on a network response is 'dead time' where another thread can run.

For CPU-bound work, the GIL means only one thread executes Python bytecode at a time — threading often provides NO speedup at all.

Checkpoint: Why does running cpu_heavy() on 4 threads take roughly the same time as running it 4 times sequentially?

  • →The GIL only allows one thread to execute Python bytecode at a time, so CPU-bound work does not parallelize across threads
  • →The operating system doesn't allow more than one Python thread to exist

When threads share mutable state, a race condition can corrupt data — a Lock ensures only one thread modifies it at a time.

Checkpoint: What would likely happen to the final counter value WITHOUT the lock in the increment() example?

  • →It would likely be less than 400000, due to a race condition on the shared counter variable
  • →The program would crash with a TypeError

Threading covers I/O-bound concurrency — multiprocessing is the tool for the CPU-bound case threads can't help with.

Judge Real Threading Suitability. Finish is_io_bound_speedup_expected(): threading helps I/O-bound work, not CPU-bound work.

Level Up šŸš€

Advanced cheat sheets, SEO tricks, and interview prep for this topic.

Browser Support

ChromeSupported

Fully supported (via server-side Python execution).

FirefoxSupported

Fully supported (via server-side Python execution).

SafariSupported

Fully supported (via server-side Python execution).

EdgeSupported

Fully supported (via server-side Python execution).

Best Practices

Reserve threading for I/O-bound work; never expect it to speed up CPU-bound loops

The GIL guarantees only one thread executes Python bytecode at a time on the standard interpreter — CPU-bound work needs multiprocessing (or the experimental free-threaded build) for genuine parallelism instead.

Protect every piece of shared mutable state accessed by multiple threads with a Lock

Compound operations like += are not atomic even under the GIL — unprotected concurrent read-modify-write access reliably produces intermittent, hard-to-reproduce race condition bugs.

Frequent Bugs

THE BUG

Adding threading.Thread to a CPU-bound function expecting a speedup, then being confused when performance is unchanged (or slightly worse due to context-switching overhead).

THE FIX

Profile first to determine whether the bottleneck is I/O-bound (threading can help) or CPU-bound (use multiprocessing instead) before reaching for threads as a performance fix.

Real-World Examples

Concurrently Fetching Multiple API Endpoints

A dashboard needs to call five independent, slow REST API endpoints and combine their results, and doing so sequentially would take the sum of all five response times.

import threading

results = {}

def fetch_and_store(name, url):
    results[name] = requests.get(url).json()

threads = [
    threading.Thread(target=fetch_and_store, args=("users", USERS_URL)),
    threading.Thread(target=fetch_and_store, args=("orders", ORDERS_URL)),
]
for t in threads: t.start()
for t in threads: t.join()
# Total time ~= slowest single request, not the sum of all requests

Interview Prep

?Frequently Asked Questions

Pascual Vila

Pascual Vila

Frontend Instructor // Code Syllabus

Common Pitfalls & Errors

The Error //

Incrementing or modifying shared state from multiple threads without a lock, producing an intermittent, hard-to-reproduce bug that only shows up under specific timing conditions.

# Wrong: race condition on shared counter def increment(): global counter for _ in range(100_000): counter += 1 # not atomic! # Correct: lock-protected def increment(): global counter for _ in range(100_000): with lock: counter += 1

The Solution //

Wrap every read-modify-write access to shared mutable state in a threading.Lock, or better, avoid shared mutable state between threads entirely where possible (e.g. using queue.Queue for thread-safe communication).

Lesson Glossary

[01]I/O-bound

Work whose bottleneck is waiting on external resources (network, disk, database) rather than CPU computation.

Code Preview
// I/O-bound context

[02]CPU-bound

Work whose bottleneck is active computation, spending most of its time executing instructions rather than waiting.

Code Preview
// CPU-bound context

[03]Race condition

A bug where the outcome depends on the unpredictable timing/interleaving of concurrent operations on shared state.

Code Preview
// Race condition context

[04]threading.Lock

A synchronization primitive ensuring only one thread executes a protected code block at a time.

Code Preview
// threading.Lock context

Continue Learning