skip to content

What is the difference between Thread.start() and Thread.run() on a threading.Thread?

level: juniorimportance: must knowfreq 70%

answer

  1. One of them is just a method call
  2. Only one asks the OS for anything
  3. Same speed as sequential is the tell
  4. Thread objects are single-use
  5. join() hands back no value

basics

~20 s

Thread.start() spawns a new operating-system thread and returns immediately; Thread.run() merely executes the work on the calling thread, with no concurrency at all. Each Thread object may be started once — a second start() raises RuntimeError.

solid answer

~40 s

A `threading.Thread` object is just a handle; the OS thread does not exist until `start()` is called. `start()` creates the thread and arranges for `run()` to execute inside it, then returns to the caller straight away. `run()` is an ordinary method: its default body calls the `target` callable with `args` and `kwargs`, and calling it yourself simply runs that work synchronously on the current thread — the classic bug behind "threaded" code that is exactly as slow as sequential code. After `start()` you use `join(timeout=None)` to block until the thread finishes and `is_alive()` to test whether it has. `join()` always returns `None`, so a worker's result has to travel through shared state, not a return value. Thread objects are single-use: `start()` on an already-started object raises `RuntimeError`, and there is no restart.

code

python · 11 lines
python
import threading

def work(label):
    print(label, "->", threading.current_thread().name)

threading.Thread(target=work, args=("run()",)).run()

t = threading.Thread(target=work, args=("start()",), name="worker-1")
t.start()
t.join()
print("alive after join:", t.is_alive())

go deeper

for a junior

Be ready to say, without hesitating, that start() creates the thread and run() is just the method it calls. Know that join() waits, is_alive() tests, and a Thread object is started only once.

for a middle

Explain the mechanics: start() registers the thread and returns once the OS thread is running, the default run() invokes target(*args, **kwargs), join(timeout) returns None so you check is_alive() afterwards, and results come back through shared state.

for a senior

Show how you get results and failures out of a bare thread, when you would subclass Thread versus pass a target, and how you use name and native_id to make threads identifiable in logs and in system-level output during an incident.

for a principal

Own the question of whether raw threading.Thread objects belong in the codebase at all: unbounded thread creation, no result plumbing and no restart push most teams towards a pooled abstraction, with hand-rolled threads reserved for long-lived singletons.

## Two methods that are not alternatives `threading.Thread` is a small Python object that wraps an operating-system thread which **does not exist yet**. Two of its methods look interchangeable and are not: * `run()` is an ordinary method that *holds* the work. Its default implementation calls the `target` callable you passed to the constructor with the stored `args` tuple and `kwargs` dict. * `start()` is the only call that asks the platform for a real thread and arranges for `run()` to execute inside it. So `Thread(target=f, args=(1,))` and a subclass that overrides `run()` are two spellings of the same idea, and neither of them runs anywhere until `start()`. ## What start() actually does `start()` checks that the object was initialized and has not been started before — a second call raises `RuntimeError: threads can only be started once`. It registers the thread in the module's bookkeeping, asks the OS for a thread, and blocks on an internal event until the new thread has signalled that it is running, then returns. Inside the new thread a bootstrap function calls `run()`, catches anything that escapes and routes it to `threading.excepthook`, and finally unregisters the thread. Calling `run()` directly skips every part of that. You get a plain method call on the calling thread: fully synchronous, no parallelism, no `is_alive()` transition. The tell-tale symptom is code that "uses threads" and takes exactly as long as the sequential version, with `threading.current_thread().name` printing `MainThread` everywhere. On CPython 3.14 there is a second, sharper consequence: the default `run()` deletes its references to `_target`, `_args` and `_kwargs` when it finishes, so a `start()` on an object whose `run()` you already invoked by hand fails inside the new thread with an `AttributeError` rather than doing the work twice. ## Lifecycle and introspection * `is_alive()` is `False` before `start()`, `True` from `start()` until `run()` returns, and `False` afterwards. * `join(timeout=None)` blocks the caller until the thread finishes. It always returns `None` — a timed join tells you nothing by itself, so the idiom is `t.join(5.0)` followed by `if t.is_alive():` to detect a worker that overran. * `join()` before `start()` raises `RuntimeError: cannot join thread before it is started`, and joining `threading.current_thread()` raises `RuntimeError: cannot join current thread`. * `name` is a mutable label (the default looks like `Thread-1 (worker)`) and is what appears in tracebacks and log records; `ident` is the Python-level thread id, valid only while the thread is alive and reusable afterwards; `native_id` is the OS-level id, which is what process-inspection tools show, so it is the field to log when correlating with system-level output. * `threading.current_thread()` returns the running thread's object, `threading.enumerate()` returns a list of the currently alive `Thread` objects (including the main thread), `threading.active_count()` is that list's length, and `threading.main_thread()` returns the thread the interpreter started in. ## Passing work in and getting results out Arguments go in as `args` (a sequence) and `kwargs` (a mapping); the perennial mistake is writing `args=(x)` instead of `args=(x,)`, which passes the *contents* of `x` if it happens to be iterable, or raises immediately if it is not. Results come back through shared state — an instance attribute on a `Thread` subclass, an appended list, a thread-safe channel — because `join()` gives you nothing. If you want a value and a propagated exception, you want a pooled worker's future rather than a bare `Thread`. ## target= or a subclass? Prefer `target=` for a plain function; it keeps the callable independently testable. Subclass `Thread` when the worker owns state or needs several methods: override `run()`, call `super().__init__()` first (nothing else works if you do not), and keep the constructor cheap, since it executes on the *creating* thread. Do not override `start()`. ## The knob nobody looks at `threading.stack_size()` reads the stack size used for threads created afterwards in this process, and `threading.stack_size(n)` sets it. `0` means "platform default". It matters in two situations — a worker that recurses deeply and blows its stack, and a process creating very many threads where the reserved address space per thread adds up. It raises `ValueError` for a size that is too small or badly aligned and `RuntimeError` where the platform will not honour it, and it never affects already-running threads or the main thread.

  • What happens if you call start() twice on the same threading.Thread object?
    It raises `RuntimeError: threads can only be started once`. A `Thread` object is single-use — there is no restart, and no way to reuse the handle after `run()` has returned. To do the work again you construct a new `Thread`, or hand the callable to a pooled worker that keeps its threads alive across many work items.
  • Does Thread.join() give you the value returned by the target function?
    No. `join()` always returns `None`, and the value returned by `run()` or by the `target` callable is discarded by the bootstrap. Results have to travel through shared state you control — an attribute on a `Thread` subclass, a list or dict you append to, or a thread-safe channel. If you want a return value *and* the worker's exception re-raised in the caller, that is what a pooled worker's future gives you.
  • What does threading.stack_size() control, and when would you change it?
    It reads or sets the stack size, in bytes, used for threads created *after* the call in this process; `0` means the platform default. Raise it for a worker that recurses deeply enough to overflow its stack, lower it when a process creates very many threads and the reserved address space matters. It raises `ValueError` for an invalid size and `RuntimeError` where the platform refuses, and it never changes threads that already exist or the main thread.

start() is posting a job to a second worker and walking away; run() is reading the job sheet out loud at your own desk and doing it yourself.

saying these in an interview costs you the question

  • Thinks calling run() starts a thread
  • Expects join() to return the target's result
  • Tries to restart a finished Thread object
  • Believes start() blocks until the work finishes
  • Forgets the trailing comma in a one-element args tuple
  • Overrides start() instead of run() in a subclass

context