Skip to main content
FastAPI · lesson 18 of 19
FastAPI advanced Lesson 18 of 19

FastAPI File Uploads, Streaming and WebSockets

FastAPI files, streaming and WebSockets, run for real: UploadFile with a 415 for spoofed types, FileResponse headers, timed chunks, and a WebSocket echo.

By SkillByExample · Published

fastapi python uploadfile streaming websockets starlette

Bytes In, Bytes Out, and a Connection That Stays Open

Most routes take a small JSON body and return a small JSON body. This lesson covers the three cases that don’t fit that shape: a client uploading a file, a server sending a file or a long body back a piece at a time, and a WebSocket, where one connection stays open and messages go both ways.

Each section shows the version that goes wrong first, then the fix. Outputs come from FastAPI 0.143.0, Starlette 1.7.0, Pydantic 2.14.1, python-multipart 0.0.32, httpx2 2.13.1 and Uvicorn 0.54.0 on Python 3.12, called with curl 7.85.0. Uploads need pip install python-multipart, and the TestClient examples need pip install httpx2 (with plain httpx, Starlette prints a deprecation warning). Run each file with uvicorn <file>:app --port 8011 and send requests from a second terminal.

Uploads with UploadFile

A parameter typed UploadFile tells FastAPI to read a multipart/form-data body, the format an HTML file input sends. Save as upload.py:

from fastapi import FastAPI, UploadFile

app = FastAPI()


@app.post("/avatar")
async def upload_avatar(file: UploadFile):
    data = await file.read()
    return {"filename": file.filename, "content_type": file.content_type, "size": len(data)}

curl -F sends a multipart form, and @ means “the contents of this file”. The second request uploads a text file but uses ;type= to claim it’s a PNG:

$ curl -s -w '\n' -F 'file=@avatar.png' 127.0.0.1:8011/avatar
{"filename":"avatar.png","content_type":"image/png","size":67}
$ curl -s -w '\n' -F 'file=@notes.txt;type=image/png' 127.0.0.1:8011/avatar
{"filename":"notes.txt","content_type":"image/png","size":15}

The route accepted a 15-byte text file as an avatar. file.content_type and file.filename aren’t checked by anyone: they’re whatever the client wrote into the part’s headers, so checking content_type == "image/png" would have let this through too. The parameter name matters, as well: it has to match the form field name, file here. To know what a file really is, look at its bytes. Every PNG starts with the same 8-byte signature. Save as upload_checked.py:

from fastapi import FastAPI, HTTPException, UploadFile

app = FastAPI()
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
MAX_BYTES = 1_000_000


@app.post("/avatar")
async def upload_avatar(file: UploadFile):
    data = await file.read(MAX_BYTES + 1)
    if len(data) > MAX_BYTES:
        raise HTTPException(413, f"file is larger than {MAX_BYTES} bytes")
    if not data.startswith(PNG_SIGNATURE):
        raise HTTPException(415, f"not a PNG, whatever the client said ({file.content_type})")
    return {"filename": file.filename, "size": len(data)}

huge.png is the same PNG with 2 MB of zero bytes after it:

$ curl -s -w ' %{http_code}\n' -F 'file=@avatar.png' 127.0.0.1:8011/avatar
{"filename":"avatar.png","size":67} 200
$ curl -s -w ' %{http_code}\n' -F 'file=@notes.txt;type=image/png' 127.0.0.1:8011/avatar
{"detail":"not a PNG, whatever the client said (image/png)"} 415
$ curl -s -w ' %{http_code}\n' -F 'file=@huge.png' 127.0.0.1:8011/avatar
{"detail":"file is larger than 1000000 bytes"} 413

