Streaming and File Responses in FastAPI

Key takeaways:

  • Returning any Response subclass short-circuits FastAPI's serialization stage entirely — response_model is documentation only.
  • Status and headers leave before the first byte of the body, so a mid-stream failure cannot become a 500.
  • A BackgroundTask attached to a streaming response runs after the generator is exhausted, making it a safe place to clean up.
  • Resources a generator needs should be acquired inside the generator, not injected by a yield dependency.
  • FileResponse streams from disk in chunks and handles conditional-request headers for you.

Most FastAPI endpoints build a Python object and let the framework turn it into bytes. Streaming inverts that: you produce the bytes yourself, incrementally, and the framework gets out of the way. Getting out of the way is more literal than people expect — the entire validation and encoding pipeline described in Response Model and Serialization Order is skipped. This page covers what that means in practice, and it sits under The FastAPI Request/Response Lifecycle.

Buffered response versus streaming response on the wire Two horizontal timelines. The upper one shows a buffered JSON response where validation and encoding complete before a single response start and body message. The lower one shows a streaming response where response start is sent first, followed by several chunks produced by the generator, then the generator finally block and the background task. Buffered JSON response validate + encode response.start one body msg Streaming response response.start chunk 1 chunk 2 chunk 3 finally block background task Status and headers are committed at response.start. After that point no failure in the generator can change the status code. No validation stage exists on the streaming path at all.

The scenario

You have an export endpoint that assembles a 400 MB CSV. It works, until traffic doubles and the pods start getting OOM-killed, because the whole payload lives in memory twice — once as a Python object, once as encoded bytes. Streaming is the fix, but the moment you switch, three things change that nobody warns you about: your response_model stops doing anything, your error handling stops working, and your database session may or may not still be open when the generator runs.

Why streaming bypasses response_model

FastAPI's route handler wraps your function and inspects what came back. The check is essentially "is this already a Response?" If yes, it is used as-is. If no, it goes through validation, filtering and encoding.

StreamingResponse, FileResponse, JSONResponse, PlainTextResponse and RedirectResponse are all Response subclasses, so all of them take the bypass. This is not a special case for streaming — it is the general rule, and streaming is simply where it matters most, because a stream has no single object to validate.

The consequence people miss is that response_model does not vanish from your API when it stops being enforced. It still generates the OpenAPI schema. Your documentation, your generated clients and your contract tests all continue to believe the endpoint returns list[Row], and nothing checks that it does. The schema becomes a promise you keep by hand.

The demonstration

The app below covers streaming, files, background cleanup, mid-stream failure and the yield-dependency question in one place. Because the interesting events happen after the response body has been sent, it records them to a module-level list that is read back at /events.

"""StreamingResponse and FileResponse: chunk ordering, response_model bypass, background cleanup."""
import tempfile
from collections.abc import AsyncIterator
from pathlib import Path

from fastapi import Depends, FastAPI
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
from starlette.background import BackgroundTask

EVENTS: list[str] = []

TMP = Path(tempfile.mkdtemp(prefix="arch-streaming-"))
REPORT = TMP / "report.csv"
REPORT.write_text("id,name,tier\n1,Ada,pro\n2,Grace,free\n")

app = FastAPI()


class Row(BaseModel):
    id: int
    name: str


async def rows() -> AsyncIterator[bytes]:
    EVENTS.append("generator: started")
    try:
        for i in range(1, 4):
            EVENTS.append(f"generator: yielding chunk {i}")
            yield f"chunk-{i}\n".encode()
    finally:
        EVENTS.append("generator: finally block ran")


def cleanup() -> None:
    EVENTS.append("background task: ran")


@app.get("/stream")
async def stream() -> StreamingResponse:
    EVENTS.clear()
    EVENTS.append("path operation: returning StreamingResponse")
    return StreamingResponse(rows(), media_type="text/plain", background=BackgroundTask(cleanup))


