AlgoInsight

Free lesson · Concurrency Interview Mastery

Creating Threads in Python

Medium10 min readconcurrencymultithreadingpython

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 executes run() synchronously on the current thread - no new thread is created. Only t.start() spins up a real OS thread and invokes run() on it. If your threads appear to execute strictly one after another in order, you almost certainly called run() instead of start(). 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:

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 finally blocks 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 += 1 is still read-modify-write - multiple bytecode operations - and the GIL can be released between those operations, letting another thread interleave. So counter += 1 across 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:

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 fetch were a CPU-bound calculation instead of a sleep, 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:

# 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=(...)) then start(); mention you'd usually use concurrent.futures for results. (2) "start() vs run()?" - 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 like count += 1 are 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.

Back to Concurrency Interview Mastery