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

Table 1. Choosing the mechanism
You need Use Runs

Values that differ between environments (URLs, flags, limits)

A BaseSettings subclass read from environment variables, .env and secret files

Once per process (cached), validated at startup

Secrets (JWT key, database password)

SecretStr fields filled from the environment or a secrets directory, never from source code

Same as above

A resource shared by all requests (HTTP client, connection pool, ML model)

lifespan creates it and yields it as state

Once per process: startup before the first request, cleanup after the last

A resource that belongs to one request (database session)

A dependency with yield (Dependencies with yield)

Per request

A small job that may finish after the response (send an e-mail, refresh a cache)

BackgroundTasks

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_prefix plus the field name, matched case-insensitively by default, so bookshelf_admin_email, BOOKSHELF_ADMIN_EMAIL and Bookshelf_Admin_Email all fill admin_email. Set case_sensitive=True to require exact case. The default prefix is empty, and the prefix also applies to .env files and secret files, not only to the process environment. A prefix such as BOOKSHELF_ keeps the application’s names from colliding with unrelated variables such as DEBUG or PORT.

  • Types. Environment variables are always strings. pydantic-settings converts them with the field’s type: "50" becomes 50, "yes" becomes True (the usual Pydantic boolean spellings 1/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 plain BaseModel, unless you set validate_default=False.

  • SecretStr. Its repr(), str() and JSON serialization show , so an accidental print(settings) or a response model containing it does not leak the value. Call get_secret_value() only where the plain value is really needed (signing a token, opening a connection). SecretBytes is the bytes counterpart.

  • Fail fast. A bad value or a missing required field raises a ValidationError naming every problem at once. That is what you want at startup, in the logs of the new deployment, rather than a 500 on 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

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
>>> 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:

  • extra defaults to forbid. Unlike BaseModel, a settings class rejects names found in the .env file that it does not know, such as a stray UNRELATED_TOOL_SETTING. Variables in the process environment are not affected, only the dotenv file. Set extra="ignore" (what most services want) or keep every name in the file prefixed and declared.

  • Several files. env_file accepts a tuple; a later file overrides an earlier one, so .env plus .env.prod (above, app_name became Bookshelf). 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) and Settings(_env_file=".env.test") replaces it. env_file_encoding sets 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 ''. Set env_ignore_empty=True to 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):

Table 2. Source priority, highest first
Rank Source Typical use

1

Arguments to the initializer, Settings(page_size=2)

Tests, programmatic overrides

2

Process environment variables

Production: set by the platform

3

Dotenv files (env_file)

Local development

4

Secret files (secrets_dir)

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}' fills db at once, and a BOOKSHELF_DB__POOL_SIZE=3 set next to it is merged on top (result: url='sqlite:///:memory:' pool_size=3 echo=True).

  • Lists and dicts are JSON. BOOKSHELF_ALLOWED_ORIGINS must be a JSON array. A comma-separated value such as https://a.example,https://b.example raises a SettingsError ("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 NoDecode and split in a mode="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

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 of is tempting (BOOKSHELF_DB_URL) but ambiguous with underscores inside field names (pool_size); env_nested_max_split=1 limits the split so llm_api_key stays 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=True makes 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()))
SecretStr('**********') 32 SecretStr('**********')
8

Facts worth knowing (all observed on 2.15.0 or stated on the official page):

  • A missing secrets_dir is 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_dir accepts a tuple of directories; later ones override earlier ones. Settings(_secrets_dir=…​) overrides the configured value per instance, which tests use.

  • Environment variables and .env rank above secret files (table above), so a stale exported variable hides the mounted secret. Reorder with settings_customise_sources if the file must win.

  • The default secrets source supports only top-level fields. The opt-in NestedSecretsSettingsSource adds fields of nested models, using either a flat layout (db_passwd with secrets_nested_delimiter="_") or sub-directories (secrets/db/password with secrets_nested_subdir=True). The example below creates secrets/db/password and resolves settings.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))

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.

Table 3. Frequently used BaseSettings options
Option Effect Default

env_prefix

Prefix for every variable, dotenv key and secret file name

""

case_sensitive

Exact-case matching of names

False

env_file, env_file_encoding