The spoofed upload now gets 415 Unsupported Media Type, and the oversized one 413, which Uvicorn’s log names Request Entity Too Large (Python 3.13+ calls it Content Too Large). read(MAX_BYTES + 1) reads at most one byte past the limit, so the route never pulls a huge file into memory just to measure it. But be clear about what this doesn’t do: by the time the route runs, the whole 2 MB body has already arrived. Starlette parsed it first, holding small files in memory and spooling bigger ones to a temporary file, which is what UploadFile wraps. A hard cap on upload size belongs in front of the app, such as nginx’s client_max_body_size. Inside the app, a middleware can reject a request whose Content-Length header is too big before anything is parsed, though that doesn’t stop a chunked body that sends no length. A signature check is also only a first filter: to trust an image, open it with an image library. And never use file.filename as a path on disk; save under a name you generate, such as f"{uuid4().hex}.png".

Downloads with FileResponse

FileResponse sends a file from disk without loading it into memory first. Save as download.py, with a small q3.csv next to it:

from pathlib import Path

from fastapi import FastAPI
from fastapi.responses import FileResponse

app = FastAPI()
REPORT = Path(__file__).parent / "q3.csv"


@app.get("/report")
def report():
    return FileResponse(REPORT)


@app.get("/report/download")
def report_download():
    return FileResponse(REPORT, filename="sales-q3.csv")


@app.get("/report/download-fr")
def report_download_fr():
    return FileResponse(REPORT, filename="ventes-été.csv")

First, without a filename:

$ curl -s -i 127.0.0.1:8011/report
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:47:40 GMT
server: uvicorn
content-type: application/vnd.ms-excel
accept-ranges: bytes
content-length: 34
last-modified: Tue, 13 Oct 2026 11:47:35 GMT
etag: "839a9d523dc0bf42c30509101dce4046"

region,total
north,1200
south,950

There’s no content-disposition header, so the browser decides what to do: it may show the CSV in the tab, and if it saves it, the name comes from the URL, report, with no extension. Starlette did fill in the rest for free: content-length from the file size, last-modified and an etag for caching, and accept-ranges: bytes, which means it answers Range requests, so downloads can resume. Look at the content-type, though. Starlette guessed it from the extension with Python’s mimetypes, which on Windows reads the registry; this run was on Windows with Excel’s type registered, and on Linux the same file is text/csv. Pass media_type="text/csv" when the type matters. Now with filename:

$ curl -s -i 127.0.0.1:8011/report/download
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:47:40 GMT
server: uvicorn
content-type: application/vnd.ms-excel
accept-ranges: bytes
content-disposition: attachment; filename="sales-q3.csv"
content-length: 34
last-modified: Tue, 13 Oct 2026 11:47:35 GMT
etag: "839a9d523dc0bf42c30509101dce4046"

region,total
north,1200
south,950
$ curl -s -D - -o /dev/null 127.0.0.1:8011/report/download-fr
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:47:40 GMT
server: uvicorn
content-type: application/vnd.ms-excel
accept-ranges: bytes
content-disposition: attachment; filename*=utf-8''ventes-%C3%A9t%C3%A9.csv
content-length: 34
last-modified: Tue, 13 Oct 2026 11:47:35 GMT
etag: "839a9d523dc0bf42c30509101dce4046"

filename= adds content-disposition: attachment, which tells the browser to save the file, under that name, rather than display it. Pass content_disposition_type="inline" to keep the name but let the browser show it, which suits PDFs. A plain header can only carry ASCII, so for a name like ventes-été.csv Starlette switches to the filename*=utf-8'' form, with the bytes percent-encoded, which every current browser decodes. Never build the path from user input without checking it: FileResponse(f"reports/{name}") with name set to ../.env sends your secrets. Resolve the path and confirm it stays inside the folder: p = (BASE / name).resolve(), then if not p.is_relative_to(BASE): raise HTTPException(404), with BASE itself resolved.

Sending a Body in Pieces with StreamingResponse

When a response is produced slowly, such as rows from a long query, an export, or tokens from a language model, the client can start reading before the end exists. Both routes below produce the same three rows, one second apart. Save as stream.py:

import asyncio
import time

from fastapi import FastAPI
from fastapi.responses import PlainTextResponse, StreamingResponse

app = FastAPI()


