Python threading gives you real operating system threads managed through a clean Python API. When you create a threading.Thread object and call its start() method, Python asks the operating system to create a new thread of execution that runs independently alongside the main thread. The operating system schedules these threads across available CPU cores, preempting them as needed.
Python wraps this OS-level behaviour in the threading module so you work with Python objects and callables rather than platform-specific thread APIs. If you have already compared the three concurrency models, you know threading is the right choice for moderate-concurrency I/O-bound work, and understanding how it operates under the hood helps you use it correctly.
The threading module sits on top of the lower-level _thread module, which provides the raw interface to the operating system's threading primitives. You rarely need to use _thread directly. The threading module adds useful abstractions: the Thread class for managing thread lifecycle, Lock and RLock for mutual exclusion, Event and Condition for signaling between threads, Semaphore for limiting concurrent access, and Barrier for synchronizing groups of threads.
This architecture gives you building blocks that compose into reliable concurrent programs without manually managing OS thread handles or platform-specific synchronization primitives.
The thread lifecycle
Every Python thread moves through a predictable set of states from creation to completion. Understanding this lifecycle helps you reason about what your threads are doing at any moment and prevents bugs where you interact with a thread that has already finished or has not yet started.
A thread begins in the created state after you instantiate threading.Thread. At this point, the thread object exists in Python but no operating system thread has been created. You can configure the thread's target function, arguments, name, and daemon flag before starting it, but the thread is not yet eligible to run.
When you call start(), the thread transitions to the started state. Python requests an OS thread from the operating system. Once the OS allocates the thread, it becomes runnable, meaning it is eligible for CPU time.
The OS scheduler decides when the thread actually runs. There is no guarantee that start() returns before the new thread begins executing; the main thread and the new thread run concurrently from that point.
During execution, a thread can voluntarily yield or be preempted by the OS. The thread gives up the CPU when it calls time.sleep(), waits on a lock, performs I/O, or calls a function that releases the GIL. The OS can also preempt the thread at any instruction boundary to give another thread a turn.
A thread finishes when its target function returns or raises an unhandled exception. At that point, the thread enters the finished state. A finished thread cannot be restarted.
Calling start() on a thread that has already run raises a RuntimeError. If you need to run the same work again, create a new Thread object.
import threading
def worker(name):
print(f"{name} is running")
t = threading.Thread(target=worker, args=("Worker-1",))
print(f"Before start: t.is_alive() = {t.is_alive()}")
t.start()
print(f"After start: t.is_alive() = {t.is_alive()}")
t.join()
print(f"After join: t.is_alive() = {t.is_alive()}")The is_alive() checks around start() and join() show the thread reporting False before it starts, True while worker is running, and False again once join() confirms it has finished:
Before start: t.is_alive() = False
After start: t.is_alive() = True
Worker-1 is running
After join: t.is_alive() = Falseis_alive() returns True from the moment start() is called until the thread's target function finishes. After join() returns, the thread is guaranteed finished and is_alive() returns False.
How the GIL controls thread execution
The Global Interpreter Lock is the central constraint on Python threading. It is a mutex that must be held by any thread that wants to execute Python bytecode. The interpreter acquires the GIL when a thread starts executing Python code and releases it periodically to give other threads a chance to run.
In CPython 3.2 and later, the GIL uses a timing-based switch interval. A thread that holds the GIL can execute for a short period, 5 milliseconds by default, before it must check whether another thread is waiting. If another thread is waiting, the current thread releases the GIL and signals the waiting thread.
You can inspect the switch interval with sys.getswitchinterval() and change it with sys.setswitchinterval(), though the default is well-tuned for most workloads.
The critical detail for I/O-bound programs is that the GIL is released during blocking I/O calls. When a thread calls socket.recv(), file.read(), time.sleep(), or any function that blocks waiting for an external resource, the Python C code releases the GIL before entering the blocking call. The thread then blocks at the OS level, and the interpreter can grant the GIL to another thread.
When the I/O operation completes, the thread reacquires the GIL before returning to Python code. This mechanism is automatic; you do not need to insert GIL release points in your code.
import threading
import time
def cpu_task():
"""Pure Python computation. Holds the GIL continuously."""
total = 0
for i in range(10_000_000):
total += i
return total
def io_task():
"""I/O operation. Releases the GIL while sleeping."""
time.sleep(0.5)
return "done"When cpu_task runs, no other thread can execute Python bytecode until cpu_task finishes its loop or the GIL switch interval elapses. When io_task runs, the GIL is released as soon as time.sleep() is called, and another thread can immediately start running Python code.
This distinction is why threading speeds up programs that spend most of their time in I/O but does not speed up programs that spend most of their time in pure Python computation. The next article on creating and managing Python threads shows you how to structure threaded programs so that I/O and CPU work are clearly separated.
The Thread class in detail
The threading.Thread class is the primary interface for creating and controlling threads. Its constructor accepts several parameters that define the thread's behaviour:
threading.Thread(
group=None,
target=None,
name=None,
args=(),
kwargs={},
daemon=None,
)target is the callable that the thread executes. If you do not provide a target, you can subclass Thread and override its run() method. args is a tuple of positional arguments passed to the target.
kwargs is a dictionary of keyword arguments. name is a string identifier for the thread, useful for debugging and logging. If not provided, Python assigns a name like Thread-1, Thread-2, and so on.
daemon is a boolean that determines whether the thread is a daemon thread. Daemon threads are background threads that do not keep the Python interpreter alive. When only daemon threads remain, the interpreter exits without waiting for them.
Non-daemon threads, the default, prevent the interpreter from exiting until they complete. Set daemon threads for background tasks that should not block program shutdown.
The Thread class also provides a ident attribute, which is a unique integer assigned by the OS when start() is called, and a native_id attribute, which is the OS-level thread identifier. These are useful for correlating Python threads with system monitoring tools.
import threading
def show_thread_info():
t = threading.current_thread()
print(f"Name: {t.name}")
print(f"Ident: {t.ident}")
print(f"Native ID: {t.native_id}")
print(f"Daemon: {t.daemon}")
t = threading.Thread(target=show_thread_info, name="DemoThread")
t.start()
t.join()threading.current_thread() returns the Thread object for the currently executing thread. threading.main_thread() returns the Thread object for the main thread. threading.active_count() returns the number of threads that are currently alive, which includes the main thread.
Daemon threads and process shutdown
Daemon threads are the simplest concurrency pattern for background work. A daemon thread runs independently and is terminated abruptly when the main program exits. You do not join() a daemon thread; you start it and let it run until the program finishes.
import threading
import time
def heartbeat():
while True:
print("System is alive.")
time.sleep(2)
monitor = threading.Thread(target=heartbeat, daemon=True)
monitor.start()
time.sleep(5)
print("Main program exiting. Daemon thread stops.")The heartbeat keeps printing on its own two-second schedule until the five-second time.sleep() in the main thread ends the program, cutting the daemon thread off mid-cycle rather than letting it finish a fourth print:
System is alive.
System is alive.
System is alive.
Main program exiting. Daemon thread stops.The daemon thread prints every 2 seconds and would run forever. When the main thread finishes after 5 seconds, the interpreter exits and the daemon thread stops, even though it was in the middle of time.sleep(). No cleanup code in the daemon thread runs; the thread is terminated without notice.
This abrupt termination makes daemon threads unsuitable for tasks that hold resources or must complete cleanly. If a daemon thread is writing to a file, a database, or a network socket when the interpreter exits, that data may be corrupted or lost. Use daemon threads only for tasks where losing the work on shutdown is acceptable, such as periodic monitoring, metrics collection, or cache warming.
For tasks that must complete before the program exits, use regular threads and join() them. The next article covers the join() pattern in detail. If a regular thread is still running when the main thread finishes, the interpreter waits for it.
The program only exits when every non-daemon thread has completed.
Thread naming and debugging
Thread names are visible in tracebacks, log messages, and debugger output. Giving each thread a descriptive name makes concurrent bugs easier to diagnose. When a thread raises an unhandled exception, the traceback includes the thread name, which helps you identify which concurrent path failed.
import threading
import logging
import time
logging.basicConfig(
level=logging.DEBUG,
format="%(threadName)s - %(message)s",
)
def process_order(order_id):
logging.debug("Processing order %d", order_id)
time.sleep(0.1)Passing name=f"OrderProcessor-{i}" when creating each thread is what makes the log format string able to print a distinct, readable label per thread instead of the generic Thread-1, Thread-2 names Python assigns by default:
threads = []
for i in range(3):
t = threading.Thread(
target=process_order,
args=(i,),
name=f"OrderProcessor-{i}",
)
threads.append(t)
t.start()
for t in threads:
t.join()Each log line is tagged with the name of the thread that produced it, so you can tell the three orders apart even though they were logged from three different threads running at once:
OrderProcessor-0 - Processing order 0
OrderProcessor-1 - Processing order 1
OrderProcessor-2 - Processing order 2The logging module includes the thread name automatically when you use the %(threadName)s format specifier. This makes it easy to trace which thread produced each log line without adding manual thread identification to every log call.
You can also enumerate all running threads with threading.enumerate(), which returns a list of live Thread objects. This is useful for monitoring and debugging: you can check whether threads you expected to finish are still running, or count how many threads your program has spawned.
Thread-local storage
When multiple threads share the same function, you sometimes need each thread to have its own copy of a variable. threading.local() creates a namespace where attributes are thread-specific. Each thread sees only the values it set.
import threading
thread_data = threading.local()
def process():
thread_data.request_id = threading.current_thread().name
print(f"{thread_data.request_id} processing")
threads = [threading.Thread(target=process) for _ in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()Each thread sets thread_data.request_id to its own name. When a thread reads thread_data.request_id, it sees the value it set, not the values set by other threads. There is no lock needed because each thread accesses its own storage.
Thread-local storage is the simplest way to manage per-thread state like database connections, request contexts, or random number generators. It avoids the complexity of passing state through every function call or using global dictionaries keyed by thread ID.
The threading module's synchronization primitives
The threading module provides several synchronization primitives beyond the basic Thread class. These are the building blocks for coordinating work between threads:
-
Lock: A basic mutual exclusion primitive. Only one thread can hold a lock at a time. Use it to protect shared mutable state from concurrent access. The article on synchronizing Python threads with locks covers locks in detail.
-
RLock: A reentrant lock that can be acquired multiple times by the same thread. Useful when a function that acquires a lock calls another function that also acquires the same lock.
-
Event: A simple signal that one thread sets and other threads wait for. Use it when one thread needs to notify others that a condition has been met, like data being ready to process.
-
Condition: A more flexible signaling mechanism that combines a lock with wait and notify semantics. Use it for producer-consumer patterns where threads wait for a specific state change.
-
Semaphore: A counter that limits how many threads can access a resource concurrently. Use it to cap the number of concurrent database connections, file handles, or API calls.
-
Barrier: A synchronization point where a fixed number of threads must all arrive before any can proceed. Use it when a group of threads must all complete a phase before moving to the next.
Each primitive is designed for a specific coordination pattern. Using the right primitive for your problem produces code that is simpler and less error-prone than building coordination from raw locks.
Common threading pitfalls before you write code
The most frequent threading bugs come from assumptions that hold in single-threaded code but break in concurrent code. Knowing them ahead of time saves hours of debugging.
The += operator on a shared variable is not atomic. count += 1 reads the value, increments it, and writes it back. If two threads do this simultaneously, one increment can be lost.
Always protect shared mutable state with a lock.
Joining threads in the wrong order can cause your program to stall. If thread A waits for thread B and thread B waits for thread A, neither can proceed. Always join threads in a consistent order, or use threading.enumerate() to wait for all threads without depending on order.
Exceptions raised in a thread do not propagate to the main thread. If a worker thread raises an unhandled exception, it terminates silently. The main thread continues running unaware that the worker failed.
Capture exceptions in the worker function and communicate failures through a shared queue, an Event, or a return-value structure.
Creating too many threads consumes memory and degrades performance. Each thread needs stack space, and the OS spends CPU time context-switching between them. For I/O-bound work with more than a few hundred concurrent tasks, consider a thread pool (concurrent.futures.ThreadPoolExecutor) or asyncio.
For CPU-bound work, limit threads to the number of available cores, and use multiprocessing instead of threading.
Rune AI
Key Insights
- Python threads are real OS threads managed through the threading module, with start(), join(), and a well-defined lifecycle.
- The GIL prevents parallel Python execution but is released during blocking I/O, which is why threading helps I/O-bound programs.
- Daemon threads are background threads that do not block process exit; regular threads keep the interpreter alive until they finish.
- A thread transitions through states: created, started (runnable), running, waiting/blocked, and finished.
- The Thread class stores identity, daemon status, and target callable; always use join() to wait for a thread to complete before accessing its results.
Frequently Asked Questions
How does the Python GIL interact with threads during I/O?
What is the difference between a daemon thread and a regular thread in Python?
How many threads can I create in a Python program?
Conclusion
Python threading is a thin, well-designed layer over operating system threads. Understanding the thread lifecycle, the GIL release mechanism during I/O, and the difference between daemon and regular threads gives you the mental model needed to use threading correctly. The next articles build on this foundation with practical examples of creating threads, managing their lifetimes, and synchronizing access to shared data.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.