Back to Blog
Python

Understanding the Python Future Object

Learn how Python Future objects work: retrieve results, handle exceptions, cancel tasks, and understand the difference between concurrent.futures and asyncio.

concurrent.futuresasynciothreadingmultiprocessingasync programming
Illustration of a Python future object as a placeholder that resolves into a result or exception

When you submit a task to a thread or process pool in Python, you receive a Future object that represents the eventual result of that task. The same concept appears in asyncio for coroutines. Understanding this object is essential for writing concurrent code that is both correct and maintainable.

A Future is not the result itself. It is a placeholder that will hold the result once the task completes, or an exception if the task fails. This lets you submit work, continue doing other things, and later ask the future for its outcome. The central idea is the same in concurrent.futures and asyncio, but there are important differences in how futures are created and used.

The Purpose of the Future Object

A future decouples the submission of work from the collection of its result. Instead of blocking until a task finishes, you get a handle immediately. This handle provides methods to check whether the task is done, wait for it, retrieve the result, or attach callbacks that run when the task completes.

Consider a simple example with ThreadPoolExecutor:

from concurrent.futures import ThreadPoolExecutor def square(n): return n * n with ThreadPoolExecutor() as executor: future = executor.submit(square, 5) print(future.result())

Here future is a Future instance. Calling result() blocks until the task finishes and returns 25. The future object is what allows the executor to return control to your code immediately after submission.

Creating a Future with concurrent.futures

In concurrent.futures, you rarely create a Future directly. Instead, you obtain one from an executor's submit method. (map returns an iterator of results, not futures.) The executor creates the Future internally and associates it with the submitted callable.

You can also create a Future manually with concurrent.futures.Future(), but that is uncommon. Manual creation is useful for testing or for integrating a callback-based API with the executor model.

from concurrent.futures import Future future = Future() future.set_result(42) print(future.result()) # 42

Setting a result manually is allowed, but you must not set it twice. Attempting to set a result on an already resolved future raises InvalidStateError.

Retrieving Results and Handling Exceptions

The result() method blocks until the future is resolved. You can pass a timeout to avoid waiting indefinitely:

future = executor.submit(slow_task) try: value = future.result(timeout=2) except TimeoutError: print("Task did not finish in time") except Exception as exc: print(f"Task raised: {exc}")

If the callable raises an exception, result() re-raises that exception in the calling thread. The exception is stored on the future and can be inspected with future.exception() without re-raising it.

future = executor.submit(divide, 1, 0) if future.exception() is not None: print(f"Error: {future.exception()}")

Future States and Callbacks

A concurrent.futures.Future moves through internal states including PENDING, RUNNING, CANCELLED, and FINISHED; the cancellation path also uses an internal CANCELLED_AND_NOTIFIED state. In normal code, use the public checks done(), running(), and cancelled() instead of inspecting the state directly. done() returns True for both cancelled and finished futures.

Callbacks are functions that run when the future completes. In concurrent.futures, they run in the thread that resolves the future, which is the worker thread for an executor task. You add a callback with add_done_callback:

future = executor.submit(square, 7) def on_done(fut): print(f"Result: {fut.result()}") future.add_done_callback(on_done)

The callback receives the future as its only argument. This is a convenient way to chain operations without blocking the main thread, but it runs in a worker thread, so be careful about thread safety when touching shared state.

Differences Between concurrent.futures.Future and asyncio.Future

asyncio has its own Future class, designed for event-loop-based concurrency. The core concept is the same, but the API differs.

First, asyncio.Future is not thread-safe. It must be used from the event loop thread, or with careful synchronization if accessed from other threads. concurrent.futures.Future is designed to be used across threads.

Second, asyncio.Future integrates with await. You can await an asyncio.Future directly, which suspends the coroutine until the future is resolved. A concurrent.futures.Future is not awaitable.

Third, callbacks on an asyncio.Future are scheduled on the event loop, but you will usually use await instead of callbacks for cleaner code.

import asyncio async def main(): loop = asyncio.get_running_loop() future = loop.create_future() loop.call_soon(future.set_result, 10) result = await future print(result) # 10 asyncio.run(main())

You rarely create an asyncio.Future manually. Use loop.create_future() when you need one, but in ordinary async code you work with asyncio.Task objects created by asyncio.create_task() or asyncio.ensure_future(). asyncio.Task is a subclass of Future.

Cancelling a Future and Timeout Behavior

Both future types support cancellation. For concurrent.futures.Future, calling cancel() returns True if the future was still pending and was successfully cancelled. If the task is already running or finished, cancel() returns False and the task continues.

future = executor.submit(slow_task) cancelled = future.cancel() print(cancelled) # False if already running

For asyncio.Future, cancellation is cooperative. Calling cancel() on a future that is still pending requests cancellation. If accepted, the future becomes cancelled and any awaiters receive CancelledError. The coroutine can catch that exception to perform cleanup, or re-raise it.

Timeout handling also differs. With concurrent.futures, you pass a timeout to result(). With asyncio, use asyncio.wait_for to wrap a coroutine or future with a timeout.

async def main(): try: result = await asyncio.wait_for(slow_coro(), timeout=2) except asyncio.TimeoutError: print("Timed out")

Using Futures with ThreadPoolExecutor and ProcessPoolExecutor

The most common way to get a Future is through an executor. ThreadPoolExecutor uses threads, while ProcessPoolExecutor uses separate processes. The future object behaves the same in both cases, but there are important differences in what can be passed to the worker.

Threads share memory, so with ThreadPoolExecutor argument objects are passed by reference. ProcessPoolExecutor uses separate processes and pickles the callable, its arguments, and the return value, so those values must be picklable.

from concurrent.futures import ProcessPoolExecutor def compute(x): return x ** 2 with ProcessPoolExecutor() as executor: future = executor.submit(compute, 4) print(future.result()) # 16

This limitation affects how you design tasks for process pools. You cannot pass lambdas or local functions easily because they are not picklable. Use module-level functions or objects that support pickling.

Common Pitfalls and Maintainability Considerations

One common mistake is doing too much work inside a done callback. In concurrent.futures, callbacks run in a worker thread. If a callback blocks by calling result() on another future that has not finished, it can tie up that worker thread and stall unrelated tasks. Calling result() on the same future inside its own callback is safe: the callback runs after the future is finished, so result() returns immediately.

Another pitfall is ignoring exceptions. With concurrent.futures, an exception stored on a future is not raised until you call result(). If no one ever does, the failure may go unnoticed. Use result() or inspect exception() in a callback. In asyncio, a task exception that is never retrieved is logged by the event loop, but you should still handle it explicitly.

When using asyncio, a common issue is forgetting to keep a reference to a task. If you create a task and never await it, it may be garbage collected and cancelled. Use asyncio.create_task and keep a reference to the task, or use asyncio.gather to manage multiple futures.

For maintainability, prefer asyncio.gather over manually adding callbacks when you need to run several coroutines concurrently. It returns an awaitable that completes when all inputs complete, and it propagates the first exception by default.

async def main(): results = await asyncio.gather(coro1(), coro2(), coro3())

Finally, a future is a low-level primitive for one asynchronous operation, not a full workflow engine. Use it when you need direct control over cancellation, timeouts, or callbacks; use higher-level abstractions such as asyncio.gather or concurrent.futures.wait for most production code.

Python Future Object: Practical Usage and Code Examples | RYUSLOG DEV