async def rows():
    for i in range(3):
        await asyncio.sleep(1)  # stands in for a slow query per row
        yield f"row {i} made at {time.strftime('%H:%M:%S')}\n"


@app.get("/all-at-once")
async def all_at_once():
    body = "".join([row async for row in rows()])
    return PlainTextResponse(body)


@app.get("/stream")
async def stream():
    return StreamingResponse(rows(), media_type="text/plain")

curl -N turns off curl’s output buffer, and the while loop stamps each line with the time it arrived on the client:

$ echo "start $(date +%T)"; curl -sN 127.0.0.1:8011/all-at-once | while read -r line; do echo "$(date +%T) got: $line"; done
start 17:18:27
17:18:30 got: row 0 made at 17:18:28
17:18:30 got: row 1 made at 17:18:29
17:18:30 got: row 2 made at 17:18:30

Row 0 was ready at 17:18:28 and sat on the server for two seconds. A normal response has to know its content-length before it sends anything, so it waits for the last row, and the client sees nothing for three seconds, then everything at once. The streamed route:

$ echo "start $(date +%T)"; curl -sN 127.0.0.1:8011/stream | while read -r line; do echo "$(date +%T) got: $line"; done
start 17:18:30
17:18:31 got: row 0 made at 17:18:31
17:18:32 got: row 1 made at 17:18:32
17:18:33 got: row 2 made at 17:18:33
$ curl -s -D - -o /dev/null 127.0.0.1:8011/stream
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:48:33 GMT
server: uvicorn
content-type: text/plain; charset=utf-8
Transfer-Encoding: chunked

Each row arrived in the same second it was made. StreamingResponse sends the headers straight away and then one chunk per yield, which is why the headers have Transfer-Encoding: chunked and no content-length. The status code and headers are already sent before the first row is made, so an exception halfway through can’t become a 500: the client just gets a body that stops early. Validate everything you can before returning the StreamingResponse. Two more practical points: a sync generator using time.sleep also works, because Starlette iterates it in the thread pool, and a proxy in front, such as nginx with proxy_buffering on, can collect the chunks and undo the streaming. For server-sent events, the same idea with media_type="text/event-stream" and data: ...\n\n lines lets a browser’s EventSource read the stream.

WebSockets: an Echo, and the Client That Leaves

Streaming only goes one way. A WebSocket starts as an HTTP request, upgrades, and then stays open with messages going both ways until one side closes it. This echo server also tracks who is online. Save as chat.py:

from fastapi import FastAPI, WebSocket

app = FastAPI()
online: set[str] = set()


@app.websocket("/ws/{name}")
async def echo(websocket: WebSocket, name: str):
    await websocket.accept()
    online.add(name)
    print(f"server: {name} joined, online={sorted(online)}")
    while True:
        text = await websocket.receive_text()
        await websocket.send_text(f"echo to {name}: {text}")
    online.discard(name)  # cleanup: does this line ever run?

curl can’t hold a WebSocket conversation, but FastAPI’s TestClient can, with no server running: websocket_connect opens the connection, and leaving the with block closes it. The client takes the server module’s name as an argument so it can test both versions:

# chat_client.py: python chat_client.py <module with the app>
import importlib
import sys

from fastapi.testclient import TestClient

server = importlib.import_module(sys.argv[1])
client = TestClient(server.app)

with client.websocket_connect("/ws/ada") as ws:
    ws.send_text("hello")
    print("client:", ws.receive_text())
    ws.send_text("still there?")
    print("client:", ws.receive_text())

print(f"client: closed, server thinks online={sorted(server.online)}")
$ python chat_client.py chat 2>&1 | grep -v '^  '
server: ada joined, online=['ada']
client: echo to ada: hello
client: echo to ada: still there?
Traceback (most recent call last):
starlette.websockets.WebSocketDisconnect: (1000, None)

