Dependencies with yield
Open something before the endpoint and close it afterwards - with one yield. Build a SQLite session that commits on success and rolls back on errors, then test the edges: cleanup order, streaming, an exception that is swallowed, and a commit that fails after the client was already told "saved" - and the scope="function" fix.
What you will be able to do
- Write a dependency with yield for setup and cleanup
- Commit on success and roll back on errors
- Know the order of cleanup for several yield dependencies
- Understand when the cleanup runs compared with the response
- Use scope="function" when the cleanup can fail
- Avoid swallowing exceptions in a yield dependency
The idea, in plain English
Some things must be opened before the work and closed after it: a database connection, a file, a lock, a timer. If you forget to close, connections run out; if an error happens in the middle, half-finished changes must be undone.
A dependency with yield does both. The code before yield runs before the endpoint; the value after yield is what the endpoint gets; the code after yield runs when the endpoint is done. With try / except / finally around the yield you can commit on success, roll back on errors and always close.
We build a notes API on a real SQLite file and test it with good notes, refused notes and a bug - then look at the edges, including one that told a client "saved" when it was not. FastAPI 0.143.0, Python’s built-in sqlite3.
Worked example: A notes API on a real SQLite file: a good note is committed, a refused note and a bug are rolled back, and the connection is always closed.
1 - A good note
The endpoint returns normally, so the code after yield commits and closes.
The code before yield runs first, the endpoint in the middle, the cleanup last. Real results from our notes API.
Words you will see in this lesson
A few words about setup and cleanup.
yieldGives a value and pauses the function; it continues later from the same line.CommitMake the changes in the database permanent.RollbackUndo the changes made since the last commit.finallyA block that always runs - after success or after an error.CleanupThe work after yield: commit or roll back, close, release.ScopeHow long a dependency lives: until the function ends, or until the response is sent.An everyday example: a library reading room
In a reading room with rare books:
- The librarian brings the book to your table - setup.
- You read and take notes - the endpoint.
- If you finish well, your notes are added to the catalogue - commit.
- If something goes wrong (you spill tea), the changes are thrown away - rollback.
- Either way, the book goes back to the shelf - finally: close.
Example 1 - a database session with yield
Read get_db() from the top:
- sqlite3.connect opens the database file - before yield, so before the endpoint.
- yield conn hands the connection to the endpoint and pauses get_db.
- After the endpoint returns, get_db continues: conn.commit().
- If the endpoint raised, the exception appears at the yield line: except rolls back and raises it again, so FastAPI still answers 400 or 500.
- finally closes the connection in every case.
- add_note inserts the note FIRST and checks the words AFTER - so a refused note really was half written. That is what rollback is for.
import sqlite3
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
app = FastAPI()
with sqlite3.connect("notes.db") as setup:
setup.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT)")
def get_db():
conn = sqlite3.connect("notes.db")
print(" open")
try:
yield conn # the endpoint runs here
conn.commit() # no error: keep the changes
print(" commit")
except Exception as exc:
conn.rollback() # an error: undo the changes
print(f" rollback ({type(exc).__name__})")
raise # let FastAPI answer the error as usual
finally:
conn.close() # always, error or not
print(" close")
DB = Annotated[sqlite3.Connection, Depends(get_db)]
@app.post("/notes")
def add_note(text: str, db: DB):
db.execute("INSERT INTO notes (text) VALUES (?)", (text,))
if "bad" in text:
raise HTTPException(status_code=400, detail="No bad words") # after the insert!
if text == "crash":
return 1 / 0
return {"saved": text}
@app.get("/notes")
def list_notes(db: DB):
return [text for (text,) in db.execute("SELECT text FROM notes ORDER BY id")]from fastapi.testclient import TestClient
from notes_db import app
c = TestClient(app, raise_server_exceptions=False)
for label, text in [("good note", "buy milk"), ("bad word", "bad idea"), ("bug", "crash")]:
print(label)
r = c.post("/notes", params={"text": text})
print(f" -> {r.status_code} {r.text}")
print("list")
r = c.get("/notes")
print(f" -> {r.status_code} {r.text}")Reading the output
- good note - open, commit, close. 200.
- bad word - the INSERT ran, then HTTPException. The dependency saw it: "rollback (HTTPException)". 400.
- bug - ZeroDivisionError: rollback, close, 500.
- list - only "buy milk" is in the file. The two rolled-back notes are gone, as if never written.
- Every request printed close - nothing was left open.
good note
open
commit
close
-> 200 {"saved":"buy milk"}
bad word
open
rollback (HTTPException)
close
-> 400 {"detail":"No bad words"}
bug
open
rollback (ZeroDivisionError)
close
-> 500 Internal Server Error
list
open
commit
close
-> 200 ["buy milk"]Several yield dependencies: the order
edges.py tests the edges. In /order, inner depends on outer, and the endpoint on inner:
- Setup runs from the outside in: outer, then inner, then the endpoint.
- Cleanup runs the other way: inner first, then outer - like closing nested boxes.
- So a session opened first is closed last, after everything that used it.
import time
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import StreamingResponse
app = FastAPI()
def slow_cleanup():
yield "x"
print(f" cleanup starts {time.strftime('%X')}")
time.sleep(2) # slow cleanup
print(f" cleanup ends {time.strftime('%X')}")
@app.get("/slow")
def slow(x: Annotated[str, Depends(slow_cleanup)]):
return {"ok": True}
def swallow():
try:
yield "s"
except Exception:
print(" swallow: caught it, did NOT raise again") # the mistake
@app.get("/swallowed")
def swallowed(s: Annotated[str, Depends(swallow)]):
raise HTTPException(status_code=404, detail="Not here")
def outer():
print(" outer: setup")
yield
print(" outer: cleanup")
def inner(_: Annotated[None, Depends(outer)]):
print(" inner: setup")
yield
print(" inner: cleanup")
@app.get("/order")
def order(_: Annotated[None, Depends(inner)]):
print(" endpoint")
return {"ok": True}
def resource():
state = {"open": True}
print(" resource: open")
yield state
state["open"] = False
print(" resource: closed")
@app.get("/stream")
def stream(res: Annotated[dict, Depends(resource)]):
def lines():
for n in range(3):
yield f"line {n} - resource open: {res['open']}\n"
return StreamingResponse(lines(), media_type="text/plain")order
outer: setup
inner: setup
endpoint
inner: cleanup
outer: cleanup
-> 200 {"ok":true}
stream
resource: open
resource: closed
-> 200
line 0 - resource open: True
line 1 - resource open: True
line 2 - resource open: True
swallowed
swallow: caught it, did NOT raise again
-> 500 Internal Server ErrorStreaming, and the swallowed exception
Two more results in that output:
- stream - the generator used the resource while the body was sent, and all three lines said "resource open: True". By default the cleanup waits until the response is finished, so a stream can still use the session.
- swallowed - the endpoint raised 404, but the dependency caught it and did NOT raise it again. The client got 500, not 404.
- In tests TestClient showed why: "FastAPIError: Response not awaited. There's a high chance that ... a dependency with yield has a block with a bare except, or a block with except Exception, and is not raising the exception again."
- Rule: in a yield dependency, every except must end with raise.
When does the cleanup run?
We ran /slow with uvicorn. Its cleanup sleeps 2 seconds. The client got its answer at once, and the cleanup ran after it:
- By default the code after yield runs AFTER the response has been sent.
- Good for streams and for slow cleanup - the client does not wait.
- Dangerous for a commit: our get_db commits after the client was already told "saved".
client asks 22:19:44
{"ok":true} client got answer 22:19:44
uvicorn log:
INFO: 127.0.0.1:57811 - "GET /slow HTTP/1.1" 200 OK
cleanup starts 22:19:44
cleanup ends 22:19:46The commit that failed after "saved"
late.py makes it real: the code after yield raises RuntimeError("disk full"), like a commit that fails.
- The client got 200 {"saved":true}. The log got "Exception in ASGI application ... RuntimeError: disk full".
- The client believes the order is saved. It is not.
- The fix: Depends(failing_commit, scope="function"). Now the cleanup runs when the endpoint function ends, BEFORE the response is sent - and the client got 500.
- The price: with scope="function" a stream can no longer use the resource - our stream said "resource open: False" on every line.
- Choose per dependency: scope="function" for work that must succeed before you answer (a commit); the default for things a stream needs or that may take time.
from typing import Annotated
from fastapi import Depends, FastAPI
app = FastAPI()
def failing_commit():
yield
print(" cleanup: commit fails")
raise RuntimeError("disk full") # an error AFTER yield
@app.post("/orders")
def create_order(_: Annotated[None, Depends(failing_commit)]):
return {"saved": True}
# late_fixed.py - the same, with one change:
@app.post("/orders")
def create_order(_: Annotated[None, Depends(failing_commit, scope="function")]): # cleanup BEFORE the answer
return {"saved": True}{"saved":true} (status 200)
INFO: 127.0.0.1:57813 - "POST /orders HTTP/1.1" 200 OK
cleanup: commit fails
ERROR: Exception in ASGI application
raise RuntimeError("disk full") # an error AFTER yield
RuntimeError: disk fullInternal Server Error (status 500)
cleanup: commit fails
INFO: 127.0.0.1:57815 - "POST /orders HTTP/1.1" 500 Internal Server Error
raise RuntimeError("disk full")
RuntimeError: disk full resource: open
resource: closed
line 0 - resource open: False
line 1 - resource open: False
line 2 - resource open: FalseTip: For our get_db that commits, use Depends(get_db, scope="function") - or commit inside the endpoint, so an error there becomes a normal error response. Module 5 uses this with a real database.
Yield dependencies at a glance
Setup and cleanupBefore and after yield.
def get_db():
conn = connect()
try: yield conn
finally: conn.close()Commit / rollbackAlways raise again.
yield conn; conn.commit() except Exception: conn.rollback(); raise
OrderSetup outside-in, cleanup inside-out.
outer setup, inner setup, endpoint, inner cleanup, outer cleanup
Cleanup before the answerErrors reach the client.
Depends(get_db, scope="function")
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“After try_notes.py, open notes.db with the sqlite3 command (sqlite3 notes.db "SELECT * FROM notes"). What is inside?”
“Remove the raise from get_db’s except block and post "bad idea". What does the client get?”
“Write an async def version of slow_cleanup with asyncio.sleep. Does the timing change?”
“Write a yield dependency that prints how many milliseconds the endpoint took (time.perf_counter before and after yield).”
What usually goes wrong
The client got 500 instead of 404, and FastAPI raised "Response not awaited".
✗ except Exception:
conn.rollback()✓ except Exception:
conn.rollback()
raiseIf close() is only after yield, an error skips it and the connection stays open.
With the default scope the client hears "saved" before the commit. Use scope="function".
The resource is closed before the stream is sent - every line said "open: False".
Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Add DELETE /notes/{note_id} using the same get_db with scope="function". If no row was deleted (cursor.rowcount == 0), raise 404 - and check that nothing else changed. Then make the commit fail on purpose (raise after commit for the text "fail") and show that the client gets 500, not 200.
Show hintHide hint
cur = db.execute("DELETE FROM notes WHERE id = ?", (note_id,)); cur.rowcount tells you how many rows.
Key points
- The code before yield runs before the endpoint; the code after it runs afterwards.
- Wrap the yield in try / except / finally: commit, roll back, always close.
- Every except in a yield dependency must raise again.
- Setup runs outside-in, cleanup inside-out.
- By default cleanup runs after the response is sent - fine for streams, risky for commits.
- scope="function" runs the cleanup before the response, so its errors reach the client.
Quick check before you move on
Interview questions
How do you manage a per-request database session in FastAPI?
A yield dependency that opens the session, yields it, commits on success, rolls back and re-raises on exceptions, and closes in finally - with scope="function" (or an explicit commit in the endpoint) so commit failures produce an error response.
When does the exit code of a yield dependency run?
With the default request scope, after the response is sent (so streaming can use the resource); with scope="function", right after the path operation returns and before the response is sent. Exit code runs in reverse order of setup.
Quiz
- 1.
How does the dependency know the endpoint raised an error?
- 2.
Why could the stream still use the resource by default?
- 3.
What is the trade-off of scope="function"?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...