python multithreading, via the python threading module, lets a program run multiple threads concurrently within a single process, sharing the same memory space. This article covers creating and managing threads, the Global Interpreter Lock (including where things genuinely stand with free-threaded Python as of mid-2026), race conditions and synchronization, and the modern, higher-level ThreadPoolExecutor approach.
Introduction: What Multithreading Is and When It Helps
What threading actually gives you
The threading module lets a single Python program run multiple threads at once, all sharing the same process memory space — meaning threads can directly read and write the same variables and objects, without the more involved inter-process communication that separate processes would require.
The key distinction that shapes this entire topic
Threading shines specifically for I/O-bound tasks — file operations, network requests, database queries — where a significant portion of the actual time is spent waiting (for a disk, for a network response, for a database) rather than actively computing. This distinction matters enormously, and it's covered in full in Section 3, once the Global Interpreter Lock has been properly explained.
What this article covers
Creating and running threads, the Global Interpreter Lock and exactly what it does and doesn't allow, thread synchronization with locks (and the race conditions that make synchronization necessary), and concurrent.futures.ThreadPoolExecutor as the modern, considerably more manageable way to work with many threads at once.
Creating and Running Threads
Two ways to create a thread
import threading
import time
def download_file(name):
print(f"Starting download: {name}")
time.sleep(2) # simulating a slow I/O operation
print(f"Finished download: {name}")
# Approach 1: passing a target function
thread = threading.Thread(target=download_file, args=("report.pdf",))# Approach 2: subclassing Thread and overriding run()
class DownloadThread(threading.Thread):
def __init__(self, name):
super().__init__()
self.name_to_download = name
def run(self):
print(f"Starting download: {self.name_to_download}")
time.sleep(2)
print(f"Finished download: {self.name_to_download}")
thread = DownloadThread("report.pdf")The first approach — passing a target function — is simpler and more common for straightforward cases. Subclassing Thread is useful when a thread genuinely needs its own additional state or methods beyond just the function it runs.
The essential lifecycle: start() and join()
thread = threading.Thread(target=download_file, args=("report.pdf",))
thread.start() # begins execution, running concurrently with the rest of your program
print("This might print before the download finishes")
thread.join() # blocks here until the thread actually completes
print("This prints only after the thread has finished").start() begins the thread's execution — your main program continues running immediately afterward, concurrently with the new thread, rather than waiting for it. .join() blocks the calling code until that specific thread finishes — genuinely important whenever you need to be certain a thread's work is complete before proceeding (for instance, before reading a result that thread was responsible for producing).
A practical example: concurrent simulated downloads
import threading
import time
def download_file(name, results):
time.sleep(2)
results[name] = f"{name} downloaded successfully"
results = {}
files = ["a.pdf", "b.pdf", "c.pdf"]
threads = []
for file in files:
t = threading.Thread(target=download_file, args=(file, results))
threads.append(t)
t.start()
for t in threads:
t.join() # wait for every thread to finish before continuing
print(results)
# {'a.pdf': 'a.pdf downloaded successfully', 'b.pdf': '...', 'c.pdf': '...'}All three downloads run concurrently — the total time is roughly 2 seconds (however long the slowest single download takes), not 6 seconds (which is what running them one after another, sequentially, would take) — a genuine, real benefit for exactly this kind of I/O-bound, wait-heavy work.
The Global Interpreter Lock (GIL): What It Is and Why It Exists
A plain-language definition
The GIL is a mutex (a lock) ensuring that only one thread executes Python bytecode at a time, even on a machine with many CPU cores. This is the single most important, and most frequently misunderstood, fact about Python threading.
Why it exists
The GIL exists primarily to simplify CPython's memory management — specifically, its reference-counting garbage collector. Without some form of locking, two threads simultaneously incrementing or decrementing an object's reference count could genuinely corrupt memory. The GIL sidesteps this entire class of problem by ensuring only one thread ever executes Python-level code at any given instant, which also happens to make built-in mutable data structures and C extensions safely usable without each of them needing their own separate, explicit locking logic.
The practical consequence
Because of the GIL, threading gives genuine, real concurrency benefits for I/O-bound work specifically, because a thread releases the GIL while it's waiting on I/O — a network request, a file read, a time.sleep() call — letting another thread run Python code during that waiting period. This is exactly why the download example in Section 2 achieved real, measurable concurrency.
But the GIL means threading does not speed up pure CPU-bound work — code that's genuinely computing continuously, with no I/O waiting involved at all. Because only one thread can execute Python bytecode at any given moment regardless of how many CPU cores are available, running four CPU-intensive calculations across four threads on a traditional (GIL-enabled) Python build provides essentially no speedup over running them sequentially — for that specific kind of workload, multiprocessing (covered in a later article in this series) remains the standard advice, since it uses genuinely separate processes, each with its own independent interpreter and GIL, sidestepping the single-lock limitation entirely.
An honest, current update: free-threaded Python
This is worth covering carefully and accurately, since it's a genuinely significant, actively evolving part of Python's story as of 2026. PEP 703 introduced an optional, separate build configuration of CPython with the GIL disabled entirely, shipped experimentally starting with Python 3.13 in late 2024. With Python 3.14, PEP 779 was accepted, moving free-threaded Python from "experimental" to officially supported — a genuinely significant milestone — though it remains an optional, non-default build (you specifically opt into it, commonly by installing a build identified with a t suffix, such as python3.14t), not the standard Python you get by simply installing Python normally.
A few honest, practical caveats worth knowing if you're actually considering it: the free-threaded build historically carried a real single-threaded performance penalty (roughly 40% slower in the initial 3.13 experimental build, reduced considerably to somewhere in the 5–10% range by 3.14 as the specializing interpreter was re-enabled), and a meaningful portion of the C-extension ecosystem — including some popular, widely used packages — still needs explicit updates to be fully thread-safe without the GIL's implicit protection. As of mid-2026, there's no committed timeline for the GIL being disabled by default in a standard Python installation — that's expected to be a considerably later milestone, plausibly not before 2028 at the earliest, based on current public discussion. For genuinely CPU-bound, parallelism-hungry workloads, it's worth experimenting with the free-threaded build directly — but treat it as a promising, actively maturing option to evaluate for your specific workload, not yet the default, universal answer for everyday Python development.
Race Conditions and Synchronization with Locks
What a race condition is
A race condition occurs when multiple threads modify shared state, and the final outcome depends on unpredictable timing — which thread happens to run exactly when. This is a genuine risk even for something that looks deceptively simple, like counter += 1 — that single line is actually three separate steps at the bytecode level (read the current value, add one, write the new value back), and two threads can interleave those steps in a way that causes one increment to be silently lost entirely.
import threading
counter = 0
def increment():
global counter
for _ in range(100_000):
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) # often NOT 400000, due to the race condition described aboveUsing threading.Lock
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # Lock is usable as a context manager, exactly per the earlier with statement article
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) # correctly 400000, every timewith lock: creates a critical section — a block of code only one thread can execute at a time. Any other thread that reaches with lock: while another thread already holds it simply waits until the lock is released, guaranteeing the increment happens as a genuinely atomic, uninterrupted operation.
Other synchronization primitives, briefly
threading provides several other synchronization tools worth knowing exist, beyond the basic Lock: RLock (a reentrant lock, allowing the same thread to acquire it multiple times without deadlocking itself — useful in recursive functions that need locking), Semaphore (limiting how many threads can access a resource simultaneously, rather than strictly one at a time), and Condition (letting threads wait for a specific condition to become true before proceeding, useful for coordinating more complex handoffs between threads).
For the common producer-consumer pattern — one or more threads producing data, one or more threads consuming it — queue.Queue is generally the better tool than manually sharing and locking data directly: it's a thread-safe queue implementation, built specifically for exactly this pattern, that handles the necessary locking internally on your behalf.
Managing Multiple Threads Cleanly with ThreadPoolExecutor
The problem with manual thread management
Manually creating, tracking, starting, and joining individual Thread objects (as shown in Section 2) gets genuinely unwieldy fast, once you're managing more than a couple of threads at once — tracking a growing list of thread objects, remembering to join every single one, and manually collecting results all adds real, repetitive boilerplate.
concurrent.futures.ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor
import time
def download_file(name):
time.sleep(2)
return f"{name} downloaded successfully"
files = ["a.pdf", "b.pdf", "c.pdf"]
with ThreadPoolExecutor(max_workers=3) as executor:
results = list(executor.map(download_file, files))
print(results)
# ['a.pdf downloaded successfully', 'b.pdf downloaded successfully', 'c.pdf downloaded successfully']ThreadPoolExecutor manages a pool of worker threads for you — .map() distributes each item in files to an available thread in the pool, collecting the results in the same order they were submitted, all without you manually creating, starting, or joining a single Thread object yourself. The with block guarantees the pool is properly shut down once you're finished with it, exactly the same guarantee covered in the earlier with statement article.
Handling results as they complete, with as_completed()
If you'd rather process results as soon as each one finishes, rather than waiting for every task in strict submission order:
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_url(url):
import time
time.sleep(2)
return f"Fetched {url}"
urls = ["https://a.com", "https://b.com", "https://c.com"]
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(fetch_url, url) for url in urls]
for future in as_completed(futures):
print(future.result())as_completed() yields each future (representing a submitted task) as soon as it finishes — genuinely useful when different tasks might take meaningfully different amounts of time, and you'd rather start processing the fastest ones immediately, rather than waiting for the slowest one before seeing any results at all.
Closing guidance
Reach for threading — ideally through the higher-level, more manageable ThreadPoolExecutor interface rather than manually managing individual Thread objects — specifically for I/O-bound concurrency: network requests, file operations, database queries, anything genuinely spending meaningful time waiting rather than computing. Reach for multiprocessing instead when your workload is genuinely CPU-bound and needs to bypass the GIL's single-thread-at-a-time bytecode execution limitation entirely — or, if you're working on a genuinely parallelism-hungry CPU-bound workload and comfortable evaluating a still-maturing option, it's worth experimenting with a free-threaded Python build directly, understanding the honest caveats and current status covered in Section 3.