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 UploadFile and read its contents with await file.read()
  • Use a file’s metadata — file.filename and file.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 metadatafile.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 an async def endpoint.
  • 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 optional filename=.

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.