FastAPI Dependency Overrides — Deep Dive

How dependency resolution actually works

FastAPI’s dependency system is built on top of Starlette and uses a resolver that walks the dependency graph for each request. When a route declares Depends(get_db), FastAPI:

  1. Checks app.dependency_overrides for get_db as a key
  2. If found, calls the override function instead
  3. If not found, calls get_db directly
  4. Caches the result for that request (dependencies are resolved once per request by default)

The lookup uses Python’s identity comparison (is), not equality. This is why you must reference the exact function object — not a re-import or a wrapper.

# routes.py
from app.deps import get_db

@app.get("/items")
async def list_items(db: Session = Depends(get_db)):
    return db.query(Item).all()

# test_routes.py
from app.deps import get_db  # Same import path = same object

def get_test_db():
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()

app.dependency_overrides[get_db] = get_test_db

If your test imports get_db from a different path (e.g., from app.routes import get_db after it was re-exported), you may end up with a different object, and the override silently does nothing.

Async override patterns

FastAPI handles both sync and async dependencies. Overrides must match the async nature of the original:

# Original async dependency
async def get_redis():
    r = await aioredis.from_url("redis://localhost")
    yield r
    await r.close()

# Override must also be async
async def get_mock_redis():
    yield FakeRedis()

app.dependency_overrides[get_redis] = get_mock_redis

Mixing sync originals with async overrides (or vice versa) causes TypeError at runtime. FastAPI detects whether a dependency is a coroutine and dispatches accordingly — the override must match.

Generator vs return overrides

Dependencies that use yield (generator dependencies) have setup and teardown phases. Overrides can also use yield:

# Production: connection pool with teardown
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

# Test: in-memory DB with rollback
def get_test_db():
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    try:
        yield session
    finally:
        transaction.rollback()
        connection.close()

The teardown after yield runs after the response is sent, just like the original. This makes overrides suitable for transactional test patterns where you roll back every test’s changes.

Factory and parameterized overrides

Sometimes you need the override to behave differently per test. Use a factory:

def make_user_override(role: str = "admin", user_id: int = 1):
    async def override():
        return User(id=user_id, role=role, email=f"test-{user_id}@example.com")
    return override

# In test
app.dependency_overrides[get_current_user] = make_user_override(role="viewer")

This avoids polluting a shared test user across tests that need different roles or permissions.

Nested dependency overrides — targeting the right layer

Consider a dependency chain: get_current_userget_tokenget_settings. You have three override points:

Override targetEffect
get_settingsget_token and get_current_user still run, but receive mock settings
get_tokenget_current_user still runs, but receives a fabricated token; get_settings is never called
get_current_userNothing in the chain runs; you provide the final user directly

Choosing the right level depends on what you’re testing. Override get_current_user to test route logic in isolation. Override get_token to test your user-resolution logic with a known token. Override get_settings to test the full chain under different configuration.

Test architecture with overrides

A well-structured test setup for a FastAPI project:

# conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.deps import get_db

SQLALCHEMY_TEST_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_TEST_URL, connect_args={"check_same_thread": False})
TestSession = sessionmaker(bind=engine)

@pytest.fixture(autouse=True)
def db_override():
    Base.metadata.create_all(bind=engine)
    def override():
        db = TestSession()
        try:
            yield db
        finally:
            db.close()
    app.dependency_overrides[get_db] = override
    yield
    app.dependency_overrides.clear()
    Base.metadata.drop_all(bind=engine)

@pytest.fixture
def client():
    from fastapi.testclient import TestClient
    return TestClient(app)

Key architectural decisions:

  • autouse=True for database overrides ensures every test gets isolation without explicit fixture requests
  • clear() in teardown prevents cross-test contamination
  • Schema create/drop per test gives complete isolation at the cost of speed; for large suites, use transaction rollback instead

Overriding class-based dependencies

FastAPI supports class-based dependencies via __call__. Overriding them requires creating an instance of the override class:

class RateLimiter:
    def __init__(self, calls_per_minute: int = 60):
        self.limit = calls_per_minute

    async def __call__(self, request: Request):
        # Check rate limit against Redis
        ...

class NoOpRateLimiter:
    async def __call__(self, request: Request):
        return None  # Always allow

rate_limiter = RateLimiter()
app.dependency_overrides[rate_limiter] = NoOpRateLimiter()

Note that the key is the instance, not the class. This is because Depends(rate_limiter) references the instance.

Performance considerations

Dependency overrides have negligible runtime cost — it’s a dictionary lookup per dependency per request. However, test suite architecture choices matter:

  • In-memory SQLite is fast but doesn’t support PostgreSQL-specific features (JSON operators, array types, ON CONFLICT)
  • Transaction rollback (start transaction → test → rollback) is faster than create/drop schema but requires careful handling of nested transactions
  • Testcontainers (spinning up a real PostgreSQL in Docker) gives full fidelity but adds seconds per suite

The right choice depends on your fidelity needs. Most teams use transaction rollback for unit tests and Testcontainers for integration tests.

Debugging override failures

When an override doesn’t seem to take effect:

  1. Verify identity: Add print(id(get_db)) in both your route module and test module. If the IDs differ, you have an import aliasing problem.
  2. Check timing: Overrides must be set before the TestClient makes the request. If you set them after constructing the client, the client may have already resolved the app.
  3. Inspect the dict: Print app.dependency_overrides right before the request to confirm your function is in there.
  4. Sub-application mounts: If your route lives in a sub-application (mounted via app.mount), you need to override on that sub-app’s instance, not the parent.

Real-world example: multi-tenant test isolation

A SaaS application with tenant-scoped data access:

# Production dependency
async def get_tenant(request: Request, db: Session = Depends(get_db)):
    tenant_id = request.headers.get("X-Tenant-ID")
    tenant = db.query(Tenant).filter_by(id=tenant_id).first()
    if not tenant:
        raise HTTPException(status_code=404)
    return tenant

# Test override factory
def make_tenant_override(tenant_name: str = "Acme Corp"):
    async def override():
        return Tenant(id=1, name=tenant_name, plan="enterprise")
    return override

# Test: verify billing endpoint respects tenant plan
def test_billing_shows_enterprise_features(client):
    app.dependency_overrides[get_tenant] = make_tenant_override("Acme Corp")
    response = client.get("/billing/features")
    assert "priority-support" in response.json()["features"]

This pattern scales to testing any per-tenant behavior without setting up real tenant records in the database.

The one thing to remember: Dependency overrides work by function identity in a simple dictionary — understand the resolution chain, match async signatures, clean up after tests, and you’ll have a test suite that’s fast, isolated, and resistant to refactoring.

pythonwebapistesting

See Also

  • Python Aiohttp Client Understand Aiohttp Client through a practical analogy so your Python decisions become faster and clearer.
  • Python Api Client Design Why building your own API client in Python is like creating a TV remote that only has the buttons you actually need.
  • Python Api Documentation Swagger Swagger turns your Python API into an interactive playground where anyone can click buttons to try it out — no coding required.
  • Python Api Mocking Responses Why testing with fake API responses is like rehearsing a play with stand-ins before the real actors show up.
  • Python Api Pagination Clients Why APIs send data in pages, and how Python handles it — like reading a book one chapter at a time instead of swallowing the whole thing.