FastAPI App Factory Pattern for Testing and Deployment

Key takeaways:

  • create_app() returns an independent object graph per call, so two instances built by the same factory disagree about settings, routes and overrides.
  • app.dependency_overrides is a dict on the app object, which is exactly why a shared app leaks mocks between tests and a fresh app cannot.
  • TestClient runs the lifespan only inside a with block; without it app.state is empty and the handler fails on an attribute that was never set.
  • Keep the factory synchronous and I/O-free, and let the lifespan own every resource.
  • The same factory deploys with uvicorn app.main:create_app --factory, giving each worker its own instance.

This guide is the hands-on companion to Application Factory Patterns. Read that page for the rationale; this one is about the mechanics, and every claim below is backed by a transcript from a real run.

The Problem This Solves

A module-level app = FastAPI() is constructed at import, once, for the lifetime of the interpreter. Every test that imports your routers gets that same object. So does every helper script, every management command, and every conftest. The consequences are familiar to anyone who has debugged a flaky suite: a test that passes alone and fails in the suite, a mock that appears in an unrelated test file, an app.state attribute set by whichever test happened to run first.

None of that is fixed by careful teardown, because careful teardown is a discipline and the shared object is a fact. The factory changes the fact.

Shared instance versus one instance per testThe upper row shows two tests using one module-level application, where an override set by the first test is still present in the second. The lower row shows the same two tests each calling create_app, so the override exists only on the first instance.Module-level app = FastAPI()test onesets overrideone shared app objectdependency_overrides dicttest twoinherits the mockcreate_app() per testtest onesets overrideinstance A: mockedinstance B: defaulttest twosees production wiringIsolation is a property of the object, not of the teardown code.

Why It Happens

Three FastAPI facts combine into the whole pattern.

dependency_overrides is an instance attribute. It is initialised as an empty dict in FastAPI.__init__ and consulted by the dependency solver on every request against self.dependency_overrides. There is no registry, no context variable, no scoping. An override therefore lives exactly as long as the app object that holds it — which is forever, if that object was created at import.

app.state is also per instance. It is a starlette.datastructures.State, a namespace with no default values. If nothing sets app.state.pool, reading it raises AttributeError, and because handlers usually read it several frames deep the traceback points at your code rather than at the missing startup.

The lifespan runs on the ASGI lifespan scope, not on import. Constructing an app does not open its pools. Something has to drive the lifespan protocol: uvicorn does it before binding the port, and TestClient does it in __enter__. Construct a TestClient without a with block and nothing drives it at all, which produces the second fact's AttributeError.

Put together: construction is cheap and side-effect-free, resources are acquired separately and deterministically, and everything mutable is attached to an object you can throw away. The factory is just the function that makes that shape available to more than one caller.

The Fix

Keep the factory to pure assembly. No await, no network, no os.environ reads outside the settings object:

# app/main.py
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator

from fastapi import FastAPI

from app.config import Settings, get_settings
from app.db import open_pool
from app.routers import api_router, admin_router, health_router


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    settings: Settings = app.state.settings
    app.state.pool = await open_pool(settings.database_url)
    try:
        yield
    finally:
        await app.state.pool.close()


def create_app(settings: Settings | None = None) -> FastAPI:
    """Pure construction. Cheap enough to call once per test, safe to call at import."""
    cfg = settings or get_settings()
    app = FastAPI(
        title=cfg.project_name,
        lifespan=lifespan,
        docs_url="/docs" if cfg.environment != "production" else None,
    )
    app.state.settings = cfg
    app.include_router(health_router, prefix="/health")
    app.include_router(api_router, prefix="/v1")
    if cfg.environment != "production":
        app.include_router(admin_router, prefix="/admin")
    return app

The ordering of lifespan and the resources it owns is a topic in its own right, covered in lifespan events vs startup and shutdown; what matters here is only that acquisition happens after construction.

Instances built by one factory really are independent

The transcript below is a real run. Three applications are built from that factory shape with different settings, each one started through the actual ASGI lifespan protocol and then queried over an in-process transport. The test instance — and only that one — gets a dependency override.

