Free lesson · Concurrency Interview Mastery
Creating Threads in Python
Python threading looks deceptively similar to Java threading - you make a thread, give it work, start it - but there's a twist lurking underneath that changes everything about when you'd actually use it. That twist is the Global Interpreter Lock, and it gets its own full lesson next. For now, learn the mechanics of creating and running threads in Python, and I'll flag where the GIL changes the calculus so the next lesson lands with context already in place.
Here's the honest framing before we start: in Python, threads are excellent for I/O-bound work (waiting on networks, files, databases) and nearly useless for CPU-bound work (heavy computation). That's not true in Java, and it's the single most important thing to internalize about Python concurrency. Keep it in mind as we go through the how-to, because the "how" is easy - the "when" is where Python is genuinely different.
The threading module
Everything lives in the threading module. The basic recipe: create a Thread, give it a target function, start() it.
import threading
def worker(name):
print(f"Running on: {threading.current_thread().name}, arg={name}")
t = threading.Thread(target=worker, args=("hello",))
t.start() # starts a NEW thread, runs worker() on it
t.join() # wait for it to finish
target is the function to run, args is a tuple of positional arguments (note the trailing comma for a single arg - ("hello",) is a tuple, ("hello") is just a string). There's also kwargs for keyword arguments. This function-and-args style is the idiomatic way; you rarely subclass.
Subclassing Thread (the less common way)
You can subclass Thread and override run(), the way you might in Java:
class Worker(threading.Thread):
def __init__(self, name):
super().__init__()
self.name_arg = name
def run(self):
print(f"Working on {self.name_arg}")
w = Worker("task-1")
w.start()
w.join()
This is occasionally useful when a thread needs to carry state or you want a named, self-contained unit. But for most code, passing a target function is cleaner and more Pythonic. Reach for subclassing only when the thread genuinely needs its own encapsulated state and behavior.
Warning: The same start-vs-run trap from Java applies here: calling
t.run()directly executesrun()synchronously on the current thread - no new thread is created. Onlyt.start()spins up a real OS thread and invokesrun()on it. If your threads appear to execute strictly one after another in order, you almost certainly calledrun()instead ofstart(). This is a classic interview gotcha across every threading language, Python included.
flowchart LR
A["t.start()"] --> B[New OS thread created]
B --> C["run()/target executes on NEW thread"]
D["t.run()"] --> E["target executes on CURRENT thread<br/>no new thread - fully synchronous"]
This flowchart contrasts start and run in Python. Calling t dot start creates a new operating system thread, and the target function or run method executes on that new thread. Calling t dot run directly creates no new thread - the target simply executes on the current thread, synchronously, like an ordinary function call. Only start produces real concurrency.
Thread essentials
The everyday Thread API you'll use constantly:
start()- launch the thread; runstarget(orrun()) on a new OS thread.join(timeout=None)- block the caller until the thread finishes, or until the timeout elapses. Withoutjoin, the main program may exit (or move on) before the thread is done.is_alive()- has it started and not yet finished?name- set viaThread(name="worker-1")ort.name = .... Named threads make logs and debugging readable; do it in real code.daemon- set viaThread(daemon=True)ort.daemon = Truebeforestart(). Daemon threads are abandoned when the main program exits; non-daemon (the default) keep the program alive until they finish.
t = threading.Thread(target=worker, args=("x",), name="worker-1", daemon=True)
t.start()
t.join(timeout=5) # wait up to 5 seconds
print(t.is_alive()) # still running?
Warning: Daemon threads are killed abruptly when the main program exits - mid-operation, with no cleanup, no
finallyblocks guaranteed to run. Never put work that must complete (flushing a buffer, writing important data) on a daemon thread, or you'll get the maddening "sometimes the last bit of data doesn't get written" bug that depends on exactly when the main thread happens to finish. Daemons are for disposable background chores only.
Getting a result back from a thread
Here's an awkward gap that surprises people coming from other languages: a Thread's target function's return value is thrown away. There's no built-in Future on a raw Thread. If you need a result, you have three options:
# Option 1: a shared mutable container (simple, but you manage it)
results = {}
def worker():
results["answer"] = compute()
t = threading.Thread(target=worker)
t.start(); t.join()
print(results["answer"])
# Option 2 (preferred): concurrent.futures, which gives you a real Future
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor() as pool:
future = pool.submit(compute)
print(future.result()) # clean return value
Option 2 - concurrent.futures - is the idiomatic modern approach and gets its own lesson. For most "run this and give me the answer" needs, prefer it over raw threads. Raw Thread is for fire-and-forget background work or when you need fine control over the thread itself.
A first look at locks
Because Python threads share memory (same as any threading model), shared mutable state needs protection - the race conditions from Module 1 are fully in play. The basic tool is threading.Lock:
lock = threading.Lock()
counter = 0
def increment():
global counter
with lock: # acquire on enter, release on exit (even on exception)
counter += 1 # critical section
The with lock: context manager is the idiomatic form - it acquires the lock on entry and releases it on exit, including if an exception is thrown, so you can't leak the lock. This is Python's equivalent of a synchronized block, and the with statement gives you the automatic-release safety that raw lock.acquire()/lock.release() doesn't. We cover locks and the rest of the synchronization toolbox in depth in later modules; here, just know that with lock: is how you guard a critical section.
Note: You might wonder: "if the GIL only lets one thread run Python bytecode at a time, why do I still need locks?" Because the GIL does not make compound operations atomic.
counter += 1is still read-modify-write - multiple bytecode operations - and the GIL can be released between those operations, letting another thread interleave. Socounter += 1across threads is still a race even with the GIL. The GIL prevents true parallel bytecode execution; it does not prevent interleaving. You still need locks for compound operations. We'll make this precise in the GIL lesson.
Thread-local data
One handy tool: threading.local() gives each thread its own private copy of some data, even though the object is shared. Useful for per-thread state like a database connection or a request context that shouldn't be shared across threads.
local_data = threading.local()
def worker():
local_data.value = compute_for_this_thread() # private to each thread
use(local_data.value)
Each thread sees its own local_data.value, with no interference - a clean way to avoid sharing where sharing isn't wanted.
Where the GIL changes everything
Now the part that makes Python threading unique. Because of the GIL, multiple Python threads cannot execute Python bytecode in parallel - only one runs Python code at any instant, even on a 16-core machine. This means:
- For CPU-bound work (number crunching, pure-Python loops), threading gives you no speedup and often a slight slowdown from the overhead of switching. Two threads doing heavy computation share one core's worth of Python execution.
- For I/O-bound work (network requests, file reads, database queries), threading works great - because the GIL is released during blocking I/O, so while one thread waits on the network, another thread runs Python code. Here threading delivers real concurrency and real throughput gains.
Important: The Python threading rule, burned in: use threads for I/O-bound work, not CPU-bound work. If your threads spend their time waiting (on networks, disks, APIs), threading is the right tool and scales nicely. If your threads spend their time computing, threading won't help - you need
multiprocessing(separate processes, true parallelism) instead. This is the most important practical fact about Python concurrency, and the next two lessons unpack exactly why and what to do about it.
A real example: concurrent I/O
Let me show threading earning its keep, because the abstract "good for I/O" claim becomes obvious once you see it. Suppose you need to fetch five URLs, each taking about a second of network wait.
import threading, time
def fetch(url):
print(f"fetching {url}")
time.sleep(1) # stands in for ~1s of network wait
print(f"done {url}")
urls = ["u1", "u2", "u3", "u4", "u5"]
# Sequential: ~5 seconds total
start = time.time()
for u in urls:
fetch(u)
print(f"sequential: {time.time() - start:.1f}s") # ~5.0s
# Threaded: ~1 second total
start = time.time()
threads = [threading.Thread(target=fetch, args=(u,)) for u in urls]
for t in threads: t.start()
for t in threads: t.join()
print(f"threaded: {time.time() - start:.1f}s") # ~1.0s
Sequentially the five fetches take about five seconds - one after another. With threads they take about one second total, because while each thread waits on its (simulated) network call, the GIL is released and the others proceed. Five seconds of mostly-waiting work collapses to roughly one. This is the canonical case where Python threading delivers a real, dramatic win - and notice it required no locks, because the threads don't share mutable state, they just each do their own I/O.
Important: The speedup here comes entirely from overlapping waiting time, not from parallel computation. If
fetchwere a CPU-bound calculation instead of asleep, the threaded version would be no faster than sequential - the threads would serialize on the GIL. Same code structure, completely different outcome depending on whether the work waits or computes. This is the GIL lesson made concrete one lesson early, and it's worth running yourself to feel it.
Other synchronization tools you'll meet
Lock is the first synchronization primitive, but the threading module ships several more that you'll see in later modules. A quick orientation so the names aren't strangers:
RLock(reentrant lock) - a lock the same thread can acquire multiple times without deadlocking itself; the lock counts acquisitions and only releases when the count returns to zero. Use it when a method holding the lock calls another method that also needs it.Event- a simple flag for signaling between threads: one threadset()s it, otherswait()until it's set. Good for "wait until initialization is done" coordination.Condition- a lock combined with the ability towait()for a condition andnotify()waiting threads; the basis of producer-consumer signaling (we build on this heavily later).Semaphore- maintains a counter of permits; threadsacquire()andrelease()to cap how many run a section concurrently (e.g., limit to 10 simultaneous connections).Barrier- makes a set of threads wait for each other at a point, then release together.
# Event example - signal readiness across threads
ready = threading.Event()
def worker():
ready.wait() # block until the event is set
print("go!")
threading.Thread(target=worker).start()
ready.set() # release all waiters
You don't need to master these now - each gets proper treatment in the synchronization-primitives and patterns modules - but recognize that Python gives you the full coordination toolkit, and with lock: is just the entry point. The concepts are identical to every other language in this course; only the import path changes.
Best practices and common pitfalls
A few hard-won habits that will keep your Python threading code out of trouble, drawn from the kinds of bugs that actually reach production.
First, prefer the high-level interface. Reaching straight for raw Thread objects is rarely the right call in application code. concurrent.futures.ThreadPoolExecutor (covered fully in its own lesson) gives you pooling, clean result handling through futures, and automatic lifecycle management, all of which you'd otherwise build by hand and get subtly wrong. Raw Thread is for the cases where you genuinely need control over an individual long-lived thread; for "run these tasks concurrently and collect results," the executor wins.
Second, always guard shared mutable state, and remember the GIL does not absolve you of this. The most seductive Python threading bug is assuming the GIL makes your code thread-safe. It does not. Any time two threads read and write the same variable, list, dictionary, or object attribute, and at least one writes, you have a potential race that the GIL will not save you from - because the GIL can release between the bytecodes of a compound operation. If you find yourself sharing state, wrap every access in with lock: or switch to a thread-safe structure like queue.Queue, which is designed for safe handoff between threads and is the cleanest way to move data from producer threads to consumer threads without managing locks yourself.
Third, name your threads and handle their exceptions. An unhandled exception inside a thread's target function kills that thread quietly - it does not crash the program, and by default it can be easy to miss in the logs. In long-running services this produces the baffling symptom of work silently stopping. Give threads meaningful names so thread dumps and logs are readable, and make sure each thread's top-level function catches and logs its own exceptions rather than letting them vanish.
Finally, be deliberate about joining and shutdown. A program that spawns threads and exits without joining them, or without shutting down its executors, can either lose in-flight work or hang at exit waiting on non-daemon threads. Decide for each thread whether its work must complete (join it, keep it non-daemon) or is disposable background activity (daemon, abandoned at exit), and structure your shutdown accordingly. Vague lifecycle management is a frequent source of "it works in development but behaves strangely in production" reports.
The interview angle
Python concurrency interviews almost always probe whether you understand the GIL's consequences, even in a "basic threading" question.
Interview: Likely questions: (1) "How do you create a thread in Python?" -
threading.Thread(target=fn, args=(...))thenstart(); mention you'd usually useconcurrent.futuresfor results. (2) "start()vsrun()?" -start()creates a new thread;run()executes synchronously on the current thread. (3) "Does the GIL mean you don't need locks?" - No; the GIL prevents parallel bytecode execution but not interleaving, so compound operations likecount += 1are still races and still need a lock. (4) "When is Python threading actually useful?" - I/O-bound workloads, where the GIL is released during blocking calls; not CPU-bound work. Answering (3) and (4) correctly is what separates people who know Python concurrency from those who've only seen the syntax.
Quick reference
import threading
# create & run
t = threading.Thread(target=fn, args=(a, b), name="w1", daemon=False)
t.start()
t.join(timeout=None)
# guard shared state
lock = threading.Lock()
with lock:
shared += 1 # still a race without the lock - GIL doesn't make it atomic
# per-thread private state
tls = threading.local()
# get a result back (preferred)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor() as pool:
print(pool.submit(fn).result())
# RULE: threads for I/O-bound; multiprocessing for CPU-bound
Next, we confront the thing that's been hovering over this whole lesson directly: the Global Interpreter Lock - what it is, why it exists, and why it forces Python's entire concurrency strategy down a different road than every other language in this course.