FastAPI · lesson 13 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6FastAPI Dependency Injection: Depends, yield and Overrides
Share logic with Depends, chain sub-dependencies, watch per-request caching, write yield dependencies that re-raise, and swap any of them with overrides.
By SkillByExample · Published
One Function, Many Routes
You’ve already used Depends three times: for a database session in CRUD with SQLModel, the current user in Authentication, and role checks in RBAC, CORS and Settings. This lesson looks at the mechanism itself: what FastAPI does with a dependency’s parameters, how often it calls each one, what happens to errors around yield, and how to replace a dependency without touching the route.
Outputs come from FastAPI 0.143.0, Starlette 1.7.0 and Pydantic 2.14.1 on Python 3.12. Each file runs its app through TestClient (pip install httpx2), so run it with python <file>.
A Dependency’s Parameters Are Request Parameters
Save as paging.py:
from typing import Annotated
from fastapi import Depends, FastAPI, Query
from fastapi.testclient import TestClient
def paging(skip: int = 0, limit: Annotated[int, Query(le=100)] = 10) -> dict:
return {"skip": skip, "limit": limit}
Paging = Annotated[dict, Depends(paging)]
app = FastAPI()
@app.get("/users")
def users(page: Paging):
return {"users": page}
@app.get("/orders")
def orders(page: Paging, status: str = "open"):
return {"orders": page, "status": status}
client = TestClient(app)
print(client.get("/users?limit=5").json())
print(client.get("/orders?skip=20&status=paid").json())
print(client.get("/orders?limit=500").json())
print([p["name"] for p in app.openapi()["paths"]["/orders"]["get"]["parameters"]])
$ python paging.py
{'users': {'skip': 0, 'limit': 5}}
{'orders': {'skip': 20, 'limit': 10}, 'status': 'paid'}
{'detail': [{'type': 'less_than_equal', 'loc': ['query', 'limit'], 'msg': 'Input should be less than or equal to 100', 'input': '500', 'ctx': {'le': 100}}]}
['status', 'skip', 'limit']
paging isn’t called by your code. FastAPI reads its signature and treats skip and limit as query parameters of every route that uses it, with the same conversion and validation as the route’s own parameters. That’s why limit=500 gets a normal 422 with loc: ['query', 'limit'], and why the OpenAPI schema for /orders lists skip and limit next to status. The Paging alias is the same Annotated shortcut as SessionDep in the SQLModel lesson. Change paging once, and every route that uses it changes with it.
Sub-dependencies and the Per-Request Cache
A dependency can depend on another. When two of them need the same thing, FastAPI calls it only once per request. Save as chain.py:
from itertools import count
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
connections = count(1)
def get_db() -> int:
conn = next(connections)
print(f" get_db -> connection {conn}")
return conn
def get_repo(db: Annotated[int, Depends(get_db)]) -> str:
return f"repo on conn {db}"
@app.get("/report")
def report(repo: Annotated[str, Depends(get_repo)], db: Annotated[int, Depends(get_db)],
fresh: Annotated[int, Depends(get_db, use_cache=False)]):
return [repo, f"route on conn {db}", f"fresh conn {fresh}"]
client = TestClient(app)
for _ in range(2):
print("request")
print(" ", client.get("/report").json())
$ python chain.py
request
get_db -> connection 1
get_db -> connection 2
['repo on conn 1', 'route on conn 1', 'fresh conn 2']
request
get_db -> connection 3
get_db -> connection 4
['repo on conn 3', 'route on conn 3', 'fresh conn 4']
get_db is needed three times per request, but it ran twice. get_repo and the route’s own db share connection 1, because FastAPI caches each dependency’s result for the rest of the request. That is what you want for a database session: the route and its helpers work inside the same transaction. use_cache=False opts out, so fresh got its own call. The cache doesn’t outlive the request, either: the second request started again at connection 3. For something that should be built once per process, such as settings, use lru_cache as the settings section did.
yield Dependencies Must Re-raise
A dependency with yield runs setup before the route and cleanup after it. The SQLModel lesson found that a plain line after yield is skipped when the route fails, and fixed it with finally. The next step is to react to the failure, for example by rolling back a transaction. Save as transaction.py:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
app = FastAPI()
def transaction():
print(" begin")
try:
yield "tx"
print(" commit")
except Exception as exc:
print(f" rollback ({type(exc).__name__})")
finally:
print(" release")
@app.post("/transfer/{amount}")
def transfer(amount: int, tx: Annotated[str, Depends(transaction)]):
if amount > 100:
raise HTTPException(400, "Over the limit")
return {"moved": amount}
client = TestClient(app, raise_server_exceptions=False)
for amount in ["50", "500", "abc"]:
print(f"POST /transfer/{amount}")
print(" ->", (r := client.post(f"/transfer/{amount}")).status_code, r.text)
$ python transaction.py
POST /transfer/50
begin
commit
release
-> 200 {"moved":50}
POST /transfer/500
begin
rollback (HTTPException)
release
-> 500 Internal Server Error
POST /transfer/abc
begin
rollback (RequestValidationError)
release
-> 500 Internal Server Error
The rollback ran, but the client was told the server crashed, for both a deliberate 400 and an ordinary 422. Two surprises are hiding here. First, the dependency sees HTTPException and even the RequestValidationError behind a 422: FastAPI throws the route’s exception into the generator at yield, and amount failed validation after transaction had already started. Second, except Exception caught that exception and didn’t raise it again, so the error response was never built. Without raise_server_exceptions=False, TestClient raises the real cause: FastAPIError: Response not awaited. There's a high chance that the application code is raising an exception and a dependency with yield has a block with a bare except, or a block with except Exception, and is not raising the exception again.
The fix is one line, raise, at the end of the except block:
except Exception as exc:
print(f" rollback ({type(exc).__name__})")
raise
$ python transaction.py
POST /transfer/50
begin
commit
release
-> 200 {"moved":50}
POST /transfer/500
begin
rollback (HTTPException)
release
-> 400 {"detail":"Over the limit"}
POST /transfer/abc
begin
rollback (RequestValidationError)
release
-> 422 {"detail":[{"type":"int_parsing","loc":["path","amount"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]}
Now the dependency cleans up and the exception carries on to FastAPI’s handlers, so the client gets the right status. The rule: in a yield dependency, except is for side effects such as rolling back or logging, and it ends with raise; cleanup that must always run goes in finally.
When Cleanup Runs: scope
TestClient waits for everything to finish, so it hides one detail: by default the code after yield runs after the response has been sent. FastAPI 0.143 lets a dependency choose. Save as scopes.py:
from typing import Annotated
from fastapi import Depends, FastAPI
def per_request():
print("request-scoped: start")
yield
print("request-scoped: end")
def per_function():
print("function-scoped: start")
yield
print("function-scoped: end")
app = FastAPI()
@app.get("/")
def root(a: Annotated[None, Depends(per_request)],
b: Annotated[None, Depends(per_function, scope="function")]):
print("route runs")
return "ok"
Run it with PYTHONUNBUFFERED=1 uvicorn scopes:app --port 8014 and call curl -s http://127.0.0.1:8014/. The server log:
request-scoped: start
function-scoped: start
route runs
function-scoped: end
INFO: 127.0.0.1:62996 - "GET / HTTP/1.1" 200 OK
request-scoped: end
scope="function" wraps only the route function, so its cleanup finished before Uvicorn logged the response. The default, request scope, finished after it. That suits closing a session, because the client isn’t kept waiting. But if the cleanup is a commit that can fail, a request-scoped dependency would already have sent “200 OK” by the time the commit failed. For that case use scope="function", or commit explicitly inside the route, as the SQLModel lesson does.
Replacing a Dependency: dependency_overrides
Because routes ask for dependencies instead of importing them, you can swap one without editing the route. app.dependency_overrides maps the original function to a replacement. Save as overrides.py:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
app = FastAPI()
def get_user() -> str:
raise HTTPException(401, "Not authenticated")
def get_payments_url() -> str:
return "https://payments.example.com"
@app.post("/checkout")
def checkout(user: Annotated[str, Depends(get_user)],
payments: Annotated[str, Depends(get_payments_url)]):
return {"user": user, "charging_via": payments}
client = TestClient(app)
print(client.post("/checkout").json())
app.dependency_overrides[get_user] = lambda: "test-user"
app.dependency_overrides[get_payments_url] = lambda: "http://fake-payments.test"
print(client.post("/checkout").json())
app.dependency_overrides.clear()
print(client.post("/checkout").json())
$ python overrides.py
{'detail': 'Not authenticated'}
{'user': 'test-user', 'charging_via': 'http://fake-payments.test'}
{'detail': 'Not authenticated'}
With the overrides in place, checkout received a logged-in user and a fake payments URL, and the real get_user never ran. The dict is global to the app, so clear() matters: forget it and later code, or the next test, keeps the fake. An override replaces the dependency everywhere it’s used, including inside other dependencies, so overriding get_db in chain.py would change what get_repo receives too. Testing with pytest uses this to point an app at a test database.
Practice
- Rewrite
pagingfrompaging.pyas a class with aslice(items)method, and use it with theDepends()shorthand.
Solution
Save as p1.py:
from typing import Annotated
from fastapi import Depends, FastAPI, Query
from fastapi.testclient import TestClient
class Paging:
def __init__(self, skip: int = 0, limit: Annotated[int, Query(le=100)] = 10):
self.skip, self.limit = skip, limit
def slice(self, items: list) -> list:
return items[self.skip : self.skip + self.limit]
app = FastAPI()
@app.get("/numbers")
def numbers(page: Annotated[Paging, Depends()]):
return page.slice(list(range(100)))
client = TestClient(app)
print(client.get("/numbers?skip=95").json())
print(client.get("/numbers?limit=3&skip=10").json())
$ python p1.py
[95, 96, 97, 98, 99]
[10, 11, 12]
A class is callable, so it can be a dependency: FastAPI reads the parameters of __init__ and passes in an instance. Depends() with no argument means “use the annotated type”, which saves writing Paging twice. The bonus is a real type, so your editor knows page.slice exists.
- In a test you want to skip the admin check in
roles.pyfrom RBAC, CORS and Settings. Tryapp.dependency_overrides[require_role("admin")] = lambda: "ada"and callDELETE /posts/1. Does it work? Fix it.
Solution
Save as p2.py next to roles.py:
from fastapi.testclient import TestClient
from roles import app, current_user, require_role
client = TestClient(app)
app.dependency_overrides[require_role("admin")] = lambda: "ada"
print("override require_role:", client.delete("/posts/1").status_code)
app.dependency_overrides.clear()
app.dependency_overrides[current_user] = lambda: "ada"
print("override current_user:", client.delete("/posts/1").status_code)
$ python p2.py
override require_role: 401
override current_user: 200
The override is silently ignored. dependency_overrides is keyed by the function object, and every call to require_role("admin") builds a new checker, so the key isn’t the function the route holds. Override the plain function the factory depends on. Here current_user returns "ada", a real admin in ROLES, so the role check still runs against her roles.
- Change
transaction.pyso the 400 for an over-limit transfer is not treated as a rollback reason (it’s a refused request, not a failure), while any other exception still rolls back.
Solution
Replace transaction with:
def transaction():
print(" begin")
try:
yield "tx"
print(" commit")
except HTTPException:
print(" nothing to roll back")
raise
except Exception as exc:
print(f" rollback ({type(exc).__name__})")
raise
finally:
print(" release")
$ python transaction.py
POST /transfer/50
begin
commit
release
-> 200 {"moved":50}
POST /transfer/500
begin
nothing to roll back
release
-> 400 {"detail":"Over the limit"}
POST /transfer/abc
begin
rollback (RequestValidationError)
release
-> 422 {"detail":[{"type":"int_parsing","loc":["path","amount"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]}
Each except clause still ends in raise, so the client keeps getting the right status code. Only the dependency’s own side effects differ.