$ GET /instances
200 OK
{
  "instances": [
    {
      "title": "prod-app",
      "environment": "production",
      "pool_from_lifespan": "pool<postgresql://primary.internal:5432/app>",
      "dsn_from_dependency": "postgresql://primary.internal:5432/app",
      "docs_url": null,
      "admin_route_registered": false,
      "admin_status": 404,
      "overrides_on_this_instance": 0
    },
    {
      "title": "staging-app",
      "environment": "staging",
      "pool_from_lifespan": "pool<postgresql://staging.internal:5432/app>",
      "dsn_from_dependency": "postgresql://primary.internal:5432/app",
      "docs_url": "/docs",
      "admin_route_registered": true,
      "admin_status": 200,
      "overrides_on_this_instance": 0
    },
    {
      "title": "test-app",
      "environment": "test",
      "pool_from_lifespan": "pool<sqlite+aiosqlite:///:memory:>",
      "dsn_from_dependency": "sqlite+aiosqlite:///:memory:",
      "docs_url": "/docs",
      "admin_route_registered": true,
      "admin_status": 200,
      "overrides_on_this_instance": 1
    }
  ],
  "lifespan_events": [
    "prod-app: startup - opening pool for postgresql://primary.internal:5432/app",
    "prod-app: shutdown - closing pool<postgresql://primary.internal:5432/app>",
    "staging-app: startup - opening pool for postgresql://staging.internal:5432/app",
    "staging-app: shutdown - closing pool<postgresql://staging.internal:5432/app>",
    "test-app: startup - opening pool for sqlite+aiosqlite:///:memory:",
    "test-app: shutdown - closing pool<sqlite+aiosqlite:///:memory:>"
  ]
}

Every axis of variation is visible at once. The production instance has docs_url: null and returns 404 for the admin route because the router was never included — not hidden, not gated at request time, absent from the routing table and therefore absent from the OpenAPI schema too. The staging instance has the same admin routes as the test instance but resolved the production DSN, because the override was applied only to the test instance. And each instance's lifespan opened and closed its own pool, with a URL taken from its own settings object.

That staging row is the one worth dwelling on. It is the same factory, the same dependency function, the same code path — and it saw the real default while its sibling saw the double. That is what isolation means in practice.

An override cannot escape its instance

The narrower proof, with two instances built from identical settings:

$ GET /override-does-not-leak
200 OK
{
  "dsn_resolved": {
    "overridden": "sqlite+aiosqlite:///:memory:",
    "sibling": "postgresql://primary.internal:5432/app"
  },
  "same_factory": true,
  "sibling_saw_production_default": true
}

Nothing was cleared between them. The sibling resolved the production default because the override never existed in its dict — which is the difference between a suite that is correct by construction and one that is correct as long as every fixture remembers its teardown.

Lifespans are per instance and nest

$ GET /lifespan-order
200 OK
{
  "events": [
    "prod-app: startup - opening pool for postgresql://primary.internal:5432/app",
    "test-app: startup - opening pool for sqlite+aiosqlite:///:memory:",
    "both apps serving",
    "test-app: shutdown - closing pool<sqlite+aiosqlite:///:memory:>",
    "second app stopped, first still serving",
    "prod-app: shutdown - closing pool<postgresql://primary.internal:5432/app>"
  ]
}

Two applications alive simultaneously, each holding its own pool, tearing down in reverse of the order they started. This is what makes a session-scoped app and a function-scoped app safe to combine in one test suite.

The Test Fixture

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient

from app.config import Settings
from app.db import get_session
from app.main import create_app


@pytest.fixture
def app():
    return create_app(Settings(
        environment="test",
        project_name="test-app",
        database_url="sqlite+aiosqlite:///:memory:",
    ))


@pytest.fixture
def client(app):
    app.dependency_overrides[get_session] = fake_session
    with TestClient(app) as c:      # the with-block is what runs the lifespan
        yield c
    # No clear() needed: this app is discarded. It is here as a habit, not a requirement.
    app.dependency_overrides.clear()

Splitting app from client matters more than it looks. Tests that need to override something specific can depend on app, set their override, and then request client — with a shared fixture you would be reaching into the client to find the app.

What actually happens without the with-block

This is a real run of the two failure modes, driven with the genuine TestClient:

$ GET /without-with-block
200 OK
{
  "status_code": 500,
  "note": "lifespan never ran, so app.state.pool was never set"
}

$ GET /with-with-block
200 OK
{
  "status_code": 200,
  "body": {
    "pool": "pool<opened-by-lifespan>",
    "dsn": "postgresql://primary.internal:5432/app"
  }
}