@app.get("/stream-typed", response_model=list[Row])
async def stream_typed():
    """response_model is declared, but a Response instance is returned as-is: no validation."""
    async def gen() -> AsyncIterator[bytes]:
        yield b'{"totally": "not a list of Row"}'

    return StreamingResponse(gen(), media_type="application/json")


@app.get("/stream-error")
async def stream_error() -> StreamingResponse:
    async def gen() -> AsyncIterator[bytes]:
        yield b"partial-data\n"
        try:
            raise RuntimeError("upstream died mid-stream")
        except RuntimeError as exc:
            # Headers are already on the wire; the only honest option is an in-band marker.
            yield f"ERROR: {exc}\n".encode()

    return StreamingResponse(gen(), media_type="text/plain")


@app.get("/download")
async def download() -> FileResponse:
    return FileResponse(REPORT, media_type="text/csv", filename="report.csv")


@app.get("/download-temp")
async def download_temp() -> FileResponse:
    copy = TMP / "scratch.csv"
    copy.write_text(REPORT.read_text())

    def remove() -> None:
        copy.unlink(missing_ok=True)
        EVENTS.append(f"background task: deleted {copy.name}, exists={copy.exists()}")

    return FileResponse(copy, media_type="text/csv", background=BackgroundTask(remove))


class FakeSession:
    def __init__(self) -> None:
        self.closed = False

    async def fetch(self, n: int) -> str:
        return f"row-{n} (session closed={self.closed})"


async def get_session() -> AsyncIterator[FakeSession]:
    EVENTS.clear()
    session = FakeSession()
    EVENTS.append("dependency: session opened")
    try:
        yield session
    finally:
        session.closed = True
        EVENTS.append("dependency: session closed")


@app.get("/stream-with-dep")
async def stream_with_dep(session: FakeSession = Depends(get_session)) -> StreamingResponse:
    EVENTS.append("path operation: returning StreamingResponse that uses the session")

    async def gen() -> AsyncIterator[bytes]:
        for n in range(1, 3):
            row = await session.fetch(n)
            EVENTS.append(f"generator: {row}")
            yield f"{row}\n".encode()

    return StreamingResponse(gen(), media_type="text/plain")


@app.get("/events")
async def events() -> dict[str, list[str]]:
    return {"events": list(EVENTS)}

Real output from _verify/examples/arch-streaming-responses.py:

$ GET /stream
200 OK
chunk-1
chunk-2
chunk-3


$ GET /events
200 OK
{
  "events": [
    "path operation: returning StreamingResponse",
    "generator: started",
    "generator: yielding chunk 1",
    "generator: yielding chunk 2",
    "generator: yielding chunk 3",
    "generator: finally block ran",
    "background task: ran"
  ]
}

$ GET /stream-with-dep
200 OK
row-1 (session closed=False)
row-2 (session closed=False)


$ GET /events
200 OK
{
  "events": [
    "dependency: session opened",
    "path operation: returning StreamingResponse that uses the session",
    "generator: row-1 (session closed=False)",
    "generator: row-2 (session closed=False)",
    "dependency: session closed"
  ]
}

$ GET /stream-typed
200 OK
{
  "totally": "not a list of Row"
}

$ GET /stream-error
200 OK
partial-data
ERROR: upstream died mid-stream


$ GET /download
200 OK
id,name,tier
1,Ada,pro
2,Grace,free


$ GET /download-temp
200 OK
id,name,tier
1,Ada,pro
2,Grace,free


$ GET /events
200 OK
{
  "events": [
    "dependency: session opened",
    "path operation: returning StreamingResponse that uses the session",
    "generator: row-1 (session closed=False)",
    "generator: row-2 (session closed=False)",
    "dependency: session closed",
    "background task: deleted scratch.csv, exists=False"
  ]
}

The generator does not run inside your handler

