Lesson 4 - Working with Files
Welcome to Working with Files
So far your Task Manager has dealt only with text and numbers — titles, IDs, JSON. But real apps move files: a user attaches a PDF to a task, uploads a photo, or downloads a report. Files travel over HTTP differently from JSON, and FastAPI gives you a clean, memory-safe way to handle them in both directions. In this lesson you’ll learn to accept an uploaded file, read it, check that it’s the kind of file you expected, and send a file back so a client can download it.
We’ll keep tying everything to the Task Manager theme: by the end you’ll be able to attach a file to a task and hand it back later.
By the end of this lesson, you will be able to:
- Accept an uploaded file with
UploadFileand read its contents withawait file.read() - Use a file’s metadata —
file.filenameandfile.content_type - Validate an upload and reject bad files with
HTTPException - Send a file back to the client for download with
FileResponse
Let’s start by accepting a file.
Accepting an Upload with UploadFile
To receive a file, you declare a parameter typed as UploadFile — a special FastAPI type that represents an uploaded file. FastAPI then reads the incoming file for you and hands you an object you can work with. Because reading a file is an input/output (I/O) operation, you write the endpoint as async def and read the contents with await file.read(), which gives you the file’s bytes.
from fastapi import FastAPI, UploadFile
app = FastAPI()
@app.post("/upload")
async def upload(file: UploadFile):
content = await file.read()
return {"filename": file.filename, "content_type": file.content_type, "size": len(content)}Three things to notice. The parameter file: UploadFile tells FastAPI “expect an uploaded file here.” Inside the function, await file.read() reads the whole file into the variable content as bytes. And file.filename and file.content_type are metadata the browser sent along with the file — the original name and the file’s declared type (its MIME type).
Let’s send a small text file to this endpoint. We use FastAPI’s TestClient, which lets us call the app in-process without starting a server. To upload, we pass a files= dictionary; each value is a tuple of (filename, bytes, content_type):
from fastapi.testclient import TestClient
client = TestClient(app)
response = client.post(
"/upload",
files={"file": ("notes.txt", b"hello fastapi files", "text/plain")},
)
print(response.status_code, response.json())200 {'filename': 'notes.txt', 'content_type': 'text/plain', 'size': 19}It worked. FastAPI received the upload, and our endpoint reported back the original name (notes.txt), the type the client claimed (text/plain), and the size in bytes (19, the length of hello fastapi files). The key takeaway: you didn’t have to parse raw HTTP — you just declared file: UploadFile and FastAPI did the rest.
Why UploadFile Beats Raw Bytes
You could accept a file as plain bytes, but UploadFile is almost always the better choice, for two reasons.
It’s memory-safe. When you accept raw bytes, FastAPI loads the entire file into your program’s memory at once. A 2 GB video upload would try to allocate 2 GB of RAM — and a few of those at the same time can crash your server. UploadFile instead uses a spooled file: small files stay in memory, but once a file grows past a threshold, it’s automatically streamed to a temporary file on disk. Your memory usage stays small no matter how big the upload is.
It carries metadata. Raw bytes are just data — you’d have no idea what the file was called or what type it is. UploadFile gives you useful attributes:
file.filename— the original name the client sent, e.g."notes.txt".file.content_type— the declared MIME type, e.g."text/plain"or"image/png".
These let you make decisions (like the validation you’ll write next) and store the file under a sensible name. In short: reach for UploadFile, not bytes, unless you have a specific reason not to.
UploadFile streams to disk - and needs python-multipart
UploadFile spools large files to a temporary file on disk instead of holding them entirely in memory, so a huge upload won’t exhaust your server’s RAM. File uploads arrive as multipart/form-data, which FastAPI parses using the python-multipart package. It’s already bundled when you install fastapi[standard] (which this course uses), so uploads just work — but if you ever see an error mentioning python-multipart, that’s the missing piece.
Validating an Upload
Just because someone can upload a file doesn’t mean you should accept it. Maybe your task attachments must be plain-text notes, and someone tries to upload an image. You can inspect the metadata and reject anything that doesn’t fit by raising an HTTPException. Recall from Lesson 2 that HTTPException returns a proper error status code instead of crashing.
Here we accept a text attachment for a task, but reject anything whose content_type isn’t text/plain with a 400 Bad Request — the right code, because the client sent the wrong kind of file:
from fastapi import FastAPI, UploadFile, HTTPException
app = FastAPI()
@app.post("/tasks/{task_id}/attachment")
async def add_attachment(task_id: int, file: UploadFile):
if file.content_type != "text/plain":
raise HTTPException(status_code=400, detail="only .txt allowed")
content = await file.read()
return {"task_id": task_id, "filename": file.filename, "size": len(content)}Notice the check happens before await file.read() — there’s no point reading a file we’re about to reject. Let’s try a valid text upload and an invalid image upload:
client = TestClient(app)
ok = client.post(
"/tasks/1/attachment",
files={"file": ("notes.txt", b"buy milk", "text/plain")},
)
print(ok.status_code, ok.json())
bad = client.post(
"/tasks/1/attachment",
files={"file": ("photo.png", b"\x89PNG\r\n", "image/png")},
)
print(bad.status_code, bad.json())200 {'task_id': 1, 'filename': 'notes.txt', 'size': 8}
400 {'detail': 'only .txt allowed'}The text file sails through with 200, and the endpoint records it against task 1. The PNG is stopped at the door with 400 and a clear message. The same pattern works for size limits: read the content, check len(content), and raise a 400 (or a 413 Request Entity Too Large) if it’s bigger than you allow. Validating uploads keeps junk — and oversized files — out of your system.
Sending a File Back with FileResponse
Files travel both ways. To let a client download a file, you return a FileResponse pointing at a path on disk. FastAPI reads that file efficiently and streams it back with the right headers. Imagine each task can produce a report saved on the server; this endpoint hands that report to the client:
from fastapi import FastAPI
from fastapi.responses import FileResponse
app = FastAPI()
@app.get("/tasks/{task_id}/report")
def download_report(task_id: int):
return FileResponse("task_report.txt", filename="download.txt")FileResponse takes the path to the file on disk as its first argument. The optional filename= sets the name the browser will save it as — here, download.txt. (In this example the file task_report.txt already exists on the server; in a real app it might be a report you generated.) Let’s request it:
client = TestClient(app)
r = client.get("/tasks/1/report")
print(r.status_code, repr(r.text))
print("content-type:", r.headers["content-type"])
print("content-disposition:", r.headers.get("content-disposition"))200 'Task report: 3 done, 1 in progress\n'
content-type: text/plain; charset=utf-8
content-disposition: attachment; filename="download.txt"The client gets a 200 and the file’s actual contents in the body. FastAPI also set two headers for us: content-type reflects the file’s type (it guessed text/plain from the .txt extension), and content-disposition: attachment; filename="download.txt" tells the browser to save the file as download.txt rather than display it. With UploadFile for receiving and FileResponse for sending, your API can handle the full file round-trip.
Practice Exercises
Exercise 1: Report the upload
Write an async endpoint POST /files that accepts a parameter file: UploadFile, reads it, and returns a dictionary with the file’s name, declared type, and byte size.
Hint
Mirror the first example: async def files(file: UploadFile):, then content = await file.read(), and return {"name": file.filename, "type": file.content_type, "size": len(content)}. The async def and await go together — reading a file is I/O.
Exercise 2: Reject big files
Extend an upload endpoint so it refuses files larger than 1 MB (1,048,576 bytes) with a 400. What do you check, and where?
Hint
After content = await file.read(), check the length: if len(content) > 1_048_576: raise HTTPException(status_code=400, detail="file too large"). Here you must read first, because you need the actual byte count to compare.
Exercise 3: Offer a download
Add a GET /tasks/{task_id}/notes endpoint that returns a file from disk named notes.txt, saved by the client as task-notes.txt.
Hint
Import it with from fastapi.responses import FileResponse, then return FileResponse("notes.txt", filename="task-notes.txt"). The first argument is the path on the server; filename= controls the name the browser saves.
Summary
FastAPI makes file handling clean in both directions. To receive a file, declare a parameter typed UploadFile, write the endpoint as async def, and read the bytes with await file.read(). Prefer UploadFile over raw bytes because it streams large files to disk (so they don’t exhaust memory) and carries useful metadata — file.filename and file.content_type. Use that metadata to validate uploads, raising HTTPException(status_code=400, ...) for the wrong type or size. To send a file back for download, return a FileResponse pointing at a path, optionally setting filename= for the saved name. Uploads rely on python-multipart, which ships with fastapi[standard].
Key Concepts
UploadFile— the parameter type for accepting an uploaded file, with memory-safe spooling and metadata.await file.read()— reads the upload’s bytes inside anasync defendpoint.file.filename/file.content_type— the upload’s original name and declared MIME type.- Validation — inspect the metadata or size and raise
HTTPException(400)to reject bad uploads. FileResponse— returns a file from disk to the client for download, with an optionalfilename=.
Why This Matters
Almost every real application moves files: attachments, avatars, exports, reports. Doing it naively — loading whole files into memory or accepting anything a client sends — is a fast path to crashed servers and security holes. UploadFile keeps memory usage flat even for huge uploads, its metadata lets you enforce rules, and FileResponse gives clients a proper, browser-friendly download. These two tools cover the full file lifecycle for your Task Manager, and they’re the same pattern you’ll reach for in any production API that handles files.
Next Steps
Continue to Lesson 5 - Guided Project: Robust Task Endpoints
Put status codes, errors, forms, and files together to build a complete, production-ready set of task endpoints.
Back to Module Overview
Return to the HTTP Done Right module overview
Continue Building Your Skills
Your Task Manager can now accept file attachments safely and hand files back for download. In the next lesson you’ll bring the whole module together — status codes, clean errors, forms, and files — to build a robust, production-ready set of task endpoints from start to finish.