The outer 200 is the harness reporting; the value that matters is status_code. A bare TestClient(app) produced a server error from a handler that merely read app.state.pool, because nothing ever set it. With the with block the same handler returns the pool the lifespan opened. If you have ever seen a test fail with an AttributeError on a state attribute you are certain gets assigned, this is the reason, and a grep for TestClient( not preceded by with will usually find it.

And the leak, side by side with the fix:

$ GET /shared-app-leaks-override
200 OK
{
  "test_one_dsn": "sqlite+aiosqlite:///:memory:",
  "test_two_dsn": "sqlite+aiosqlite:///:memory:",
  "leaked": true
}

$ GET /fresh-app-per-test
200 OK
{
  "test_one_dsn": "sqlite+aiosqlite:///:memory:",
  "test_two_dsn": "postgresql://primary.internal:5432/app",
  "leaked": false
}

In the first case the second "test" ran against a mock it never asked for, and passed — which is the worst possible outcome, because a leaked override usually makes tests greener, not redder. In the second, the second test saw the production dependency and would have failed loudly if it depended on the double. Broader technique for overrides is in overriding dependencies in tests.

Deployment

# --factory: the import target is a callable uvicorn invokes once per worker process.
CMD ["uvicorn", "app.main:create_app", "--factory", \
     "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

Gunicorn's uvicorn worker class accepts the same target through --factory on newer releases; if yours does not, a one-line asgi.py containing app = create_app() gives you a module-level object built from the factory, which keeps the construction logic in one place.

Because each worker calls create_app() itself, there is no fork-inherited state to worry about — no connection opened in the parent and shared across children, which is a classic source of "connection already closed" errors under Gunicorn. Each worker opens its pool in its own lifespan, in its own process.

Verification

The suite-level check is ordering. Run the tests in a randomised order and confirm the result is unchanged:

pytest -p randomly            # random order each run
pytest -p randomly -p no:cacheprovider --count 3

Order-dependent failures are the signature of shared state. With a per-test factory there is nothing left to share, so a persistent order dependency points at something genuinely global — a module-level cache, an lru_cache on a settings getter, or a singleton client someone imported directly.

For the deployment, assert wiring rather than liveness. A health endpoint that returns a constant proves the process is up and nothing else; one that touches the pool proves the lifespan ran:

@router.get("/health/ready")
async def ready(session: Annotated[AsyncSession, Depends(get_session)]) -> dict[str, str]:
    await session.execute(text("SELECT 1"))    # proves the lifespan-owned pool is live
    return {"status": "ready"}

Point the orchestrator's readiness probe at that and its liveness probe at a constant endpoint. Conflating the two makes a database blip restart otherwise healthy pods.

Trade-offs and When Not To Use This

Construction cost is real if the factory does too much. The pattern only stays cheap while create_app is pure assembly. The moment someone puts a schema reflection call or a remote config fetch in it, every test pays that cost and the fixture becomes the slowest thing in the suite. Push anything with latency into the lifespan.

A factory does not isolate module-level state. If a router module holds a global client, or a settings getter is memoised with lru_cache, two app instances still share it. The factory isolates what it constructs, not what your imports do — which is why the settings object is passed in rather than read inside.

Some tooling expects a module-level app. Static OpenAPI extraction scripts, some IDE integrations and a few third-party debuggers want an importable app. A thin asgi.py satisfies them without giving up the factory.

Per-test construction is not free at very large scale. A suite of many thousands of tests against an app with a large routing table will notice. Scope the fixture to the module before you consider sharing across the session, and keep clearing overrides if you do.

A single-instance script does not need this. If the application is one file that runs one way, app = FastAPI() is honest and the factory is ceremony. Adopt it when a second environment or a test suite appears — which, admittedly, is usually immediately.

FAQ

Why must TestClient be used as a context manager? Entering the context runs the application's lifespan startup and exiting runs shutdown. A bare TestClient(app) skips the lifespan entirely, so anything the lifespan puts on app.state is missing and the handler fails with an AttributeError that looks nothing like the real cause.

Why clear dependency_overrides after each test? Overrides are a plain dict living on the app object, so they last as long as that object does. If a fixture reuses one app, a leftover override leaks a mock into the next test, which then passes alone and fails in the suite. Building a fresh app per test removes the problem rather than managing it.

Should I share one app across the whole test session for speed? Prefer a fresh app per test; construction is pure Python object assembly with no I/O, so it is cheap. If profiling shows construction genuinely dominates a very large suite, scope the fixture to the module and still reset dependency_overrides between tests.

Does uvicorn --factory change how the application behaves? No. It changes when the app is constructed: the import target is a callable that uvicorn invokes once per worker process, instead of an object built at import time. The application object it returns is identical to one built any other way.

Can create_app do async work such as opening a pool? It should not. Keep the factory synchronous and free of I/O so it can be called from a fixture without an event loop, and put resource acquisition in the lifespan, which runs after construction and is torn down deterministically.

How do I test code that reads app.state directly? Expose the resource through a dependency that reads app.state once and returns a typed handle, then override that dependency. Reaching into request.app.state from a handler works but is untyped and cannot be replaced in a test without mutating the app object.