Look at the first /events list. path operation: returning StreamingResponse is logged before generator: started. Constructing a StreamingResponse does not consume the iterator; it stores it. The generator body executes later, while Starlette is pumping the ASGI send channel.

This is the mechanical reason for most streaming bugs. Anything scoped to the handler's execution — an open transaction, a try/except around the return, a context variable set locally — has a lifetime that does not obviously extend over the generator. Whatever the generator needs must either be captured in its closure and still be alive, or acquired by the generator itself.

Background tasks are the right cleanup hook

The /stream trace shows the ordering clearly: the generator's finally block ran, and then the background task ran. Both happened after the third chunk. That makes BackgroundTask a genuinely safe place to clean up resources the stream was consuming — the stream is definitively finished by then.

/download-temp puts this to work on the pattern everyone eventually needs: serve a temporary file, then delete it. The background task's own log line records exists=False, so the deletion really happened, and the client had already received the full body. Doing the deletion in the handler before returning would have removed the file before FileResponse ever opened it.

Note the type: this is starlette.background.BackgroundTask, constructed and passed to the response, not FastAPI's BackgroundTasks dependency. Both end up on the same response.background slot, but only the former can be attached to a Response you construct yourself. The broader question of what belongs in a background task at all is covered in FastAPI BackgroundTasks vs Celery vs Arq.

The yield dependency question, measured

/stream-with-dep injects a session through a yield dependency and uses it inside the generator. The trace shows session closed=False on both rows, and dependency: session closed arriving after the last row. So in FastAPI 0.139.2, the exit stack unwinds after the streaming body is consumed, and the pattern works.

That is a useful data point and a bad thing to depend on. The position of dependency teardown relative to response delivery has changed across FastAPI versions, and it is not part of the documented contract — the surrounding rules are set out in Yield Dependencies and Cleanup Order. If teardown moved earlier in some future release, this endpoint would begin emitting rows from a closed session, and it would do so only under streaming, only in production, and only for large exports.

Write it so the question cannot arise:

@app.get("/export")
async def export(request: Request) -> StreamingResponse:
    factory = request.app.state.session_factory  # Shared factory, not a live session.

    async def gen() -> AsyncIterator[bytes]:
        # The generator owns the session for exactly as long as it needs it.
        async with factory() as session:
            async for row in session.stream(select(Order)):
                yield f"{row.id},{row.total}\n".encode()

    return StreamingResponse(gen(), media_type="text/csv")

The generator opens and closes its own session, so its lifetime is bounded by the code that uses it rather than by the framework's unwind order. This does mean holding a pooled connection for the duration of the export, which for a slow client is a long time — Fixing asyncpg Pool Exhaustion explains why that matters and what to do about it.

Errors after the first byte

/stream-error demonstrates the constraint that has no workaround. The response is 200 OK, the body contains partial-data followed by ERROR: upstream died mid-stream, and there is no world in which it could have been a 500. The status line and headers were committed in the http.response.start message before the generator produced its first chunk.

Your options, in full:

  1. Emit an in-band error marker, as the example does. For NDJSON this is natural: a final line such as {"error": "..."} that clients are contracted to check. For CSV it is ugly but workable. For a single large JSON array it is nearly impossible, which is an argument for NDJSON.
  2. Let the exception propagate, which aborts the connection mid-body. The client sees a truncated response — for HTTP/1.1 chunked encoding, a missing terminal chunk, which well-behaved clients report as an error. This is honest but gives the client no diagnostic.
  3. Validate before streaming. Do the work that can fail — permission checks, query planning, the first row fetch — inside the handler, before you construct the StreamingResponse. Failures there are ordinary exceptions handled by the mechanisms in Global Exception Handlers for Consistent API Responses, and they produce a proper status code. This is the technique that actually reduces mid-stream failures rather than dressing them up.

FileResponse specifics