The conversation worked: accept() completed the handshake, and each send_text got its echo. Then the client closed, and the server, waiting in receive_text(), got a disconnect message, which Starlette raises as WebSocketDisconnect with the close code, 1000 for a normal close. Nothing caught it, so the cleanup line was never reached and ada would stay “online” forever. TestClient re-raises the server’s exception when the with block exits, which is why the client’s last line didn’t print either. Catch the disconnect, and do cleanup in finally so it runs however the handler ends. Save as chat_fixed.py:

from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()
online: set[str] = set()


@app.websocket("/ws/{name}")
async def echo(websocket: WebSocket, name: str):
    await websocket.accept()
    online.add(name)
    print(f"server: {name} joined, online={sorted(online)}")
    try:
        while True:
            text = await websocket.receive_text()
            await websocket.send_text(f"echo to {name}: {text}")
    except WebSocketDisconnect as exc:
        print(f"server: {name} left with code {exc.code}")
    finally:
        online.discard(name)
$ python chat_client.py chat_fixed
server: ada joined, online=['ada']
client: echo to ada: hello
client: echo to ada: still there?
server: ada left with code 1000
client: closed, server thinks online=[]

The disconnect is now an expected event, logged with its code, and online is empty again. online here is a set in one process: with several Uvicorn workers or several servers, each has its own copy, and a broadcast to “everyone online” only reaches the clients connected to that process. Real chat systems put a shared channel, such as Redis pub/sub, behind the sockets.

Practice

  1. Change the upload route to take several files in one request, as files: list[UploadFile], return each file’s name and size, and reject more than three files with a 400. Test it with TestClient, whose files= argument takes a list of (field, (filename, bytes)) pairs.
Solution

Save as p1.py:

from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.testclient import TestClient

app = FastAPI()


@app.post("/attachments")
async def attach(files: list[UploadFile]):
    if len(files) > 3:
        raise HTTPException(400, f"at most 3 files, got {len(files)}")
    return [{"name": f.filename, "size": len(await f.read())} for f in files]


if __name__ == "__main__":
    client = TestClient(app)
    two = [("files", ("a.txt", b"hello")), ("files", ("b.csv", b"x,y\n1,2\n"))]
    r = client.post("/attachments", files=two)
    print(r.status_code, r.json())
    four = [("files", (f"{i}.txt", b"!")) for i in range(4)]
    r = client.post("/attachments", files=four)
    print(r.status_code, r.json())
$ python p1.py
200 [{'name': 'a.txt', 'size': 5}, {'name': 'b.csv', 'size': 8}]
400 {'detail': 'at most 3 files, got 4'}

Several parts with the same field name, files, become one list. With curl that’s -F 'files=@a.txt' -F 'files=@b.csv'. As with the single-file size check, the count check runs after all four files have been received and parsed, so it limits what you store, not what the client can send.

  1. Turn stream.py into server-sent events: media_type="text/event-stream", and each event as an event: line, a data: line holding JSON, and a blank line. Read it once with TestClient.stream while timing each line, then with curl -N against Uvicorn. Why do the two timings differ?
Solution

Save as p2.py:

import asyncio
import json
import time

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient

app = FastAPI()


async def events():
    for i in range(3):
        await asyncio.sleep(0.5)
        yield f"event: row\ndata: {json.dumps({'row': i})}\n\n"


@app.get("/events")
async def stream_events():
    return StreamingResponse(events(), media_type="text/event-stream")


if __name__ == "__main__":
    start = time.perf_counter()
    with TestClient(app).stream("GET", "/events") as r:
        print(r.headers["content-type"])
        for line in r.iter_lines():
            print(f"{time.perf_counter() - start:.1f}s {line!r}")
