Python Future Result: Retrieving Values from Concurrent Tasks
Learn how to retrieve results from Python Future objects, handle exceptions, set timeouts, and use callbacks in concurrent.futures.
Python's concurrent.futures module exposes a Future object that represents the eventual result of an asynchronous computation. You usually receive one future per submitted task, and the Future provides a consistent way to check status, retrieve the value, or handle errors.
What a Future Represents in Python
A Future is a placeholder for a result that will be available at some point. executor.submit() returns a Future for the submitted callable. The Future object tracks whether the callable is pending, running, completed, or cancelled. This abstraction lets you write code that does not block until the result is actually needed.
The executor.map() method, by contrast, returns an iterator over results directly, not Future objects.
Retrieving the Result with result()
The direct way to get a future's result is to call result(). This method blocks until the callable completes and returns the value. If the callable raised an exception, result() re-raises that exception.
from concurrent.futures import ThreadPoolExecutor def square(n): return n * n with ThreadPoolExecutor() as executor: future = executor.submit(square, 5) result = future.result() print(result) # 25
The result() method accepts an optional timeout parameter. If the future does not complete within that many seconds, it raises concurrent.futures.TimeoutError.
Handling Exceptions from a Future
When a callable raises an exception, the Future captures it. Calling result() re-raises the original exception. You can also inspect it with exception() without raising it.
def fail(): raise ValueError("bad input") with ThreadPoolExecutor() as executor: future = executor.submit(fail) try: future.result() except ValueError as e: print(f"Caught: {e}") # Alternatively, inspect the exception object if future.exception() is not None: print("Future failed")
The exception() method returns the exception instance if the future failed, or None if it completed successfully. A cancelled future is not a successful future: calling exception() on a cancelled future raises CancelledError.
Using done() and add_done_callback()
Instead of blocking on result(), you can register a callback that runs when the future completes. The callback receives the Future object. This is useful for non-blocking workflows.
def on_done(fut): print(f"Result: {fut.result()}") with ThreadPoolExecutor() as executor: future = executor.submit(square, 7) future.add_done_callback(on_done)
Note that for a ThreadPoolExecutor, the callback runs in the worker thread that completes the future. If the callback does heavy work, it can block that worker. If you need callbacks to run in the main thread, coordinate with an event loop or a queue.
Cancelling a Future and Its Consequences
A future can be cancelled only if it has not started running. Calling cancel() returns True if the future was cancelled, False otherwise. If cancelled, result() raises concurrent.futures.CancelledError.
with ThreadPoolExecutor() as executor: future = executor.submit(square, 10) print(future.cancel()) # True if the task has not started
Once a future is running, cancel() returns False and the operation continues. Cancelling a future that is already done has no effect.
Timeouts and Waiting
When using result(timeout=...), you must handle TimeoutError. The future continues running in the background; you can try again later.
import time from concurrent.futures import ThreadPoolExecutor, TimeoutError def slow(): time.sleep(5) return 42 with ThreadPoolExecutor() as executor: future = executor.submit(slow) try: value = future.result(timeout=2) except TimeoutError: print("Timed out, will wait longer") value = future.result() # blocks until done
A timeout does not cancel the underlying task. It only stops the current call from waiting further.
Choosing Between ThreadPoolExecutor and ProcessPoolExecutor
Both executors return Future objects, but they differ in how work is executed. ThreadPoolExecutor is suitable for I/O-bound tasks; ProcessPoolExecutor for CPU-bound tasks. The result() and exception() behavior is identical, but pickling requirements differ for process pools.
| Feature | ThreadPoolExecutor | ProcessPoolExecutor |
|---|---|---|
| Best for | I/O-bound tasks | CPU-bound tasks |
| Memory sharing | Shared memory | Separate processes |
| Pickling | Not required | Required for arguments and results |
Use ThreadPoolExecutor when your tasks spend most of their time waiting on network, disk, or user input. Use ProcessPoolExecutor when your tasks are compute-heavy and need to bypass the GIL.
Common Pitfalls with Future Results
Calling result() on a future that is already done returns immediately if the callable succeeded, or re-raises the stored exception if it failed. Calling it multiple times is safe, but an exception will be re-raised each time. Also, be careful not to block the main thread indefinitely without a timeout in production code.
When using map(), you get an iterator of results, not futures. If you need to handle exceptions per item, use submit() with a list of futures. For example, to collect results while preserving order and handling failures individually, you can create a list of futures and iterate over them.
with ThreadPoolExecutor() as executor: futures = [executor.submit(square, i) for i in range(10)] for fut in futures: try: print(fut.result()) except Exception as e: print(f"Task failed: {e}")
This pattern gives you fine-grained control over each task's outcome, which is often necessary in real applications.