← Back to FastAPI
Lesson 4.3 · Dependencies

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.

Intermediate35 min

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.

workflowAround the endpointstep 1 / 3

1 - A good note

The endpoint returns normally, so the code after yield commits and closes.

log
open, commit, close
answer
200 {"saved":"buy milk"}

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.

Small dictionary
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.
Example 1 - notes_db.py
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")]
try_notes.py
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.
Output
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.
edges.py
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")
Output
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 Error

Streaming, 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".
Output - curl, and the uvicorn log
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:46

The 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.
late.py and the fix
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}
Output - default scope (curl and uvicorn log)
{"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 full
Output - scope="function"
Internal 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
Output - a stream with scope="function"
resource: open resource: closed line 0 - resource open: False line 1 - resource open: False line 2 - resource open: False

Tip: 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 cleanup

Before and after yield.

def get_db():
    conn = connect()
    try: yield conn
    finally: conn.close()
Commit / rollback

Always raise again.

yield conn; conn.commit()
except Exception: conn.rollback(); raise
Order

Setup outside-in, cleanup inside-out.

outer setup, inner setup, endpoint, inner cleanup, outer cleanup
Cleanup before the answer

Errors 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.

Look in the file

“After try_notes.py, open notes.db with the sqlite3 command (sqlite3 notes.db "SELECT * FROM notes"). What is inside?”

Forget the raise

“Remove the raise from get_db’s except block and post "bad idea". What does the client get?”

Async

“Write an async def version of slow_cleanup with asyncio.sleep. Does the timing change?”

A timer

“Write a yield dependency that prints how many milliseconds the endpoint took (time.perf_counter before and after yield).”

What usually goes wrong

Catching and not raising again

The client got 500 instead of 404, and FastAPI raised "Response not awaited".

✗ except Exception:
    conn.rollback()
✓ except Exception:
    conn.rollback()
    raise
Closing without finally

If close() is only after yield, an error skips it and the connection stays open.

Committing after the response

With the default scope the client hears "saved" before the commit. Use scope="function".

Using scope="function" for a stream

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.

1.

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 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

Which notes were in the file after the three POSTs?
Only "buy milk" - the other two were rolled back.
In which order did the cleanup of outer and inner run?
inner first, then outer.
What did the client get when the cleanup raised "disk full" with the default scope?
200 {"saved":true} - the error was only in the log.
What did the swallowed 404 become?
500 Internal Server Error.

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. 1.

    How does the dependency know the endpoint raised an error?

  2. 2.

    Why could the stream still use the resource by default?

  3. 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...