$ python p2.py
text/event-stream; charset=utf-8
1.5s 'event: row'
1.5s 'data: {"row": 0}'
1.5s ''
1.5s 'event: row'
1.5s 'data: {"row": 1}'
1.5s ''
1.5s 'event: row'
1.5s 'data: {"row": 2}'
1.5s ''
$ echo "start $(date +%T.%2N)"; curl -sN 127.0.0.1:8011/events | while read -r line; do echo "$(date +%T.%2N) got: $line"; done
start 17:20:40.24
17:20:41.37 got: event: row
17:20:41.42 got: data: {"row": 0}
17:20:41.46 got: 
17:20:41.88 got: event: row
17:20:41.94 got: data: {"row": 1}
17:20:41.98 got: 
17:20:42.35 got: event: row
17:20:42.39 got: data: {"row": 2}
17:20:42.42 got: 

The format is right in both, but TestClient handed over every line at 1.5 seconds: it collected the whole response before handing over the first line, so it can check what a stream contains but not when the pieces arrive. Through Uvicorn and curl the events arrive about half a second apart, as they were produced. The few hundredths between lines of one event are the cost of running date for each line. Test the content with TestClient, and check timing against a real server.

  1. Make the server end the conversation: when the client sends bye, close the socket with code 1000 and a reason, and otherwise reply in upper case. On the client, catch the close as a WebSocketDisconnect and print its code and reason.
Solution

Save as p3.py:

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.testclient import TestClient

app = FastAPI()


@app.websocket("/ws")
async def shout(websocket: WebSocket):
    await websocket.accept()
    while True:
        text = await websocket.receive_text()
        if text == "bye":
            await websocket.close(code=1000, reason="client said bye")
            return
        await websocket.send_text(text.upper())


if __name__ == "__main__":
    with TestClient(app).websocket_connect("/ws") as ws:
        ws.send_text("hi")
        print("got:", ws.receive_text())
        ws.send_text("bye")
        try:
            ws.receive_text()
        except WebSocketDisconnect as exc:
            print("server closed:", exc.code, exc.reason)
$ python p3.py
got: HI
server closed: 1000 client said bye

WebSocketDisconnect works in both directions: the server gets it when the client leaves, and the test client gets it when the server closes. The return after close matters: the conversation is over, so the handler must not loop back to receive_text on a socket it has closed. Use code 1000 for a normal close and 1008 for a policy violation, such as a bad token; the reason is a short string the other side can log.

Frequently Asked Questions

Can I trust UploadFile.content_type to check a file's type in FastAPI?
No. UploadFile.content_type and UploadFile.filename are whatever the client wrote in the multipart headers, so curl -F 'file=@notes.txt;type=image/png' uploads a text file that reports image/png. Read the first bytes and compare them with the format's signature, such as the 8-byte PNG header, and return 415 if they don't match. For images you will display, also open the file with an image library.
How do I limit upload size in FastAPI?
Inside the route you can read at most the limit plus one byte with await file.read(MAX_BYTES + 1) and return 413 if you got more, which avoids loading a huge file into memory. But Starlette has already received and parsed the whole request body before the route runs, spooling large files to a temporary file. A hard cap on what clients can send belongs in front of the app, such as nginx's client_max_body_size.
How do I make FastAPI's FileResponse download with a filename?
Pass filename='report.csv' to FileResponse. Starlette then adds content-disposition: attachment; filename="report.csv", which makes the browser save the file under that name instead of displaying it. Names with non-ASCII characters are sent in the filename*=utf-8'' form with percent-encoded bytes, and content_disposition_type='inline' keeps the name but lets the browser show the file.
Why does my FastAPI StreamingResponse arrive all at once?
Usually something between the generator and your eyes is buffering. curl buffers its output unless you pass -N, a proxy such as nginx buffers unless proxy_buffering is off, and FastAPI's TestClient collects the whole response before handing over the first line. Through Uvicorn and curl -N, each yield arrives as it is produced, with Transfer-Encoding: chunked and no content-length header.
How do I handle a client disconnecting from a FastAPI WebSocket?
When the client closes the connection, the next await websocket.receive_text() raises WebSocketDisconnect, carrying the close code, 1000 for a normal close. Wrap the receive loop in try/except WebSocketDisconnect and put cleanup, such as removing the user from an online list, in a finally block. Without that, the exception escapes the handler and any cleanup after the loop never runs.