Dotenv file(s) and their encoding

no file

env_nested_delimiter, env_nested_max_split

Flat names for nested models, limit of splits

none

env_ignore_empty

Treat empty variables as unset

False

env_parse_none_str

A string that is converted to None (for example "null")

none

secrets_dir

Directory (or tuple) of one-value-per-file secrets

none

extra

What to do with unknown dotenv/secret names: forbid or ignore

forbid

validate_default

Validate default values

True

nested_model_default_partial_update

Merge partial nested values into defaults

False

toml_file, json_file, yaml_file

Files for the matching config sources (added in settings_customise_sources)

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] = replacement swaps 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 .env for the other fields. Make tests hermetic with Settings(_env_file=None, …​), and pass required secrets explicitly with a random test value (for example secrets.token_hex(16)), never a real key.

  • lru_cache makes the first call decide: variables changed after it are ignored until get_settings.cache_clear() (test_env_after_cache). That is why per-test environment changes need cache_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 def function 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_cache remains 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:

Table 4. Applying the config factor
Principle In a FastAPI service

Config in the environment, not in code

BaseSettings fields; no if ENV == "prod" branches around literals

Strict separation between code and config

The same image or wheel runs in dev, staging and prod; only variables, .env or mounted secrets differ

No grouping into named "environments" in code

Prefer many independent variables over a ProductionSettings subclass tree; a .env.prod file is a local convenience, not the production mechanism

Secrets are config, not source

