Application Factory Patterns in FastAPI
An application factory is a create_app() function that constructs and returns a fully wired FastAPI instance, replacing the module-level app = FastAPI() that welds your application to import time.
This guide covers the construction half of the architecture described in Core Architecture and Routing Patterns: the factory is where configuration, routers, middleware and the lifespan are assembled into one object. It pairs directly with Configuration Management, which supplies the typed settings the factory consumes, and with Modular Router Organization, which supplies the routers it mounts. Two guides sit beneath it: FastAPI App Factory Pattern for Testing and Deployment walks through the fixtures and the deployment command, and Lifespan Events vs Startup and Shutdown covers the context manager the factory attaches — what it guarantees about ordering, and why @app.on_event is no longer the way to write it.
If you adopt one structural pattern from this area, make it this one. Almost every other pattern in the codebase composes through the factory.
Prerequisites
- FastAPI on a recent release, so the
lifespanargument is available and the deprecatedon_eventdecorators are not needed. - A typed settings object. Pydantic Settings is the usual choice; see managing environment variables with pydantic-settings.
- Routers defined as
APIRouterinstances in their own modules rather than decorated directly onto a global app. pytestandTestClientif you intend to collect the testing benefit, which is most of the point.
Why a Module-Level Instance Breaks Down
app = FastAPI() at module scope is created exactly once, at import, and shared by everything that imports it. That is fine for a single script. It stops being fine the moment a second consumer appears.
The second consumer is almost always a test suite. Every test module that imports a router transitively imports the app, so all of them share one object — one dependency_overrides dict, one app.state, one routing table. A mock installed in one test file is present in the next. State written by whichever test ran first is read by whichever ran second. The suite develops an order dependency that nobody wrote deliberately, and the symptom is a test that passes alone and fails in CI.
The problem also shows up outside tests. A management command that imports your routers to introspect them triggers whatever the app's import does. A hot-reload cycle re-imports and re-registers routes. A second environment forces if ENV == "production" branches into module bodies, where they execute at import in a context you cannot control from the caller.
None of this is fixed by better hygiene, because the sharing is structural. The factory replaces shared construction with explicit construction, and the sharing simply stops existing.
Core Mechanics: What the Factory Owns
The factory has exactly three responsibilities, and keeping them separate is what makes the pattern robust.
Accept configuration rather than discover it. The signature takes a settings object with a default, so production can call create_app() and a test can call create_app(Settings(environment="test", ...)). A factory that reads os.environ internally cannot be handed test configuration without mutating the process environment, which is the same global state you were trying to escape.
Construct the instance and attach immutable state. Create the FastAPI object, store the settings on app.state, register the lifespan. app.state is a plain namespace, so treat it as a place for app-lifetime handles only — the settings, later the pool. Anything per-request belongs in a dependency, as covered in Dependency Injection Strategies.
Mount routers and middleware synchronously. All of it, before the function returns. This is what makes the routing table and the OpenAPI document a deterministic function of the settings rather than of timing.
Resource acquisition deliberately does not live in the factory. It lives in the lifespan, which runs after construction, and which has its own ordering guarantees documented in Lifespan Events vs Startup and Shutdown. That separation is what keeps create_app synchronous, callable without an event loop, and cheap enough to invoke once per test.
# 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:
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", tags=["observability"])
app.include_router(api_router, prefix="/v1", tags=["public"])
if cfg.environment in ("development", "staging", "test"):
app.include_router(admin_router, prefix="/admin", tags=["internal"])
return app
Production Implementation
The instance is a function of its settings
The gate on cfg.environment above is not cosmetic. A router that was never included is not in the routing table, so it is not in the generated OpenAPI document and there is no handler to reach even if someone guesses the path. That is a stronger property than hiding a route behind an authorisation check.
The following transcript is a real run of a factory of exactly that shape, asked to build a production instance and a staging instance and then to produce each one's OpenAPI document:
$ GET /schema-per-environment
200 OK
{
"production": {
"title": "production-app",
"paths": [
"/v1/items"
],
"docs_url": null
},
"staging": {
"title": "staging-app",
"paths": [
"/admin/stats",
"/v1/items"
],
"docs_url": "/docs"
}
}
Same code, same routers, two different published API surfaces. The production document does not mention /admin/stats because the production instance has no such route, and docs_url is null because the interactive documentation was never mounted.
A related piece of folklore is worth correcting with a run rather than repeating. It is often said that a router included after the schema has been generated will be missing from it, because app.openapi() caches its result. On FastAPI 0.139.2 that is not what happens:
$ GET /late-include-and-the-schema
200 OK
{
"paths_before_late_include": [
"/v1/items"
],
"paths_after_late_include": [
"/late/oops",
"/v1/items"
]
}
The schema picked the late router up. So the argument for mounting everything inside create_app is not that the schema will otherwise be wrong — it is determinism. A routing table assembled at construction is a pure function of the settings; one assembled partly during startup depends on how far the lifespan got before something raised, and that is a much harder thing to reason about when a deploy half-fails.
Configuration fails at construction, not at request time
Because the settings object is validated when it is built, a bad environment variable stops the process before it serves anything:
$ GET /bad-settings-fail-at-construction
200 OK
{
"errors": [
{
"loc": [
"project_name"
],
"type": "string_too_short",
"msg": "String should have at least 1 character"
},
{
"loc": [
"environment"
],
"type": "string_pattern_mismatch",
"msg": "String should match pattern '^(development|staging|production|test)$'"
}
]
}
Two problems reported together, each naming the field and the rule it broke. Compare that with the alternative — reading os.environ["ENVIRONMENT"] inside a handler and discovering the typo when a request arrives, in a code path that may not be exercised for hours. Validating at construction turns a latent runtime bug into a failed deploy, which is where you want it.
Instances do not leak into each other
This is the property the whole pattern exists to provide, and it is worth seeing rather than assuming. Two applications built from the same factory with identical settings, one of them given a dependency override:
$ 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
}
No teardown ran between them. The sibling resolved the real dependency because dependency_overrides is an ordinary dict attached to one app object, and the sibling has its own. Isolation here is a property of the object graph, not of a fixture remembering to clean up.
Serving the factory
Uvicorn and Gunicorn both understand factories. The --factory flag tells the server the import target is a callable to invoke, once per worker, rather than an already-constructed app:
# Each worker process calls create_app() exactly once — no import-time side effects.
CMD ["uvicorn", "app.main:create_app", "--factory", \
"--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
This matters more than it appears under a pre-forking server. If the app object were built at import, it would be built in the parent process and inherited by every fork — including anything it had opened. Sockets and connections do not survive a fork intact, and the resulting errors ("connection already closed", "server closed the connection unexpectedly") appear under load and vanish in development. With --factory, each worker constructs its own instance and opens its own resources in its own lifespan, and the failure mode disappears.
Async and Performance Notes
The factory is synchronous by design, and it should stay that way. A synchronous, I/O-free create_app can be called from a pytest fixture without an event loop, from a script, from a schema-export command — anywhere. The moment someone adds an await to it, every caller needs a loop and the fixture gains a layer of asynchrony it did not need.
That constraint also protects startup latency. Every deployment platform has an opinion about how long a container may take to become ready, and work placed in the factory or early in the lifespan is work that happens before the first health check succeeds. Blocking I/O is the worst version of this: a synchronous migration or a slow DNS lookup in the startup path blocks the event loop the ASGI server is running on, so the process is neither ready nor responsive. If unavoidable, push it through asyncio.to_thread() — the mechanics are in running sync code in a threadpool.
On serverless platforms the calculus shifts again. Eager pool creation at startup is a cold-start cost paid on every scale-out event, and a per-container pool multiplied by an autoscaler's idea of capacity can exhaust the database's connection limit without any single container looking unreasonable. Keep the factory as it is, and make the lifespan's acquisition lazy or route it through an external pooler such as PgBouncer or RDS Proxy. The connection-budget failure mode is covered in fixing asyncpg pool exhaustion.
Testing Strategy
The factory's return on investment is almost entirely in the test suite. The shape is small:
# 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(
project_name="test-app",
environment="test",
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
Two rules carry most of the weight.
Construct TestClient inside a with block. Entering the context is what drives the ASGI lifespan protocol; a bare TestClient(app) never starts it, so nothing the lifespan sets on app.state exists and the handler fails on an attribute it has every right to expect. This is the single most common factory-related test failure, and it is demonstrated with real output in FastAPI App Factory Pattern for Testing and Deployment.
Prefer a fresh app to a cleaned one. Clearing dependency_overrides in teardown works, right up until a test raises before its teardown line. Building a new app per test makes the question moot. Which dependencies to replace, and how to override a dependency that yields, is covered in overriding dependencies in tests and yield dependencies and cleanup order.
For tests that are themselves async, TestClient is the wrong tool — it manages its own loop and will not compose with an already-running one. Drive the app with httpx.AsyncClient over ASGITransport instead, as described in TestClient vs httpx AsyncClient and testing async endpoints with pytest-asyncio.
The suite-level verification is randomised ordering. If results change when the order changes, something is still shared, and with a per-test factory that something is module-level: a memoised settings getter, a client created at import, a cache keyed on nothing.
Failure Modes and Diagnosis
AttributeError on app.state.<resource> in a handler. The lifespan did not run. In tests this means a TestClient outside a with block; in production it means the server was pointed at an object rather than driven through the lifespan protocol, or a mounted sub-application is being asked for a resource its parent owns. Sub-application lifespans are the subtle case, covered in APIRouter prefix vs sub-application mounting.
A test passes alone and fails in the suite. A leaked dependency override or module-level state. Reproduce with the two test ids in isolation, then check whether the app object is shared and whether anything is memoised at import.
A test passes in the suite and fails alone. The mirror image, and more alarming: the test was relying on state another test created. Almost always a fixture that mutates a module-level object rather than an app-level one.
"Connection already closed" only under Gunicorn. A resource opened at import and inherited across a fork. Move it into the lifespan and serve with --factory.
Duplicate route or duplicate operation-id warnings on reload. A router being included more than once, typically because a module includes it at import as well as the factory including it explicitly. Routers should be defined at import and included only by create_app.
Deploy succeeds, readiness never goes green. Slow or blocking work in the startup path. Check whether anything before the yield does synchronous I/O, and split liveness from readiness so a slow dependency does not trigger a restart loop.
Config change has no effect. A settings getter wrapped in lru_cache that was populated by an earlier import. Pass settings into the factory rather than fetching them inside it, and clear the cache explicitly in fixtures that need to vary configuration.
Choosing How Much Factory You Need
Module-level app | create_app() factory | Factory plus per-environment settings | |
|---|---|---|---|
| Test isolation | Shared object, manual cleanup | Fresh instance per test | Fresh instance, per-test config |
| Overrides | Global for the process | Scoped to the instance | Scoped to the instance |
| Environment differences | if branches at import | Passed in as settings | Validated at construction |
| OpenAPI surface | One, fixed | Varies with the settings | Varies, and verified per environment |
| Serving | uvicorn app.main:app | uvicorn app.main:create_app --factory | Same, plus config validation on boot |
| Fork safety | Risk of inherited resources | Per-worker construction | Per-worker construction |
| Cost | Nothing | One function | One function plus a settings model |
| Right for | A single script | Anything with a test suite | Anything with more than one environment |
The middle column is rarely the resting place. Once you have a factory, passing settings into it is a small step and it eliminates the last category of import-time branching.
FAQ
Why use an application factory instead of a module-level FastAPI instance? A factory returns a fresh, isolated object graph per call, which is what makes a test suite deterministic and lets each environment load its own configuration. A module-level instance couples construction to import, so every consumer inherits the same object and any state attached to it.
Does the factory pattern slow down startup?
Not meaningfully, provided the factory only assembles objects. Construction runs once per worker process and the routing assembly is pure Python with no I/O. The cost only becomes visible if someone puts a network call or a schema reflection inside create_app, which belongs in the lifespan instead.
How do I run a factory with Uvicorn or Gunicorn?
Point the server at the callable and pass --factory, for example uvicorn app.main:create_app --factory. Each worker process invokes create_app once, so module import has no side effects and no resource is inherited across a fork.
Where do connection pools belong in a factory?
In the lifespan context manager, not in create_app. The factory builds the object graph; the lifespan acquires and releases the long-lived resources. That keeps construction synchronous and side-effect-free while resource ownership stays explicit and ordered.
Should the factory read environment variables itself?
No. Accept a settings object and let the caller decide where it came from. A factory that reads os.environ internally cannot be handed test configuration without mutating the process environment, which reintroduces exactly the global state the pattern removes.
Can two applications from the same factory run at once? Yes, and it is routine in test suites. Each instance owns its own state, its own dependency overrides and its own lifespan, so two instances can be alive simultaneously with different database URLs and neither can observe the other.
What belongs on app.state versus in a dependency?app.state holds immutable, app-lifetime handles such as the settings object and a connection pool. Per-request objects — a session, a transaction, the current user — belong in dependencies, because anything mutable placed on app.state is shared by every concurrent request.
Related Reading
- Up to the area: Core Architecture and Routing Patterns.
- The hands-on guide: FastAPI App Factory Pattern for Testing and Deployment — fixtures,
--factory, and the override leak shown with real output. - What the factory attaches: Lifespan Events vs Startup and Shutdown — ordering guarantees, teardown in reverse, and migrating off
@app.on_event. - What it consumes: Configuration Management and Modular Router Organization.
- What it makes testable: Dependency Injection Strategies and Overriding Dependencies in Tests.