FastAPI applications often depend on resources that are expensive to create for every request. These include HTTP clients, database connection pools, machine learning models, Redis connections, and background service clients.
Creating a new HTTP client for every incoming request can waste connections and increase resource usage. Loading a machine learning model repeatedly can make an endpoint unnecessarily slow. Similarly, failing to close clients and connection pools during application shutdown can leave resources open longer than necessary.
FastAPI provides the lifespan mechanism to manage resources during an application's startup and shutdown. It allows an application to initialize shared resources once, make them available to request handlers, and clean them up when the application stops.
This article explains how FastAPI lifespan works, how to share an asynchronous HTTP client safely, and how to avoid common resource-management mistakes in production applications.
Understanding FastAPI Lifespan
An application has a lifecycle that extends beyond individual requests. During startup, it may need to establish connections or load configuration. While running, it handles requests using those resources. During shutdown, it should release resources properly.
FastAPI supports this lifecycle through an asynchronous context manager passed to the application's FastAPI constructor.
The general structure looks like this:
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: initialize resources.
print("Application starting")
yield
# Shutdown: release resources.
print("Application stopping")
app = FastAPI(lifespan=lifespan)
The code before yield runs during application startup. The code after yield runs during application shutdown.
The lifespan function is not executed for every request. Instead, it manages resources for the lifetime of the application instance within a running worker.
This distinction is important when designing applications that use shared clients or expensive initialization processes.
Why Shared HTTP Clients Matter
Consider an application that calls an external service whenever a user requests information.
A basic implementation might create an HTTP client inside the endpoint:
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.get("/external-data")
async def get_external_data():
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.example.com/data"
)
response.raise_for_status()
return response.json()
This example demonstrates asynchronous HTTP requests, but it creates and closes an HTTP client for every request.
An HTTP client can maintain a connection pool, reuse connections, and manage common request settings. Creating a new client repeatedly prevents the application from taking full advantage of a long-lived connection pool.
A better design creates the client during application startup and closes it during shutdown.
For example, an application can use httpx.AsyncClient as a shared resource managed by FastAPI lifespan.
Creating a Shared HTTP Client With Lifespan
The following example initializes one asynchronous HTTP client for each application instance.
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request
@asynccontextmanager
async def lifespan(app: FastAPI):
async with httpx.AsyncClient(
timeout=httpx.Timeout(10.0),
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20
)
) as client:
app.state.http_client = client
yield
app = FastAPI(lifespan=lifespan)
@app.get("/external-data")
async def get_external_data(request: Request):
client: httpx.AsyncClient = request.app.state.http_client
response = await client.get(
"https://api.example.com/data"
)
response.raise_for_status()
return response.json()
Let's examine the important parts.
Initialize the client before accepting requests
The lifespan function creates the HTTP client before reaching yield. The application can then use that client while it is running.
The timeout configuration limits how long the application waits for an HTTP operation. Connection limits help control how many connections the client maintains.
These values are examples, not universal production defaults. Select limits based on the external service's capacity, the application's expected concurrency, and the HTTP client's behavior.
Store the resource in application state
The line below stores the client on the application instance:
app.state.http_client = client
The endpoint accesses it through:
request.app.state.http_client
This avoids creating a new client for each request.
Application state is appropriate for resources shared by handlers in the same application instance. It should not be confused with process-wide or deployment-wide storage.
Close the client during shutdown
The client is managed by an async with block. When the lifespan context exits, the client is closed automatically.
This releases resources managed by the HTTP client and allows its connections to be cleaned up correctly.
Using a context manager is safer than relying on developers to remember to close the client manually.
Sharing Multiple Resources
Real applications often require more than one shared resource. For example, an application might use an HTTP client for external APIs and a database connection pool for database access.
You can initialize multiple resources in the same lifespan function.
from contextlib import AsyncExitStack, asynccontextmanager
import httpx
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
async with AsyncExitStack() as stack:
http_client = await stack.enter_async_context(
httpx.AsyncClient(timeout=10.0)
)
app.state.http_client = http_client
# Initialize other asynchronous context-managed
# resources here and register them with stack.
yield
app = FastAPI(lifespan=lifespan)
AsyncExitStack is useful when an application manages several asynchronous context managers. Each resource registered with the stack is closed when the stack exits.
This approach is especially helpful when initialization involves multiple resources that must be cleaned up if a later initialization step fails.
For a database connection pool, use the initialization and cleanup APIs provided by the specific database library. Do not assume every database driver exposes the same interface.
Using Lifespan With Dependency Injection
Accessing request.app.state directly is simple, but a larger application may benefit from FastAPI dependencies.
Dependencies provide a consistent way to access shared resources and make endpoint code easier to test.
import httpx
from fastapi import Depends, FastAPI, Request
def get_http_client(
request: Request
) -> httpx.AsyncClient:
return request.app.state.http_client
@app.get("/products")
async def get_products(
client: httpx.AsyncClient = Depends(get_http_client)
):
response = await client.get(
"https://api.example.com/products"
)
response.raise_for_status()
return response.json()
This example assumes the lifespan function from the previous section has already initialized app.state.http_client.
The dependency retrieves the shared client without creating a new instance. The endpoint focuses on making the request and processing the response.
Dependency injection also makes testing easier because the dependency can be overridden with a test client or mock.
For example, a test can replace the HTTP client dependency with a controlled implementation that returns predictable responses. This avoids depending on an external service during every test run.
Handling Startup Failures Correctly
Resource initialization can fail for several reasons:
- An external service is unavailable.
- A required configuration value is missing.
- A database connection cannot be established.
- A model file cannot be loaded.
- A required credential is invalid.
The application should not silently continue when a resource is essential to serving requests.
Consider a resource that must be available before the application can function:
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
resource = await initialize_required_resource()
app.state.resource = resource
try:
yield
finally:
await resource.close()
app = FastAPI(lifespan=lifespan)
Here, initialize_required_resource() and resource.close() represent application-specific asynchronous functions. They must be implemented according to the resource library being used.
If initialization fails before yield, the application does not enter the normal serving phase through this lifespan context. That is preferable to accepting requests when a required dependency is unavailable.
The try and finally structure ensures that cleanup runs after successful initialization when the lifespan exits. For multiple resources, AsyncExitStack can simplify cleanup and handle resources registered before a later initialization step fails.
Optional resources require a different policy. If the application can function without a resource, it may be reasonable to log the failure and use a fallback. That decision should be explicit rather than silently ignoring exceptions.
Lifespan and Multiple Application Workers
One common misunderstanding is assuming that a resource created during lifespan is shared by every worker in a deployment.
That is not how application state works.
Suppose a server starts four worker processes. Each process runs its own application instance and initializes its own lifespan resources.
The result may look like this:
Deployment setup | Expected resource instances |
|---|
One worker with one application instance | One shared client for that instance |
Four worker processes | Typically four clients, one per application instance |
Two separate application replicas, each with four workers | Typically eight clients in total |
Multiple requests handled by one application instance | Requests share that instance's client |
These counts assume each worker creates one application instance and one resource. Actual behavior depends on the server configuration and application deployment.
This matters when configuring connection pools. If each worker can maintain up to 100 connections, four workers could potentially maintain up to 400 connections. The external service must be able to handle the combined load.
Lifespan is intended for resources local to an application instance. It is not a mechanism for coordinating shared mutable state across processes.
For shared data or coordination, use an appropriate external system, such as a database, Redis, or a message broker.
Testing FastAPI Lifespan
A test should verify not only endpoint behavior but also resource initialization and cleanup.
When using FastAPI's TestClient, enter it as a context manager to ensure that lifespan startup and shutdown are executed.
from fastapi.testclient import TestClient
def test_application_startup_and_shutdown():
with TestClient(app) as client:
response = client.get("/external-data")
assert response.status_code == 200
This example assumes that the endpoint's external dependency is available and returns a successful response. For a reliable automated test, override the HTTP client dependency or use a controlled test server instead of relying on a live external API.
For asynchronous tests, use an appropriate async testing setup and verify that the lifespan context is actually entered. Simply constructing a test client does not always guarantee that startup and shutdown have been exercised.
Tests should also cover initialization failures, request timeouts, external service errors, and resource cleanup where those behaviors are important to the application.
Common Mistakes to Avoid
Mistake | Why it causes problems | Better approach |
|---|
Creating an HTTP client inside every endpoint call | Repeated setup prevents effective connection reuse | Create the client during lifespan |
Forgetting to close shared resources | Connections and other resources may remain open | Use context managers or AsyncExitStack |
Treating application state as global shared storage | Separate workers have separate application instances | Use an external system for cross-worker coordination |
Ignoring required resource initialization failures | Requests may reach an application that cannot function correctly | Fail startup when a required resource cannot be initialized |
Performing long-running work during every startup | Increases restart and deployment time | Initialize only what the application needs |
Assuming tests automatically run lifespan hooks | Startup and cleanup may not be exercised | Use the appropriate test context manager or async lifespan setup |
Best Practices for Production Applications
Set explicit timeouts
Every external HTTP request should have an appropriate timeout. Without one, a slow or unresponsive service can consume application resources for longer than intended.
Configure connection limits based on workload and deployment size rather than copying example values without evaluation.
Keep resource ownership clear
The component that creates a shared resource should generally be responsible for closing it. Endpoints should use the resource without closing it after individual requests.
Otherwise, one request could close a client that other requests still need.
Avoid unnecessary global state
Store application-specific resources in app.state or expose them through dependencies. This keeps their lifecycle tied to the application instance and makes testing more manageable.
Monitor resource health
Track relevant metrics such as request latency, timeout rates, connection-pool usage, and external service errors. Reusing a client improves resource management, but it does not remove the need for observability and error handling.
Advantages and Disadvantages
The lifespan mechanism offers several benefits:
- Resources are initialized once per application instance instead of once per request.
- Context managers provide a predictable cleanup mechanism.
- Shared clients can reuse connection pools.
- Startup and shutdown logic stays separate from endpoint business logic.
- Dependencies make shared resources easier to replace during testing.
There are also trade-offs. Each worker may create its own resource instances, increasing total connection usage. A failed initialization can prevent the application from starting. In addition, shared clients must be used according to the concurrency guarantees and lifecycle rules of their underlying libraries.
These trade-offs are manageable when resource ownership, configuration, and cleanup are designed explicitly.
Summary
FastAPI lifespan provides a structured way to initialize and release resources that an application needs while it is running.
For an asynchronous HTTP client, create the client during startup, store it on the application instance, access it through request handlers or dependencies, and close it during shutdown. Apply the same lifecycle principles to database pools, Redis clients, and other resources using the appropriate library-specific APIs.
Remember that resources are normally shared within one application instance, not automatically across every worker or replica. Configure connection limits accordingly, handle startup failures deliberately, and test the complete lifecycle.
With these practices, FastAPI applications can reuse resources efficiently without losing control over cleanup, concurrency, or failure handling.
Join the conversation! Your thoughts help the community grow.