SecretStr, secrets_dir, platform secret stores; generate keys with openssl rand -hex 32

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)}
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())
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:

  • @asynccontextmanager turns the function into an async context manager, like contextlib.contextmanager does for sync code (see Files & Context Managers). The lifespan parameter takes that object; FastAPI enters it with async with at startup and exits it at shutdown. Using async with …​ as http: around the yield, as above, is the neatest way to guarantee that the resource is closed even if the shutdown is triggered by an error. A try/finally around a plain yield does the same.

  • The request cannot see the client until startup is complete. The server does not accept requests before the code up to yield has 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[…​] = …​, a dict here) 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 a TypedDict for the lifespan’s return type; request.state.http itself remains untyped for the type checker, which is why the small get_http dependency with a typed return is worth having.

  • app.state is the older alternative. Assign app.state.something = …​ in the lifespan and read it with request.app.state.something. It works the same in sub-apps' own app but 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 plain TestClient(app).get(…​) answers 500 with AttributeError: 'State' object has no attribute 'http' (executed). The same holds for httpx.AsyncClient(transport=ASGITransport(app=app)); the official async-testing page points to LifespanManager from the third-party asgi-lifespan package for that case.

  • A failing startup stops the app. An exception before yield propagates: Uvicorn logs it and exits with "Application startup failed", and TestClient re-raises it in the test (verified with a RuntimeError("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 4 and a pool of 10, the database sees up to 40 connections, and a dictionary in request.state is 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, on fails if lifespan is unsupported, and off skips it: with --lifespan off the log shows no startup or shutdown lines and your code before and after yield never runs.

  • --reload restarts the process, so startup and shutdown run on every code change.

  • Shutdown runs after the server stops taking connections. On SIGINT or SIGTERM Uvicorn stops accepting, lets the in-flight requests (including their background tasks, Tasks and Shutdown) finish, and then runs the code after yield. 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.

sequenceDiagram autonumber participant S as ASGI server (Uvicorn) participant L as lifespan(app) participant A as FastAPI app participant B as Background tasks Note over S,L: Startup S->>L: lifespan.startup L->>L: Settings read and validated, code before yield L->>L: open pool, create HTTP client, load model L-->>S: startup complete, yielded state S->>S: start accepting connections loop for every request S->>A: HTTP request, with a copy of the lifespan state A->>A: dependencies, path function A-->>S: response sent to the client A->>B: run BackgroundTasks after the response B-->>A: tasks finished (or an exception is logged) end Note over S,L: Shutdown S->>S: SIGINT or SIGTERM: stop accepting, wait for in-flight requests and tasks S->>L: lifespan.shutdown L->>L: code after yield, close client and pool L-->>S: shutdown complete S->>S: process exits

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 lifespan=

Never run when the sub-app is mounted: only main started and stopped

APIRouter(lifespan=…​) included with include_router

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 admin.router.lifespan_context(admin) itself

Sub-app startup and shutdown run, nested inside the parent’s

Sub-app reads request.state

Sees the parent’s lifespan state ({'db': 'parent-pool'} in a check), because the state travels in the ASGI scope

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
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:

  • def tasks run in the threadpool, async def tasks on the event loop, exactly as for path operations. The [threadpool] and [event loop] markers above come from asyncio.get_running_loop() inside the task. All the rules of Async & Concurrency apply: never block inside an async def task.

  • Arguments are evaluated at add_task, not when the task runs. Pass identifiers and plain data, not ORM objects, open sessions or the Request; those may be closed or invalid by then.

  • Tasks added before an exception are dropped. With stars=0 the path function raised HTTPException; the response was the 422 error and the events list stayed empty, including the task the dependency had already queued. Tasks run only for a response that completes normally.

  • Raising or returning a Response directly. A task added to the injected BackgroundTasks still runs when the path function returns a Response(status_code=202) yourself (checked); if you set Response(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 use BackgroundTasks (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. BackgroundTasks is 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:

Table 5. Teardown of a yield dependency versus background tasks
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 scope="request"

The exit code runs after the response and after the tasks, so the session is still open inside the task

Depends(…​, scope="function") (0.121.0, 2025-11-03)

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)
200 {'accepted': True} ['task 1']
  • A failing task does not change the response (it is already sent) and stops the remaining tasks: task 3 never ran, and the traceback is only logged by the server. TestClient re-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. curl returns when the response is sent, but the work continues. The next server run shows a def task that sleeps two seconds and an async def task that blocks with time.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"}
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 N the 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.

Table 6. BackgroundTasks versus a queue
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. Required SecretStr without default, .env in .gitignore, keys from openssl rand -hex 32 or a secret store.

  • Unknown names in .env. extra="forbid" is the default for settings: one unrelated line breaks startup. Use extra="ignore" or prefix everything.

  • Comma-separated lists in environment variables. They are parsed as JSON; use a JSON array or NoDecode plus a validator.

  • Calling Settings() per request (a dependency without lru_cache): re-reads files, as the counter proved (three requests, three instances).

  • Forgetting cache_clear() in tests that change the environment; or relying on dependency_overrides inside the lifespan, where it does not apply.

  • Relative env_file and secrets_dir paths. They depend on the working directory; a service started from another folder silently gets defaults for .env and only a warning for a missing secrets directory.

  • Printing or logging the settings. SecretStr masks itself, but plain str fields holding passwords, URLs with credentials or the PYDANTIC_SETTINGS_DEBUG output do not.

  • Lifespan not running in tests. Use with TestClient(app); plain TestClient(app) and httpx.ASGITransport do 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 fastapi CLI 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_event with lifespan. The handlers are silently ignored.

  • Blocking inside an async def background 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 BackgroundTasks for 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

Table 7. Settings, events and tasks over time
Older guides and code Today

from pydantic import BaseSettings and class Config: env_file = ".env"

from pydantic_settings import BaseSettings, SettingsConfigDict with model_config = SettingsConfigDict(env_file=".env") (Pydantic v2; the separate package comes with fastapi[standard])

@app.on_event("startup") / ("shutdown") with globals

lifespan= with an @asynccontextmanager (supported since 0.93.0, 2023-03-07); on_event is deprecated and ignored when a lifespan is given

app.state.client = …​ set in startup events

Yield a dictionary from the lifespan and read request.state (Starlette lifespan state)

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 app.dependency_overrides in tests

A settings instance created at import and reconfigured by monkeypatching

@lru_cache dependency get_settings() plus dependency_overrides

Secrets in code, in Dockerfiles or in ENV lines

SecretStr fields, mounted secret files (secrets_dir), a secret store; never in the image

Yield dependencies unusable in background tasks (0.106.0, 2023-12-25)

Usable again since 0.118.0 (2025-09-29); scope="function" or "request" since 0.121.0 (2025-11-03)

BackgroundTasks presented as the answer to all asynchronous work

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.