Settings, Lifespan & Background Tasks
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13, pydantic-settings 2.15 and Starlette 1.7,
checked on 2026-10-02. FastAPI supports Python 3.10 through 3.14; every example below was executed on Python 3.13 with
TestClient (several also against a real Uvicorn 0.54 server with curl), and the outputs shown are from those runs.
Three questions decide the structure of every service: where does configuration come from (the environment, files,
secrets), what has to be created once per process and closed on exit (HTTP clients, connection pools, models), and
what work may happen after the response has been sent. FastAPI answers them with pydantic-settings, the lifespan
parameter and BackgroundTasks. Dependencies (Dependency Injection)
are the glue between the three, and the running scenario is the Bookshelf API.
In a Hurry: What Goes Where
| You need | Use | Runs |
|---|---|---|
Values that differ between environments (URLs, flags, limits) |
A |
Once per process (cached), validated at startup |
Secrets (JWT key, database password) |
|
Same as above |
A resource shared by all requests (HTTP client, connection pool, ML model) |
|
Once per process: startup before the first request, cleanup after the last |
A resource that belongs to one request (database session) |
A dependency with |
Per request |
A small job that may finish after the response (send an e-mail, refresh a cache) |
|
In the same process, right after the response is sent |
Work that must survive a restart, be retried, scale out or run on other machines |
A task queue (Celery, arq) or a message broker |
In separate worker processes |
Settings with pydantic-settings
Hard-coding a database URL or a key in a module is the quickest way to leak a secret and to make the same code unusable
in two environments. The official recommendation is to read configuration from the environment and to validate it at
startup, with the same tooling used for request bodies: a Pydantic model. The class is BaseSettings, and since
Pydantic v2 it lives in the separate package pydantic-settings (from pydantic_settings import BaseSettings).
pip install "fastapi[standard]" already includes it; otherwise install it with pip install pydantic-settings.
The old import from pydantic import BaseSettings raises an import error in v2 (see
Pydantic v2 Essentials).
Environment Variables
A settings class is a Pydantic model whose values come from the environment. Declare fields with types and defaults; a field without default is required, and the application refuses to start without it.
from typing import Annotated
from fastapi import FastAPI
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BOOKSHELF_")
app_name: str = "Bookshelf API"
admin_email: str
page_size: int = Field(default=20, ge=1, le=100)
debug: bool = False
jwt_secret: SecretStr # no default: startup fails if it is missing
app = FastAPI()
settings = Settings() # reads os.environ once, at import time
@app.get("/info")
def info() -> dict:
return {
"app_name": settings.app_name,
"admin_email": settings.admin_email,
"page_size": settings.page_size,
"debug": settings.debug,
"secret_repr": repr(settings.jwt_secret),
"secret_len": len(settings.jwt_secret.get_secret_value()),
}
Run with the variables set only for that command (Bash syntax; on PowerShell assign $Env:NAME first). The secret is
generated on the spot instead of being typed:
BOOKSHELF_ADMIN_EMAIL=admin@bookshelf.example \
BOOKSHELF_PAGE_SIZE=50 \
BOOKSHELF_DEBUG=yes \
BOOKSHELF_JWT_SECRET=$(openssl rand -hex 32) \
fastapi run settings_basics.py
curl http://127.0.0.1:8000/info
{"app_name":"Bookshelf API","admin_email":"admin@bookshelf.example","page_size":50,"debug":true,"secret_repr":"SecretStr('**********')","secret_len":64}
What happened, and the rules behind it:
-
Names. The variable for a field is
env_prefixplus the field name, matched case-insensitively by default, sobookshelf_admin_email,BOOKSHELF_ADMIN_EMAILandBookshelf_Admin_Emailall filladmin_email. Setcase_sensitive=Trueto require exact case. The default prefix is empty, and the prefix also applies to.envfiles and secret files, not only to the process environment. A prefix such asBOOKSHELF_keeps the application’s names from colliding with unrelated variables such asDEBUGorPORT. -
Types. Environment variables are always strings. pydantic-settings converts them with the field’s type:
"50"becomes50,"yes"becomesTrue(the usual Pydantic boolean spellings1/0,true/false,yes/no,on/off), and complex types (lists, dicts, models) are decoded as JSON (see Nested Settings and Lists). -
Validation. All Pydantic constraints apply (
Field(ge=1, le=100),Literal,HttpUrl, validators). Defaults are validated too, unlike in a plainBaseModel, unless you setvalidate_default=False. -
SecretStr. Itsrepr(),str()and JSON serialization show, so an accidentalprint(settings)or a response model containing it does not leak the value. Callget_secret_value()only where the plain value is really needed (signing a token, opening a connection).SecretBytesis the bytes counterpart. -
Fail fast. A bad value or a missing required field raises a
ValidationErrornaming every problem at once. That is what you want at startup, in the logs of the new deployment, rather than a500on the first request that touches the setting:
os.environ["BOOKSHELF_PAGE_SIZE"] = "500"
del os.environ["BOOKSHELF_JWT_SECRET"]
Settings()
2 validation errors for Settings
page_size
Input should be less than or equal to 100 [type=less_than_equal, input_value='500', input_type=str]
For further information visit https://errors.pydantic.dev/2.13/v/less_than_equal
jwt_secret
Field required [type=missing, input_value={'admin_email': 'admin@bo...: '500', 'debug': 'yes'}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.13/v/missing
Source: Pydantic: Settings Management
The module-level settings = Settings() above is the simplest form and fine for scripts. A settings dependency
(A Cached Settings Dependency, Overridden in Tests) is the better one for services because tests can replace it.
.env Files
Many settings change between machines and you do not want ten export lines in every terminal. Put them in a
.env file ("dotenv") next to the application and point the model at it with env_file. Reading dotenv files uses
python-dotenv, which pydantic-settings installs as a dependency. Never commit .env: add it to .gitignore and commit an
.env.example with empty or fake values instead.
import secrets, tempfile, pathlib
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
tmp = pathlib.Path(tempfile.mkdtemp())
(tmp / ".env").write_text(
"BOOKSHELF_APP_NAME=Bookshelf (dev)\n"
"BOOKSHELF_DATABASE_URL=sqlite:///./dev.db\n"
f"BOOKSHELF_JWT_SECRET={secrets.token_hex(16)}\n"
"UNRELATED_TOOL_SETTING=1\n"
)
(tmp / ".env.prod").write_text("BOOKSHELF_APP_NAME=Bookshelf\n")
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="BOOKSHELF_",
env_file=(tmp / ".env", tmp / ".env.prod"), # later files override earlier ones
env_file_encoding="utf-8",
)
app_name: str = "Bookshelf API"
database_url: str = "sqlite:///./bookshelf.db"
jwt_secret: SecretStr
class LenientSettings(Settings):
model_config = SettingsConfigDict(extra="ignore") # merged with the parent's config
Source: Pydantic: Dotenv (.env) support
>>> Settings()
ValidationError: 1 validation error for Settings
unrelated_tool_setting
Extra inputs are not permitted [type=extra_forbidden, input_value='1', input_type=str]
>>> LenientSettings()
app_name='Bookshelf' database_url='sqlite:///./dev.db' jwt_secret=SecretStr('**********')
Points the run made visible:
-
extradefaults toforbid. UnlikeBaseModel, a settings class rejects names found in the.envfile that it does not know, such as a strayUNRELATED_TOOL_SETTING. Variables in the process environment are not affected, only the dotenv file. Setextra="ignore"(what most services want) or keep every name in the file prefixed and declared. -
Several files.
env_fileaccepts a tuple; a later file overrides an earlier one, so.envplus.env.prod(above,app_namebecameBookshelf). A missing file is skipped silently, so a wrong relative path simply yields defaults; relative paths are resolved against the current working directory, not the module’s location. -
Per instance.
Settings(_env_file=None)ignores the file (used in tests, see A Cached Settings Dependency, Overridden in Tests) andSettings(_env_file=".env.test")replaces it.env_file_encodingsets the encoding. -
Real environment beats the file. A variable exported in the shell wins over the same name in
.env, which is exactly how production overrides a developer default (checked on 2.15.0). -
Empty values.
BOOKSHELF_APP_NAME=is an empty string, not "unset": the field becomes''. Setenv_ignore_empty=Trueto treat empty variables as missing and fall back to the default.
Where a Value Comes From: Priority
When the same field is available from several places, the first of these wins (checked on 2.15.0):
| Rank | Source | Typical use |
|---|---|---|
1 |
Arguments to the initializer, |
Tests, programmatic overrides |
2 |
Process environment variables |
Production: set by the platform |
3 |
Dotenv files ( |
Local development |
4 |
Secret files ( |
Docker / Kubernetes mounted secrets |
5 |
Field defaults |
Safe, non-secret defaults |
A command-line source (cli_parse_args) exists in pydantic-settings and, when enabled, ranks above all of these; it is
not used in web services. The order is a convention you can change by overriding the class method
settings_customise_sources, which returns the sources in the order you want, and it can add file sources such as
TOML, JSON or YAML (TomlConfigSettingsSource, JsonConfigSettingsSource, YamlConfigSettingsSource; YAML needs the yaml extra of pydantic-settings, and TOML needs tomli only before Python 3.11). The example
keeps the default order but appends a TOML file as the lowest-priority source, and maps one field to a standard
variable name with a validation alias:
import os, pathlib, tempfile
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict, TomlConfigSettingsSource
tmp = pathlib.Path(tempfile.mkdtemp())
(tmp / "config.toml").write_text('app_name = "From TOML"\npage_size = 33\n')
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BOOKSHELF_", toml_file=tmp / "config.toml")
app_name: str = "default"
page_size: int = 20
# DATABASE_URL (the name most platforms set) or BOOKSHELF_DATABASE_URL
database_url: str = Field(
default="sqlite://",
validation_alias=AliasChoices("DATABASE_URL", "BOOKSHELF_DATABASE_URL"),
)
@classmethod
def settings_customise_sources(
cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings
):
return (
init_settings,
env_settings,
dotenv_settings,
file_secret_settings,
TomlConfigSettingsSource(settings_cls), # lowest priority
)
os.environ["BOOKSHELF_PAGE_SIZE"] = "5"
os.environ["DATABASE_URL"] = "postgresql://db/bookshelf"
print(Settings())
app_name='From TOML' page_size=5 database_url='postgresql://db/bookshelf'
app_name came from TOML (nothing else set it), page_size from the environment (rank 2 beats the file), and
database_url through the alias. Two practical tools when a value is not what you expected: print
Settings.model_config, and set the environment variable PYDANTIC_SETTINGS_DEBUG=1 with DEBUG logging enabled; the
pydantic_settings logger then lists what every source collected, in priority order, and which dotenv or secret files
were found. That output contains the loaded values, secrets included, so use it only locally.
Nested Settings and Lists
Group related settings in sub-models and fill them from flat variables with env_nested_delimiter. With the delimiter
, BOOKSHELF_DBPOOL_SIZE=10 sets settings.db.pool_size. The nested classes are plain BaseModel`s, not
`BaseSettings, so they read nothing from the environment by themselves.
import os
from pydantic import BaseModel, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
url: str = "sqlite:///./bookshelf.db"
pool_size: int = 5
echo: bool = False
class RedisSettings(BaseModel):
host: str = "localhost"
port: int = 6379
password: SecretStr | None = None
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="BOOKSHELF_",
env_nested_delimiter="__",
env_nested_max_split=1, # split only on the first delimiter after the prefix
)
db: DatabaseSettings = DatabaseSettings()
redis: RedisSettings = RedisSettings()
allowed_origins: list[str] = []
os.environ["BOOKSHELF_DB__URL"] = "postgresql+psycopg://bookshelf@db/bookshelf"
os.environ["BOOKSHELF_DB__POOL_SIZE"] = "10"
os.environ["BOOKSHELF_REDIS__HOST"] = "cache.internal"
os.environ["BOOKSHELF_REDIS__PASSWORD"] = os.urandom(6).hex()
os.environ["BOOKSHELF_ALLOWED_ORIGINS"] = '["https://shelf.example", "https://admin.shelf.example"]'
print(Settings())
db=DatabaseSettings(url='postgresql+psycopg://bookshelf@db/bookshelf', pool_size=10, echo=False) redis=RedisSettings(host='cache.internal', port=6379, password=SecretStr('**********')) allowed_origins=['https://shelf.example', 'https://admin.shelf.example']
More ways to feed the same structure, all verified:
-
A whole object as JSON.
BOOKSHELF_DB='{"url": "sqlite:///:memory:", "echo": true}'fillsdbat once, and aBOOKSHELF_DB__POOL_SIZE=3set next to it is merged on top (result:url='sqlite:///:memory:' pool_size=3 echo=True). -
Lists and dicts are JSON.
BOOKSHELF_ALLOWED_ORIGINSmust be a JSON array. A comma-separated value such ashttps://a.example,https://b.exampleraises aSettingsError("error parsing value for field "allowed_origins" from source "EnvSettingsSource""), a very common surprise. -
Comma-separated lists. Disable the JSON decoding for that field with
NoDecodeand split in amode="before"validator:
from typing import Annotated
from pydantic import field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BOOKSHELF_")
allowed_origins: Annotated[list[str], NoDecode] = []
@field_validator("allowed_origins", mode="before")
@classmethod
def split_csv(cls, v):
if isinstance(v, str):
return [o.strip() for o in v.split(",") if o.strip()]
return v
Source: Pydantic: Disabling JSON parsing
With BOOKSHELF_ALLOWED_ORIGINS="https://a.example, https://b.example" the result is
['https://a.example', 'https://b.example'].
-
env_nested_max_split. A delimiter ofis tempting (BOOKSHELF_DB_URL) but ambiguous with underscores inside field names (pool_size);env_nested_max_split=1limits the split sollm_api_keystays one name. Prefer_. -
Defaults of sub-models. By default an environment variable for one nested field replaces the model’s default object instead of updating it. The config option
nested_model_default_partial_update=Truemakes partial updates merge into the default; read its section on the settings page before relying on it.
Secrets Directories
Container platforms usually provide secrets as files, one value per file: Docker (and Compose) mounts them under
/run/secrets, Kubernetes projects a Secret volume as a directory (see
Secrets and Compose in Practice).
Files avoid the leaks of environment variables (shown by docker inspect, inherited by child processes, dumped in crash
reports). Point secrets_dir at the directory; each file name is the field name (with the prefix, if any) and the file
content is the value, with the trailing newline removed.
import os, pathlib, tempfile
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
secrets = pathlib.Path(tempfile.mkdtemp()) # stands in for /run/secrets
(secrets / "jwt_secret").write_text(os.urandom(16).hex() + "\n")
(secrets / "db_password").write_text(os.urandom(8).hex())
class Settings(BaseSettings):
model_config = SettingsConfigDict(secrets_dir=secrets) # in a container: "/run/secrets"
jwt_secret: SecretStr
db_password: SecretStr
region: str = "eu-west-1"
s = Settings()
print(repr(s.jwt_secret), len(s.jwt_secret.get_secret_value()), repr(s.db_password))
os.environ["JWT_SECRET"] = os.urandom(4).hex() # a variable with the same name wins
print(len(Settings().jwt_secret.get_secret_value()))
Source: Pydantic: Secrets
SecretStr('**********') 32 SecretStr('**********')
8
Facts worth knowing (all observed on 2.15.0 or stated on the official page):
-
A missing
secrets_diris only a warning (directory "/definitely/not/here" does not exist), and the field then fails as "required" if it has no other source. A path that is a file raises an error. -
secrets_diraccepts a tuple of directories; later ones override earlier ones.Settings(_secrets_dir=…)overrides the configured value per instance, which tests use. -
Environment variables and
.envrank above secret files (table above), so a stale exported variable hides the mounted secret. Reorder withsettings_customise_sourcesif the file must win. -
The default secrets source supports only top-level fields. The opt-in
NestedSecretsSettingsSourceadds fields of nested models, using either a flat layout (db_passwdwithsecrets_nested_delimiter="_") or sub-directories (secrets/db/passwordwithsecrets_nested_subdir=True). The example below createssecrets/db/passwordand resolvessettings.db.password:
from pydantic import BaseModel, SecretStr
from pydantic_settings import BaseSettings, NestedSecretsSettingsSource, SettingsConfigDict
class Db(BaseModel):
user: str = "bookshelf"
password: SecretStr
class Settings(BaseSettings):
model_config = SettingsConfigDict(secrets_dir="secrets", secrets_nested_subdir=True)
db: Db
@classmethod
def settings_customise_sources(
cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings
):
return (init_settings, env_settings, dotenv_settings, NestedSecretsSettingsSource(file_secret_settings))
Source: Pydantic: Nested Secrets
The result printed db=Db(user='bookshelf', password=SecretStr('')). pydantic-settings also ships
sources for cloud secret stores, AWSSecretsManagerSettingsSource, AzureKeyVaultSettingsSource and
GoogleSecretManagerSettingsSource; each needs its optional extra and the cloud credentials, which are described on
the official settings page and are out of scope here. Whatever the source, the application code stays the same
SecretStr field.
Configuration Options at a Glance
All of these go in SettingsConfigDict(…) (they are also accepted as underscore-prefixed arguments of the initializer,
for example _env_file). Names were checked against the installed package.
| Option | Effect | Default |
|---|---|---|
|
Prefix for every variable, dotenv key and secret file name |
|
|
Exact-case matching of names |
|
|
Dotenv file(s) and their encoding |
no file |
|
Flat names for nested models, limit of splits |
none |
|
Treat empty variables as unset |
|
|
A string that is converted to |
none |
|
Directory (or tuple) of one-value-per-file secrets |
none |
|
What to do with unknown dotenv/secret names: |
|
|
Validate default values |
|
|
Merge partial nested values into defaults |
|
|
Files for the matching config sources (added in |
none |
A Cached Settings Dependency, Overridden in Tests
A module-level settings = Settings() is created at import time, so a test must set environment variables before
importing the module, and the instance cannot be replaced afterwards. The pattern in the official docs is a
dependency that returns the settings, cached with functools.lru_cache. Cached, because constructing a settings object
reads the environment and the .env file every time, which you do not want per request; a dependency, because
app.dependency_overrides can then substitute it.
from functools import lru_cache
from typing import Annotated
from fastapi import Depends, FastAPI
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
created = 0
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BOOKSHELF_", env_file=".env", extra="ignore")
app_name: str = "Bookshelf API"
page_size: int = 20
jwt_secret: SecretStr = SecretStr("unset") # placeholder default only for this demo
def model_post_init(self, __context) -> None:
global created
created += 1
@lru_cache
def get_settings() -> Settings:
return Settings()
SettingsDep = Annotated[Settings, Depends(get_settings)]
def get_uncached() -> Settings: # the anti-pattern
return Settings()
app = FastAPI(title="Bookshelf API")
@app.get("/books")
def list_books(settings: SettingsDep) -> dict:
return {"app": settings.app_name, "page_size": settings.page_size}
@app.get("/uncached")
def uncached(settings: Annotated[Settings, Depends(get_uncached)]) -> dict:
return {"page_size": settings.page_size}
(The created counter and the /uncached route exist only to measure instances; the placeholder default
SecretStr("unset") keeps this demo startable and is not something to keep in a real model, where a secret is
required with no default.) The tests override the dependency, clear the cache, and count instances:
import pytest
from fastapi.testclient import TestClient
import settings_dependency as m
from settings_dependency import Settings, app, get_settings
@pytest.fixture
def client():
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
def test_default(client):
assert client.get("/books").json() == {"app": "Bookshelf API", "page_size": 20}
def test_override(client):
app.dependency_overrides[get_settings] = lambda: Settings(page_size=2, app_name="Test shelf")
assert client.get("/books").json() == {"app": "Test shelf", "page_size": 2}
def test_env_after_cache(client, monkeypatch):
monkeypatch.setenv("BOOKSHELF_PAGE_SIZE", "7")
assert client.get("/books").json()["page_size"] == 20 # the cached instance still serves the old value
get_settings.cache_clear()
assert client.get("/books").json()["page_size"] == 7
get_settings.cache_clear()
def test_instances_created(client):
get_settings.cache_clear()
m.created = 0
for _ in range(3):
client.get("/books")
assert m.created == 1 # cached: one instance for three requests
m.created = 0
for _ in range(3):
client.get("/uncached")
assert m.created == 3 # uncached: one instance (and one .env read) per request
All four tests passed. What they show, and how to use the pattern well:
-
Depends(get_settings)is the key of the override:app.dependency_overrides[get_settings] = replacementswaps it for every route and sub-dependency that declares it. Clear the dictionary after each test (the fixture does) so overrides do not leak. More on overrides in Testing. -
Settings(page_size=2)in an override still reads the environment and any.envfor the other fields. Make tests hermetic withSettings(_env_file=None, …), and pass required secrets explicitly with a random test value (for examplesecrets.token_hex(16)), never a real key. -
lru_cachemakes the first call decide: variables changed after it are ignored untilget_settings.cache_clear()(test_env_after_cache). That is why per-test environment changes needcache_clear()before and after. -
The cached object is shared by all requests and threads. Treat settings as read-only:
model_config = SettingsConfigDict(frozen=True)forbids assigning attributes (mutable values such as lists stay mutable). -
Reading settings in an
async deffunction constructs them synchronously; file sources can block the event loop for a moment. Build them at startup (below), not per request, and in a worker thread if you must reload. -
Some newer pydantic-settings documentation describes a built-in
Settings.settings_cached(); it does not exist in 2.15.0 (executed:AttributeError), so@lru_cacheremains the portable pattern.
Twelve-Factor Configuration
The Twelve-Factor App method says that configuration — everything likely to vary between deploys — belongs in the environment, strictly separated from code. The test it offers is whether the code base could be open-sourced at any moment without leaking a credential. In FastAPI terms:
| Principle | In a FastAPI service |
|---|---|
Config in the environment, not in code |
|
Strict separation between code and config |
The same image or wheel runs in dev, staging and prod; only variables, |
No grouping into named "environments" in code |
Prefer many independent variables over a |
Secrets are config, not source |
|
Validate at startup |
Required fields without defaults and Pydantic constraints stop a misconfigured deploy before it serves traffic |
Dev/prod parity |
Run the same dependencies locally with Compose (see Compose in Practice) instead of swapping implementations through module names |
Older material, including a widely read FastAPI book, switches between a fake and a real data layer by setting an
environment variable that selects which module to import. That works, but a settings object plus dependency injection
does it more explicitly: read settings.task_backend (or a repository choice) in one dependency, return the right
implementation, and override that dependency in tests (shown in When to Move to a Task Queue or a Broker). Kubernetes ConfigMaps and
Secrets (ConfigMaps) feed environment variables or files into the Pod, which is
exactly what BaseSettings reads.
Lifespan: Startup and Shutdown Logic
A web service has resources that live as long as the process: an HTTP client with a connection pool, a database engine,
a Redis connection, a loaded ML model, a warmed cache. Creating them per request is wasteful; creating them at import
time makes every test that imports the module pay for them and makes shutdown impossible to do cleanly. The
lifespan is the hook for exactly this. It is one async context manager passed to FastAPI(lifespan=…): the
code before yield runs once before the first request, the code after yield runs once after the last one. This is the
ASGI Lifespan protocol (events lifespan.startup and lifespan.shutdown), implemented by Starlette and driven by the
server.
A Lifespan with a Shared Client and Lifespan State
The lifespan can yield a dictionary. Starlette copies it into every request as request.state ("lifespan state"), so
handlers and dependencies reach the shared objects through the request, without module globals. The example creates one
httpx.AsyncClient (backed here by httpx.MockTransport, so it needs no network) for an ISBN lookup service:
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, TypedDict
import httpx
from fastapi import Depends, FastAPI, Request
logger = logging.getLogger("bookshelf")
logging.basicConfig(level=logging.INFO, format="%(message)s")
class State(TypedDict):
http: httpx.AsyncClient
isbn_cache: dict[str, str]
def fake_isbn_service(request: httpx.Request) -> httpx.Response:
isbn = request.url.path.rsplit("/", 1)[-1]
return httpx.Response(200, json={"isbn": isbn, "title": "Dune"})
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[State]:
logger.info("startup: creating the shared HTTP client")
async with httpx.AsyncClient(
base_url="https://isbn.example",
transport=httpx.MockTransport(fake_isbn_service), # replace in production
timeout=httpx.Timeout(5.0),
) as http:
yield {"http": http, "isbn_cache": {}} # becomes request.state
logger.info("shutdown: HTTP client closed") # reached after the yield
app = FastAPI(title="Bookshelf API", lifespan=lifespan)
def get_http(request: Request) -> httpx.AsyncClient:
return request.state.http
HttpDep = Annotated[httpx.AsyncClient, Depends(get_http)]
@app.get("/lookup/{isbn}")
async def lookup(isbn: str, http: HttpDep, request: Request) -> dict:
cache: dict[str, str] = request.state.isbn_cache
if isbn not in cache:
response = await http.get(f"/isbn/{isbn}")
cache[isbn] = response.json()["title"]
return {"isbn": isbn, "title": cache[isbn], "cached_items": len(cache)}
Source: FastAPI: Lifespan Events
from fastapi.testclient import TestClient
with TestClient(app) as client: # `with` is what runs startup and shutdown
print(client.get("/lookup/978-0441172719").json())
print(client.get("/lookup/978-0441172719").json())
Source: FastAPI: Lifespan Events
startup: creating the shared HTTP client
{'isbn': '978-0441172719', 'title': 'Dune', 'cached_items': 1}
{'isbn': '978-0441172719', 'title': 'Dune', 'cached_items': 1}
shutdown: HTTP client closed
How it works and what to remember:
-
@asynccontextmanagerturns the function into an async context manager, likecontextlib.contextmanagerdoes for sync code (see Files & Context Managers). Thelifespanparameter takes that object; FastAPI enters it withasync withat startup and exits it at shutdown. Usingasync with … as http:around theyield, as above, is the neatest way to guarantee that the resource is closed even if the shutdown is triggered by an error. Atry/finallyaround a plainyielddoes the same. -
The request cannot see the client until startup is complete. The server does not accept requests before the code up to
yieldhas finished, so a slow model load delays readiness rather than failing requests. -
Lifespan state. The yielded mapping appears as
request.state(attribute access:request.state.http). Each request receives a shallow copy, which has two consequences, both checked: mutating a shared object (request.state.isbn_cache[…] = …, adicthere) is visible to every request, but rebinding an attribute (request.state.counter += 1) changes only that request’s copy and is lost. Annotate the mapping with aTypedDictfor the lifespan’s return type;request.state.httpitself remains untyped for the type checker, which is why the smallget_httpdependency with a typed return is worth having. -
app.stateis the older alternative. Assignapp.state.something = …in the lifespan and read it withrequest.app.state.something. It works the same in sub-apps' ownappbut is a single mutable namespace; the yielded state is explicit, per-request and what Starlette recommends. -
Without
with TestClient(app)the lifespan does not run. A plainTestClient(app).get(…)answers500withAttributeError: 'State' object has no attribute 'http'(executed). The same holds forhttpx.AsyncClient(transport=ASGITransport(app=app)); the official async-testing page points toLifespanManagerfrom the third-partyasgi-lifespanpackage for that case. -
A failing startup stops the app. An exception before
yieldpropagates: Uvicorn logs it and exits with "Application startup failed", andTestClientre-raises it in the test (verified with aRuntimeError("database unreachable")).
Settings and Lifespan Together
A realistic service reads its settings first, then builds what depends on them. The next example combines the
nested settings and cached dependency of the previous sections with a pool opened and closed in the lifespan
(Pool stands in for a SQLAlchemy engine, asyncpg pool or Redis client):
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from functools import lru_cache
from typing import Annotated
from fastapi import Depends, FastAPI, Request
from pydantic import BaseModel, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger("bookshelf")
class DatabaseSettings(BaseModel):
url: str = "sqlite:///./bookshelf.db"
pool_size: int = Field(default=5, ge=1)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="BOOKSHELF_",
env_file=".env",
env_nested_delimiter="__",
extra="ignore",
)
app_name: str = "Bookshelf API"
db: DatabaseSettings = DatabaseSettings()
jwt_secret: SecretStr # required, no default
@lru_cache
def get_settings() -> Settings:
return Settings()
SettingsDep = Annotated[Settings, Depends(get_settings)]
class Pool:
"""Stands in for a SQLAlchemy engine or an asyncpg/redis pool."""
def __init__(self, url: str, size: int) -> None:
self.url, self.size, self.closed = url, size, False
def close(self) -> None:
self.closed = True
logger.info("pool closed")
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[dict]:
# a plain call: dependency_overrides do NOT apply here, so look the override up explicitly
settings = app.dependency_overrides.get(get_settings, get_settings)()
pool = Pool(settings.db.url, settings.db.pool_size)
logger.info("pool opened: %s (size %d)", pool.url, pool.size)
try:
yield {"pool": pool}
finally:
pool.close()
app = FastAPI(title="Bookshelf API", lifespan=lifespan)
@app.get("/status")
def status(request: Request, settings: SettingsDep) -> dict:
pool: Pool = request.state.pool
return {"app": settings.app_name, "pool_url": pool.url, "pool_size": pool.size, "closed": pool.closed}
With a .env containing BOOKSHELF_APP_NAME=Bookshelf (dev) and BOOKSHELF_DB__URL=sqlite:///./dev.db, and the
secret and pool size passed in the environment:
BOOKSHELF_JWT_SECRET=$(openssl rand -hex 32) BOOKSHELF_DB__POOL_SIZE=8 fastapi run full.py
curl http://127.0.0.1:8000/status
{"app":"Bookshelf (dev)","pool_url":"sqlite:///./dev.db","pool_size":8,"closed":false}
Without BOOKSHELF_JWT_SECRET the process does not come up (output from uvicorn full:app, abridged):
pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
jwt_secret
Field required [type=missing, input_value={'app_name': 'Bookshelf (...: 'sqlite:///./dev.db'}}, input_type=dict]
ERROR: Application startup failed. Exiting.
The tests use the override and prove both behaviours (they passed):
import secrets
import pytest
from fastapi.testclient import TestClient
from full import Settings, app, get_settings
def test_status_with_override():
app.dependency_overrides[get_settings] = lambda: Settings(
_env_file=None, # ignore any developer .env
jwt_secret=secrets.token_hex(16), # random per run, never a real key
db={"url": "sqlite:///:memory:", "pool_size": 1},
)
try:
with TestClient(app) as client: # `with` runs the lifespan
assert client.get("/status").json() == {
"app": "Bookshelf API",
"pool_url": "sqlite:///:memory:",
"pool_size": 1,
"closed": False,
}
finally:
app.dependency_overrides.clear()
def test_fail_fast_without_secret(monkeypatch):
monkeypatch.delenv("BOOKSHELF_JWT_SECRET", raising=False)
monkeypatch.chdir("/") # no .env here
get_settings.cache_clear()
with pytest.raises(Exception) as exc:
with TestClient(app):
pass
assert "jwt_secret" in str(exc.value)
get_settings.cache_clear()
The comment in lifespan is the important detail. A lifespan is not a dependency: nothing injects into it, so
dependency_overrides do not apply to a plain get_settings() call there. A first version that called
get_settings() directly ignored the test override and raised the ValidationError for the missing secret (executed);
looking the override up in app.dependency_overrides (or setting the environment and clearing the cache) fixes it.
What the Server Does
The lifespan is run by the ASGI server, once per process. Executed with Uvicorn and a lifespan that logs its PID:
uvicorn serve_l:app --workers 2
INFO: Started server process [13282]
INFO: Started server process [13283]
INFO: Waiting for application startup.
INFO: Waiting for application startup.
INFO: startup in pid 13282: opening pool
INFO: startup in pid 13283: opening pool
INFO: Application startup complete.
INFO: Application startup complete.
...
INFO: Shutting down
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Waiting for application shutdown.
INFO: shutdown in pid 13282: closing pool
INFO: shutdown in pid 13283: closing pool
INFO: Application shutdown complete.
INFO: Application shutdown complete.
Consequences for design:
-
Each worker has its own copy of the pool, model and in-memory cache: with
--workers 4and a pool of 10, the database sees up to 40 connections, and a dictionary inrequest.stateis not shared between workers. Shared state across processes belongs in Redis or the database. See Deployment. -
uvicorn --lifespan {auto,on,off}controls the protocol.auto(the default) tolerates an app that does not support lifespan,onfails if lifespan is unsupported, andoffskips it: with--lifespan offthe log shows no startup or shutdown lines and your code before and afteryieldnever runs. -
--reloadrestarts the process, so startup and shutdown run on every code change. -
Shutdown runs after the server stops taking connections. On
SIGINTorSIGTERMUvicorn stops accepting, lets the in-flight requests (including their background tasks, Tasks and Shutdown) finish, and then runs the code afteryield. A hard kill (SIGKILL, out-of-memory kill, a crash) skips it entirely, so cleanup in the lifespan is a courtesy to the other services, not a guarantee.
The Timeline: Startup, Requests, Shutdown
The diagram puts the three mechanisms of this page on one time axis: the lifespan brackets the whole run, requests happen in between, and background tasks start after each response.
Source: ASGI: Lifespan Protocol
Lifespan in Sub-applications and Routers
The official documentation warns that lifespan events run only for the main application, not for sub-applications
mounted with app.mount(…) (see
Sub-applications, WSGI & GraphQL). Verified by running
a mounted app that has its own lifespan, an APIRouter that has one, and an explicit workaround:
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
events: list[str] = []
def make_lifespan(name: str):
@asynccontextmanager
async def lifespan(app: FastAPI):
events.append(f"{name} startup")
yield {"owner": name}
events.append(f"{name} shutdown")
return lifespan
# 1. a mounted sub-application with its own lifespan
admin = FastAPI(title="Admin", lifespan=make_lifespan("admin"))
@admin.get("/ping")
def admin_ping():
return {"ok": True}
main = FastAPI(title="Bookshelf API", lifespan=make_lifespan("main"))
main.mount("/admin", admin)
with TestClient(main) as c:
c.get("/admin/ping")
print("mounted:", events)
# 2. a router with a lifespan, included in the main app
events.clear()
router = APIRouter(prefix="/reviews", lifespan=make_lifespan("reviews-router"))
@router.get("/")
def reviews():
return []
main2 = FastAPI(lifespan=make_lifespan("main"))
main2.include_router(router)
with TestClient(main2) as c:
c.get("/reviews/")
print("included router:", events)
# 3. the parent runs the sub-application's lifespan explicitly
events.clear()
@asynccontextmanager
async def parent_lifespan(app: FastAPI):
events.append("main startup")
async with admin.router.lifespan_context(admin):
yield
events.append("main shutdown")
main3 = FastAPI(lifespan=parent_lifespan)
main3.mount("/admin", admin)
with TestClient(main3) as c:
c.get("/admin/ping")
print("explicit:", events)
mounted: ['main startup', 'main shutdown']
included router: ['main startup', 'reviews-router startup', 'reviews-router shutdown', 'main shutdown']
explicit: ['main startup', 'admin startup', 'admin shutdown', 'main shutdown']
| Situation | What happens (0.142.2) |
|---|---|
Mounted sub-app has |
Never run when the sub-app is mounted: only |
|
Run together with the main lifespan, nested inside it (observed; the tutorial does not document it, so do not make a design depend on the exact nesting) |
Parent enters |
Sub-app startup and shutdown run, nested inside the parent’s |
Sub-app reads |
Sees the parent’s lifespan state ( |
Practical guidance: put shared resources in the main app’s lifespan and let sub-apps read them through
request.state. If a mounted app truly needs its own startup (a third-party app, for instance), enter its lifespan
from the parent as shown. Remember that the mount is invisible to the parent’s OpenAPI schema.
The Deprecated on_event
Before lifespan (introduced in 0.93.0, 2023-03-07) the way to run code at startup and shutdown was a pair of decorators,
@app.on_event("startup") and @app.on_event("shutdown"). They still work in 0.142.2 but are deprecated: the decorator
emits DeprecationWarning: on_event is deprecated, use lifespan event handlers instead. Their design problem
is that startup and shutdown are separate functions, so a resource opened in one has to be stored in a global to be
closed in the other. Verified behaviour:
import warnings
from fastapi import FastAPI
from fastapi.testclient import TestClient
log: list[str] = []
old = FastAPI()
@old.on_event("startup") # deprecated: emits DeprecationWarning
async def startup() -> None:
log.append("startup")
@old.on_event("shutdown")
def shutdown() -> None:
log.append("shutdown")
with TestClient(old):
pass
print(log) # ['startup', 'shutdown']
Two more facts: it is all lifespan or all events, never both — an app created with lifespan= ignores its on_event
handlers (a check printed only ['lifespan start', 'lifespan stop'] with an additional on_event("startup") registered); and the
handlers could be def or async def. Migrating is mechanical: move the startup code before the yield of an
@asynccontextmanager, the shutdown code after it, and replace globals with yielded state.
BackgroundTasks: Work After the Response
Sometimes the client does not need to wait for a side effect: an e-mail confirmation, a cache refresh, an audit record, a
small file write. Background tasks are functions that run after the response has been sent, in the same process.
BackgroundTasks is Starlette’s class (starlette.background), re-exported by FastAPI. Declare a parameter of that type
and FastAPI injects an instance, as it does for Request; call add_task(function, *args, **kwargs).
In a Path Operation and in a Dependency
A task is an ordinary def or async def function. add_task takes the function, then positional and keyword arguments
for it. The parameter can be declared at several levels (path operation, dependency, sub-dependency) and FastAPI
reuses the same object, so all the tasks are merged and run together afterwards, in the order they were added. The next
app has both: the dependency audit adds one task, the path function two more.
import asyncio
import logging
from typing import Annotated
from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel
logger = logging.getLogger("bookshelf")
events: list[str] = []
def record(msg: str) -> None:
try:
asyncio.get_running_loop()
where = "event loop"
except RuntimeError:
where = "threadpool"
events.append(f"{msg} [{where}]")
class ReviewIn(BaseModel):
reader: str
stars: int
def send_thanks_email(address: str, *, subject: str) -> None: # def: runs in the threadpool
record(f"email to {address}: {subject}")
async def refresh_rating_cache(isbn: str) -> None: # async def: runs on the event loop
await asyncio.sleep(0)
record(f"rating cache refreshed for {isbn}")
def audit(background_tasks: BackgroundTasks, x_request_id: Annotated[str, Header()] = "n/a") -> str:
background_tasks.add_task(record, f"audit request {x_request_id}") # added from a dependency
return x_request_id
app = FastAPI(title="Bookshelf API")
@app.post("/books/{isbn}/reviews", status_code=201)
async def add_review(
isbn: str,
review: ReviewIn,
background_tasks: BackgroundTasks,
request_id: Annotated[str, Depends(audit)],
) -> ReviewIn:
if review.stars == 0:
raise HTTPException(status_code=422, detail="stars must be 1..5")
background_tasks.add_task(send_thanks_email, f"{review.reader}@example.com", subject="Thanks!")
background_tasks.add_task(refresh_rating_cache, isbn)
record("path function done")
return review
Source: FastAPI: Background Tasks
curl -i -X POST http://127.0.0.1:8000/books/978-0441172719/reviews \
-H 'Content-Type: application/json' -H 'X-Request-Id: req-1' \
-d '{"reader":"ana","stars":5}'
{"reader":"ana","stars":5}
The status is 201, and the events list (taken from a TestClient run) shows the order:
path function done [event loop]
audit request req-1 [threadpool]
email to ana@example.com: Thanks! [threadpool]
rating cache refreshed for 978-0441172719 [event loop]
The path function finished first, then the tasks ran in insertion order (the dependency’s task first, because the dependency executed first). Notes:
-
deftasks run in the threadpool,async deftasks on the event loop, exactly as for path operations. The[threadpool]and[event loop]markers above come fromasyncio.get_running_loop()inside the task. All the rules of Async & Concurrency apply: never block inside anasync deftask. -
Arguments are evaluated at
add_task, not when the task runs. Pass identifiers and plain data, not ORM objects, open sessions or theRequest; those may be closed or invalid by then. -
Tasks added before an exception are dropped. With
stars=0the path function raisedHTTPException; the response was the422error and theeventslist stayed empty, including the task the dependency had already queued. Tasks run only for a response that completes normally. -
Raising or returning a
Responsedirectly. A task added to the injectedBackgroundTasksstill runs when the path function returns aResponse(status_code=202)yourself (checked); if you setResponse(background=BackgroundTask(…))yourself, that one runs instead and the injected tasks are not attached.BackgroundTask(singular) is the Starlette class for that case; for everything else useBackgroundTasks(plural) so FastAPI can manage it. -
No result, no status. The client gets the response before anything runs, so a task cannot change it, and there is nobody to return a value to. If the client must learn the outcome, return a job id (
202 Accepted) and expose a status endpoint, which in turn needs shared storage (see When to Move to a Task Queue or a Broker). -
OpenAPI.
BackgroundTasksis not a request parameter; it does not appear in the schema or the Swagger UI.
Interplay with Dependencies with yield
Two timing rules, both explained with traces on Dependencies with yield:
| FastAPI version / dependency scope | What a background task sees |
|---|---|
0.106.0 to 0.117 (2023-12 to 2025-09) |
The yield dependency’s exit code ran before the task: sessions were closed, so tasks could not use them |
0.118.0 and later, default |
The exit code runs after the response and after the tasks, so the session is still open inside the task |
|
The exit code runs before the response is sent, so the task finds the resource closed |
Relying on a request-scoped resource staying open inside a task works in current versions but ties the task to FastAPI’s
lifecycle and hides a database connection until the task finishes. The robust design is the one the official docs
recommend: a background task owns its resources. Open a new session (or take a client from the lifespan state’s pool)
inside the task, and pass only IDs to it. Part of that is that you cannot simply use the request’s Session object
after the response: create a short-lived one with the same factory your dependency uses.
Errors, Blocking and Shutdown
Behaviour verified with TestClient (errors) and a real Uvicorn server (timing and shutdown).
from fastapi import BackgroundTasks, FastAPI
from fastapi.testclient import TestClient
ran: list[str] = []
app = FastAPI()
def ok(n): ran.append(f"task {n}")
def boom(): raise RuntimeError("smtp server is down")
@app.post("/failing")
def failing(tasks: BackgroundTasks):
tasks.add_task(ok, 1); tasks.add_task(boom); tasks.add_task(ok, 3)
return {"accepted": True}
with TestClient(app, raise_server_exceptions=False) as c:
r = c.post("/failing")
print(r.status_code, r.json(), ran)
Source: Starlette: Background Tasks
200 {'accepted': True} ['task 1']
-
A failing task does not change the response (it is already sent) and stops the remaining tasks:
task 3never ran, and the traceback is only logged by the server.TestClientre-raises it by default (raise_server_exceptions=True), which is how a test notices. In production, catch and log inside the task (with context such as the review id), because nothing else will tell you. -
Tasks are sequential, not concurrent. Several tasks added in one request run one after the other.
-
The server treats the task as part of the request.
curlreturns when the response is sent, but the work continues. The next server run shows adeftask that sleeps two seconds and anasync deftask that blocks withtime.sleep:
import asyncio
import logging
import time
from fastapi import BackgroundTasks, FastAPI
log = logging.getLogger("uvicorn.error")
app = FastAPI(title="Bookshelf API")
def build_report_sync(isbn: str) -> None: # def: threadpool, does not block the loop
time.sleep(2)
log.info("sync report for %s finished", isbn)
async def build_report_blocking(isbn: str) -> None: # async def with a blocking call: freezes the loop
time.sleep(2)
log.info("blocking async report for %s finished", isbn)
@app.post("/reports/sync/{isbn}", status_code=202)
def report_sync(isbn: str, tasks: BackgroundTasks) -> dict:
tasks.add_task(build_report_sync, isbn)
return {"status": "accepted"}
@app.post("/reports/blocking/{isbn}", status_code=202)
def report_blocking(isbn: str, tasks: BackgroundTasks) -> dict:
tasks.add_task(build_report_blocking, isbn)
return {"status": "accepted"}
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
Source: FastAPI: Background Tasks
curl -s -o /dev/null -w "POST %{time_total}s\n" -X POST http://127.0.0.1:8124/reports/sync/978-0441172719
curl -s -o /dev/null -w "health during task %{time_total}s\n" http://127.0.0.1:8124/health
curl -s -o /dev/null -w "POST %{time_total}s\n" -X POST http://127.0.0.1:8124/reports/blocking/978-0441172719
curl -s -o /dev/null -w "health during task %{time_total}s\n" http://127.0.0.1:8124/health
POST 0.034281s
health during task 0.001130s
POST 0.000871s
health during task 1.692800s
The POST returned at once in both cases (the task runs after the response), but while the blocking async def task ran,
the unrelated /health request waited about 1.7 seconds: it froze the event loop for every client. The def task left
/health instant. The fix is the same as anywhere else: make the task def, await an async client, or call
run_in_threadpool (see Async & Concurrency).
Tasks and Shutdown
On SIGINT Uvicorn logged Waiting for background tasks to complete. (CTRL+C to force quit), let the two-second task
finish, and only then ran application shutdown. With --timeout-graceful-shutdown 1 the same task was cancelled
after one second (a CancelledError traceback in the log). So a task shorter than your platform’s termination grace
period is usually finished on a rolling deploy, a long one is cut off, and a crash loses everything queued. A
background task is not durable: no retry, no persistence, no visibility.
Limits of BackgroundTasks
-
Runs in the same process and the same worker; with
--workers Nthe load of tasks is not balanced across them. -
No retries, no scheduling (delays, cron), no priorities, no result store, no progress, no rate limiting.
-
A queue of tasks lives in memory; a restart or crash loses it. The client cannot cancel or inspect a task.
-
CPU-heavy work in a task still competes with request handling in the same process (see the CPU-bound section of Async & Concurrency).
When to Move to a Task Queue or a Broker
The official docs draw the line this way: if the work needs heavy computation, does not have to run in the same process
(no shared memory or variables), or has to run on multiple processes and servers, use a bigger tool such as Celery, with a
message or job queue manager like RabbitMQ or Redis. If you need the objects of the same app, or the jobs are small (send
an e-mail), BackgroundTasks is enough.
| Requirement | BackgroundTasks |
Task queue (Celery, arq) or broker |
|---|---|---|
Survive restart or crash |
No |
Yes, messages are persisted (depending on broker settings) |
Retry with backoff, dead-letter handling |
Hand-written |
Built in or a broker feature |
Run on other machines, scale workers independently |
No |
Yes |
Delayed or periodic jobs (cron-like) |
No |
Yes (beat scheduler, cron jobs) |
Results, status, cancellation |
No |
Result backends or your own table |
Long or CPU-heavy work |
Competes with requests |
Dedicated worker processes |
Share in-process state with the app |
Yes |
No (data crosses a boundary) |
Operations cost |
None |
Broker, workers, monitoring |
Celery is the long-established distributed task queue (broker such as RabbitMQ or Redis, workers, optional beat
scheduler); arq is a smaller asyncio-native queue on Redis that fits async FastAPI code; a message broker used
directly (RabbitMQ, Kafka) is the right tool when the work is really an event several independent consumers react to. The
concepts of queues, acknowledgements and idempotent consumers are in
Getting Started with Messaging,
Messaging Models and Patterns and
Delivery Guarantees; when the message must be published atomically with a
database write, see
The Transactional Outbox Pattern. Whatever the
queue, tasks are retried, so they must be idempotent (recalculating a rating twice must not double-count).
A design that keeps both options open hides the mechanism behind one small interface, selected by a setting and replaceable
in tests, which also illustrates the settings-as-dependency idea from the twelve-factor section. The BrokerRunner
below is a stub that records the message; in a real service its submit would call the queue client’s enqueue function
(for example Celery’s send_task or an arq pool’s enqueue_job; those client libraries were not run here).
from typing import Annotated, Literal, Protocol
from fastapi import BackgroundTasks, Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BOOKSHELF_")
task_backend: Literal["inline", "broker"] = "inline"
broker_url: str = "amqp://localhost"
class TaskRunner(Protocol):
def submit(self, name: str, **kwargs: object) -> None: ...
HANDLERS = {}
def handler(fn):
HANDLERS[fn.__name__] = fn
return fn
@handler
def recalculate_rating(isbn: str) -> None:
print(f"recalculating rating for {isbn}")
class InlineRunner:
"""FastAPI BackgroundTasks: same process, lost if the process dies."""
def __init__(self, tasks: BackgroundTasks) -> None:
self.tasks = tasks
def submit(self, name: str, **kwargs: object) -> None:
self.tasks.add_task(HANDLERS[name], **kwargs)
class BrokerRunner:
"""Adapter for Celery, arq, a RabbitMQ/Kafka producer, ...: only enqueues a message."""
sent: list[tuple[str, dict]] = []
def submit(self, name: str, **kwargs: object) -> None:
self.sent.append((name, kwargs)) # replace by `celery_app.send_task(...)`, a publish, ...
def get_settings() -> Settings:
return Settings()
def get_runner(
settings: Annotated[Settings, Depends(get_settings)], tasks: BackgroundTasks
) -> TaskRunner:
return InlineRunner(tasks) if settings.task_backend == "inline" else BrokerRunner()
RunnerDep = Annotated[TaskRunner, Depends(get_runner)]
app = FastAPI(title="Bookshelf API")
@app.post("/books/{isbn}/reviews", status_code=202)
def add_review(isbn: str, runner: RunnerDep) -> dict:
runner.submit("recalculate_rating", isbn=isbn)
return {"accepted": True}
c = TestClient(app)
print(c.post("/books/978-0441172719/reviews").json())
app.dependency_overrides[get_settings] = lambda: Settings(task_backend="broker")
print(c.post("/books/978-0441172719/reviews").json(), BrokerRunner.sent)
recalculating rating for 978-0441172719
{'accepted': True}
{'accepted': True} [('recalculate_rating', {'isbn': '978-0441172719'})]
The first request ran the handler inline (after the response); with the override the second request only enqueued a
message, and the route did not change. Switching BOOKSHELF_TASK_BACKEND=broker in the environment does the same in a
deployment. The queue client itself (a Celery app, an arq pool, a Kafka producer) is a perfect candidate for the
lifespan: create it at startup, expose it through lifespan state, and close it at shutdown.
Pitfalls
-
Committing
.env, or putting a real secret in a default. Defaults are source code. RequiredSecretStrwithout default,.envin.gitignore, keys fromopenssl rand -hex 32or a secret store. -
Unknown names in
.env.extra="forbid"is the default for settings: one unrelated line breaks startup. Useextra="ignore"or prefix everything. -
Comma-separated lists in environment variables. They are parsed as JSON; use a JSON array or
NoDecodeplus a validator. -
Calling
Settings()per request (a dependency withoutlru_cache): re-reads files, as the counter proved (three requests, three instances). -
Forgetting
cache_clear()in tests that change the environment; or relying ondependency_overridesinside the lifespan, where it does not apply. -
Relative
env_fileandsecrets_dirpaths. They depend on the working directory; a service started from another folder silently gets defaults for.envand only a warning for a missing secrets directory. -
Printing or logging the settings.
SecretStrmasks itself, but plainstrfields holding passwords, URLs with credentials or thePYDANTIC_SETTINGS_DEBUGoutput do not. -
Lifespan not running in tests. Use
with TestClient(app); plainTestClient(app)andhttpx.ASGITransportdo not run it. -
Rebinding lifespan state per request.
request.state.x = …changes the request’s copy only; mutate the shared object. -
Resources created at import time. Module-level clients and pools are created on import (tests, the
fastapiCLI importing the app, reloader processes), and are never closed. Create them in the lifespan. -
Expecting a mounted sub-app’s lifespan to run. It does not (see the table above).
-
Mixing
on_eventwithlifespan. The handlers are silently ignored. -
Blocking inside an
async defbackground task. Freezes the event loop for all requests (1.7 seconds in the run above). -
Assuming background tasks are reliable. No retries, lost on a crash, cut at shutdown timeouts; a failing task stops the ones after it.
-
Passing the open request session or ORM instances to a task. Pass IDs and open a fresh session inside.
-
Using
BackgroundTasksfor long-running or scheduled work. Move it to a queue (When to Move to a Task Queue or a Broker). -
One pool per worker. Size pools for
workers x pool_size, not for one process.
Where Older Guides Are Outdated, and What Changed Recently
| Older guides and code | Today |
|---|---|
|
|
|
|
|
Yield a dictionary from the lifespan and read |
Environment variable switching between a fake and a real module to select the data layer |
A settings value and a dependency that returns the implementation, overridden with |
A settings instance created at import and reconfigured by monkeypatching |
|
Secrets in code, in Dockerfiles or in |
|
Yield dependencies unusable in background tasks (0.106.0, 2023-12-25) |
Usable again since 0.118.0 (2025-09-29); |
|
A same-process convenience; durable, retried or distributed work belongs in a queue or broker |
Gunicorn/Uvicorn worker snippets that assume one shared in-memory state |
Each worker process runs its own lifespan and holds its own state |
Release history lives on What’s Changed & Migration; neighbouring topics are on Dependency Injection, Dependencies with yield, Testing and Deployment.