/download returns FileResponse(REPORT, media_type="text/csv", filename="report.csv"). Three things happen that a hand-rolled Response(open(path,"rb").read()) would not do: the file is streamed in chunks rather than read whole, Content-Length is set from a stat call, and Last-Modified and ETag headers are derived from the file's metadata so conditional requests can return 304.

The filename argument sets Content-Disposition: attachment, which is what makes a browser download rather than render. Omit it to serve inline.

Two cautions. First, FileResponse opens the file when the response is sent, not when it is constructed, which is exactly why the delete-after-send pattern must use a background task. Second, never build the path from unvalidated user input — a path traversal here reads arbitrary files. Resolve the path and assert it is within the intended directory before constructing the response.

Verification

Streaming endpoints need a test that actually streams. A test that calls client.get() and reads .text buffers the whole body and will happily pass on an endpoint that has silently become non-streaming.

def test_export_streams_incrementally(client):
    with client.stream("GET", "/export") as resp:
        assert resp.status_code == 200
        assert resp.headers["content-type"].startswith("text/csv")
        chunks = list(resp.iter_bytes())
    # More than one chunk proves the body was not buffered into a single message.
    assert len(chunks) > 1


def test_temp_file_is_removed_after_download(client, tmp_path):
    resp = client.get("/download-temp")
    assert resp.status_code == 200
    # The background task runs before the response context closes in TestClient.
    assert not (tmp_path / "scratch.csv").exists()

Also assert memory: if you can, run the export against a large fixture under a memory cap. Streaming that quietly regressed to buffering shows up as a resident-set spike, not as a failing assertion.

Trade-offs and when not to stream

Streaming is not free and is not always right.

  • You lose the schema guarantee. As /stream-typed proves, a declared response_model=list[Row] accepted a body that was not a list of Row at all. If a contract matters more than memory, buffer.
  • You lose clean error semantics after the first byte, as shown above.
  • BaseHTTPMiddleware can defeat it. Middleware built on BaseHTTPMiddleware pipes the response through a memory object stream and can buffer it, converting your stream back into a single payload. If you stream, prefer pure ASGI middleware; the performance argument for that choice is made in Middleware Implementation.
  • Proxies may buffer anyway. Nginx buffers upstream responses by default; without proxy_buffering off your chunks arrive in one lump regardless of what the application did.
  • A slow client holds resources for as long as it takes to read. For a large export drawn from a database, that means a pooled connection pinned for minutes.

For payloads under a few megabytes, a buffered response_model endpoint is simpler, safer and fully validated. Reach for streaming when the payload is genuinely unbounded, when time-to-first-byte matters, or when the data is already bytes on disk.

FAQ

Why does response_model have no effect on a StreamingResponse? Because FastAPI only runs serialize_response when the handler returns something that is not already a Response. A StreamingResponse is a Response, so FastAPI forwards it untouched and never validates the bytes the generator produces. The declared response_model still appears in the OpenAPI schema, which means the schema becomes a claim you must uphold yourself.

When does a background task attached to a StreamingResponse run? After the generator is exhausted and the final body chunk has been sent. In a captured trace the order was generator finally block, then background task, so a background task can safely clean up resources the stream was using.

Can I return a 500 if my stream fails halfway through? No. The status code and headers are sent in the http.response.start message before the first chunk of the body, so by the time an error occurs the client already has a 200. The only options are to emit an in-band error marker in the payload format or to abort the connection, which surfaces to the client as a truncated response.

Should the database session for a streaming query come from a yield dependency? It works in FastAPI 0.139.2, where teardown was observed to run after the stream was consumed, but relying on that couples your code to an implementation detail. Acquiring the session inside the generator with an async with block makes the lifetime explicit and immune to future changes in unwind ordering.

Does FileResponse read the whole file into memory? No. FileResponse streams the file in chunks and sets Content-Length from a stat call, so memory use stays flat regardless of file size. It also handles ETag and Last-Modified headers, which is why it is preferable to reading bytes yourself and returning a plain Response.