Back to Blog
Python

Python Thread Start: The threading.Thread API

python thread start: Learn how to start a thread in Python using threading.Thread, pass arguments, manage daemon threads, join, and understand the GIL's impact.

threadingconcurrencypythonGILThread
A visual representation of starting a thread in Python, showing a thread object and its start method.

Starting a thread in Python is a common task when you need to run work concurrently. The threading module provides the Thread class, and the start() method is what actually begins execution. This article covers the python thread start workflow: creating a Thread object, calling start(), passing arguments, managing daemon threads, joining, and understanding the GIL's impact on performance.

The threading.Thread API and the start() method

The Thread constructor creates a thread object, but it does not start it. You must call start() to begin execution in a separate OS thread. Here is the minimal pattern:

import threading import time def worker(): print("Worker thread running") time.sleep(1) t = threading.Thread(target=worker) t.start() print("Main thread continues")

When start() is called, Python schedules the worker function to run in a new thread. The main thread continues immediately without waiting for the worker to finish. If you call start() on the same Thread object twice, it raises RuntimeError. The thread object can only be started once.

Passing arguments to a thread

The Thread constructor accepts args and kwargs to pass positional and keyword arguments to the target function:

def greet(name, greeting="Hello"): print(f"{greeting}, {name}") t = threading.Thread(target=greet, args=("Alice",), kwargs={"greeting": "Hi"}) t.start()

args must be a tuple, even for a single argument. kwargs is a dictionary. This is the standard way to parameterize a thread's work without relying on global state.

Daemon threads and their lifecycle

A daemon thread does not keep the process alive. When the main thread exits, daemon threads are abruptly stopped. Non-daemon threads block the interpreter from exiting until they finish. You set the daemon flag either in the constructor or by assigning to the daemon attribute before calling start():

t = threading.Thread(target=worker, daemon=True) t.start()

Daemon threads are useful for background tasks like monitoring or periodic cleanup that should not prevent the program from exiting. However, because they can be killed mid-operation, they are not suitable for work that must complete or that modifies external resources without cleanup.

Joining threads and waiting for completion

To wait for a thread to finish, call join(). This blocks the calling thread until the target thread completes or an optional timeout expires:

t = threading.Thread(target=worker) t.start() t.join() # Wait indefinitely print("Worker finished")

Without join(), the main thread may finish before the worker, and if the worker is non-daemon, the interpreter will still wait for it. join() gives you explicit control over synchronization. You can also pass a timeout to avoid blocking forever, but the thread continues running in the background.

Common mistakes when starting threads

A frequent error is calling run() instead of start(). run() executes the target function synchronously in the current thread, defeating the purpose of threading. Another mistake is starting a thread that accesses shared mutable data without locks, leading to race conditions. For example, incrementing a counter from multiple threads is not atomic in Python:

counter = 0 def increment(): global counter for _ in range(100000): counter += 1 threads = [threading.Thread(target=increment) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(counter) # Likely less than 1000000

The fix is to use a Lock or rely on thread-safe data structures. Also, avoid starting a thread in a daemon that performs critical cleanup, because it may be terminated before finishing.

The GIL and what it means for thread performance

The Global Interpreter Lock (GIL) serializes bytecode execution in CPython. This means CPU-bound Python threads cannot run in parallel on multiple cores; they take turns. For I/O-bound work, threads are effective because the GIL is released during blocking I/O operations. If your workload is CPU-heavy, consider using multiprocessing to achieve parallelism across processes. The GIL does not apply to all Python implementations, but it is a core constraint in CPython, which is the most common runtime.

When to use threads instead of processes

Threads are lighter than processes and share memory, which simplifies passing data. Use threads when:

  • The work is I/O-bound, such as network requests, file reads, or database calls.
  • You need to share state frequently and can manage synchronization.
  • The overhead of process creation and inter-process communication would dominate.

Use processes when:

  • The work is CPU-bound and you need to use multiple cores.
  • You want to isolate failures so a crash in one worker does not take down the whole application.
  • You need to bypass the GIL entirely.

For a mixed workload, you can combine both, but start with the simplest approach that meets your concurrency needs.

Handling exceptions in threads

Exceptions raised inside a thread do not propagate to the main thread. They are printed to stderr and the thread terminates. To capture exceptions, you can override the run() method or use a wrapper that stores the exception:

class ExceptionThread(threading.Thread): def run(self): try: super().run() except Exception as e: self.exception = e def faulty(): raise ValueError("boom") t = ExceptionThread(target=faulty) t.start() t.join() if hasattr(t, "exception"): print(f"Caught: {t.exception}")

This pattern lets you inspect thread failures after join(). For production code, you should log exceptions inside the thread and consider using a thread pool with proper error handling.

python thread start: Practical Usage and Code Examples | RYUSLOG DEV