multithreading
Python
threading
concurrent programming
thread management

Wait until all threads are finished in Python

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In Python, waiting for threads to finish usually means calling join() on each thread you started. That gives the main thread a clean synchronization point so it does not exit early or start using results before the worker threads are done. The right pattern depends on whether you are managing raw threading.Thread objects or using a higher-level API such as ThreadPoolExecutor.

Use join() with threading.Thread

The standard low-level approach is:

  1. create the thread
  2. start the thread
  3. call join() when you need to wait for completion
python
1import threading
2import time
3
4
5def worker(name):
6    print(f"{name} started")
7    time.sleep(1)
8    print(f"{name} finished")
9
10
11threads = []
12for i in range(3):
13    thread = threading.Thread(target=worker, args=(f"thread-{i}",))
14    thread.start()
15    threads.append(thread)
16
17for thread in threads:
18    thread.join()
19
20print("all threads finished")

The important detail is that join() blocks the calling thread until the target thread finishes.

join() Does Not Start the Thread

A common mistake is thinking join() somehow schedules the thread. It does not. The thread must already be running or already finished.

Wrong mental model:

  • create thread
  • call join() and expect work to begin

Correct model:

  • create thread
  • call start()
  • later call join() to wait

If you forget start(), the thread never runs and the program logic is wrong even though the API calls look close.

Use Timeouts When You Need Bounded Waiting

join() optionally accepts a timeout, which is useful when a worker may hang or when the caller must stay responsive.

python
1import threading
2import time
3
4
5def worker():
6    time.sleep(5)
7
8
9thread = threading.Thread(target=worker)
10thread.start()
11thread.join(timeout=1)
12
13print(thread.is_alive())

After a timed join, is_alive() tells you whether the thread is still running.

A timed join is not cancellation. It only limits how long the caller waits.

Daemon Threads Change Shutdown Behavior

If a thread is marked as a daemon thread, Python will not wait for it during interpreter shutdown in the same way as normal threads.

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

Daemon threads are fine for some background activities, but if your requirement is "wait until all work is done," daemon threads are usually the wrong default. A normal thread plus explicit join() is clearer and safer.

ThreadPoolExecutor Is Often Easier

For many programs, concurrent.futures.ThreadPoolExecutor is a better abstraction than managing thread objects directly.

python
1from concurrent.futures import ThreadPoolExecutor
2import time
3
4
5def worker(x):
6    time.sleep(1)
7    return x * x
8
9
10with ThreadPoolExecutor(max_workers=3) as executor:
11    futures = [executor.submit(worker, n) for n in range(5)]
12    results = [future.result() for future in futures]
13
14print(results)

Calling future.result() waits for each task, and leaving the with block also waits for submitted tasks to finish.

This is usually easier to manage than manually storing and joining threads.

Watch for Exceptions in Worker Threads

If a raw threading.Thread raises an exception, it does not automatically propagate back through join(). That means "all threads finished" is not the same as "all threads succeeded."

With executors, calling future.result() re-raises task exceptions in the caller, which is often a nicer failure model.

If you use raw threads and need structured error handling, plan for that explicitly rather than assuming join() will report worker failures.

Common Pitfalls

  • Forgetting to call start() before join().
  • Assuming join() reports worker exceptions automatically.
  • Using daemon threads when the program actually needs to wait for the work to finish.
  • Calling join() too early and accidentally serializing work that was meant to overlap.
  • Reimplementing thread-pool behavior manually when ThreadPoolExecutor would be simpler.

Summary

  • For raw Python threads, join() is the standard way to wait for completion.
  • Start threads first, then join them when synchronization is needed.
  • Use timeout joins when waiting must be bounded.
  • Prefer normal threads over daemon threads when completion matters.
  • For many workloads, ThreadPoolExecutor is a cleaner way to manage waiting and results.

Course illustration
Course illustration

All Rights Reserved.