Let’s Start With a Real Problem
You add a “profile picture” feature to your app. It works great in your own testing. Then, within a week: someone uploads a 40MB image and your server’s disk usage spikes. Someone else renames a .exe file to photo.jpg and uploads it. A third person uploads a genuinely corrupted file that crashes your image-processing code. None of these are exotic attacks — they’re the completely predictable, everyday reality of accepting files from the public internet.
This chapter is about handling file uploads the way a production API actually needs to: validating what comes in, processing images safely, and storing everything in an organized, predictable way.
What You’ll Learn
- How to accept file uploads in FastAPI using
UploadFile - How to validate file type and file size before trusting anything about the upload
- How to safely open and verify an image using Pillow, rejecting corrupted or fake files
- How to resize images and store them with unique, collision-proof filenames
- How to serve uploaded files back out through your API
- Theory + hands-on assignment questions to lock in what you’ve learned
Theory: Why File Uploads Need Extra Care
A file upload is fundamentally different from a JSON request body — Pydantic can’t validate the contents of a file the way it validates a string or a number. That means you are responsible for checking three things, in this order, before doing anything else with an uploaded file:
- Type — is this actually the kind of file you expect? (Never trust the filename extension alone — it’s just text the client typed.)
- Size — is it within a reasonable limit? An unbounded upload endpoint is an easy way for someone to fill up your server’s disk.
- Integrity — for images specifically, can it actually be opened and decoded as a real image, or is it corrupted or not an image at all?
⚠️ Never trust
Content-Typealone. A malicious client can set anyContent-Typeheader they like, regardless of what the file actually contains. For images, the only reliable check is genuinely trying to open and decode the file with an imaging library — which is exactly what Program 2 does with Pillow.
Program 1: Basic Upload with Type and Size Validation
Program Objective
Build a small, standalone FastAPI project with an upload endpoint that validates an incoming file’s content type and size, saves it to disk with a unique filename, and serves it back through a static file URL.
Step-by-Step Implementation
Step 1: Create the project
uv init file-upload-basics-demo
cd file-upload-basics-demo
uv add fastapi --extra standard
💡
python-multipart(required for handling file uploads) is already included via thestandardextra, so no separate install is needed.
Step 2: Create the project structure
mkdir -p app/routers uploads
touch app/__init__.py app/routers/__init__.py
file-upload-basics-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ └── routers/
│ ├── __init__.py
│ └── uploads.py
├── uploads/
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
app/routers/uploads.py
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, status
router = APIRouter(prefix="/upload", tags=["Uploads"])
UPLOAD_DIR = Path("uploads")
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB
@router.post("/image", status_code=status.HTTP_201_CREATED)
async def upload_image(file: UploadFile):
# 1. Validate content type
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported file type: {file.content_type}. Allowed: {', '.join(ALLOWED_CONTENT_TYPES)}.",
)
# 2. Read the file into memory and validate size
contents = await file.read()
if len(contents) > MAX_FILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large. Maximum allowed size is {MAX_FILE_SIZE_BYTES // (1024 * 1024)} MB.",
)
# 3. Build a unique, collision-proof filename
file_extension = Path(file.filename).suffix
unique_filename = f"{uuid.uuid4().hex}{file_extension}"
destination_path = UPLOAD_DIR / unique_filename
# 4. Save the file to disk
with open(destination_path, "wb") as f:
f.write(contents)
return {
"original_filename": file.filename,
"saved_as": unique_filename,
"content_type": file.content_type,
"size_bytes": len(contents),
"url": f"/uploads/{unique_filename}",
}
app/main.py
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.routers import uploads
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
app = FastAPI(title="File Upload Basics Demo", version="1.0.0")
app.include_router(uploads.router)
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
@app.get("/", tags=["Root"])
def read_root():
return {"message": "Welcome to File Upload Basics Demo"}
Detailed Code Explanation
file: UploadFile— FastAPI recognizes this type and automatically parses incomingmultipart/form-data, giving you a file-like object with.filename,.content_type, and async methods like.read().UploadFileis preferred over the olderbytes-based approach because it streams the file rather than loading the entire thing into memory immediately, though we do call.read()here to get the full contents for validation and saving.- Step 1 — content type check happens first and is cheap: reject obviously wrong file types before spending any effort reading or processing the file at all.
- Step 2 — size check happens after reading the bytes, since we need the actual byte count to check against
MAX_FILE_SIZE_BYTES.413 Request Entity Too Largeis the semantically correct status code for this specific failure — more precise than a generic400. - Step 3 — the unique filename is the detail most beginners miss: if you saved the file using its original filename, two different users uploading
photo.jpgwould silently overwrite each other’s files.uuid.uuid4().hexgenerates a random, effectively collision-proof identifier, and we preserve the original file extension so the saved file still opens correctly. app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")inmain.pyis what actually makes uploaded files retrievable — any file saved into theuploads/folder becomes accessible athttp://127.0.0.1:8000/uploads/<filename>, without needing a dedicated “download” endpoint.UPLOAD_DIR.mkdir(exist_ok=True)ensures the folder exists before the app tries to save anything into it —exist_ok=Truemeans this won’t raise an error if the folder is already there from a previous run.
Execution & Testing
uv run fastapi dev app/main.py
- Open
http://127.0.0.1:8000/docs, expandPOST /upload/image, click Try it out, and choose a real.jpgor.pngfile from your computer. Execute, and confirm you get back a response with aurlfield. - Paste that URL (e.g.
http://127.0.0.1:8000/uploads/<generated-name>.jpg) into a new browser tab — confirm the actual image loads. - Try uploading a
.txtor.pdffile — confirm you get a400explaining the content type isn’t allowed. - Try uploading a genuinely large file (over 5MB, e.g. a high-resolution photo) — confirm you get a
413explaining it’s too large. - Upload the same image file twice in a row — check your
uploads/folder and confirm two different filenames were created, proving the collision protection works.
Program 2: Processing and Storing a Profile Picture
Program Objective
Build a small, standalone FastAPI project with a protected POST /users/me/avatar endpoint that validates an uploaded image using Pillow, resizes it to a consistent size, saves it under a predictable per-user filename, and updates the logged-in user’s avatar_url in the database.
Step-by-Step Implementation
Step 1: Create the project
uv init avatar-upload-demo
cd avatar-upload-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
uv add "python-jose[cryptography]"
uv add pillow
Step 2: Create the project structure
mkdir -p app/core app/db app/models app/schemas app/services app/routers uploads/avatars
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
avatar-upload-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── security.py
│ │ └── dependencies.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── token.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── user_service.py
│ │ └── avatar_service.py
│ └── routers/
│ ├── __init__.py
│ ├── users.py
│ └── auth.py
├── uploads/
│ └── avatars/
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Avatar Upload Demo"
app_version: str = "1.0.0"
database_url: str = "sqlite:///./app.db"
jwt_secret_key: str = "a-very-insecure-demo-secret-change-this"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
settings = Settings()
app/core/security.py
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
to_encode = {"sub": subject, "exp": expire}
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise ValueError("Invalid or expired token.") from exc
app/core/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.security import decode_token
from app.db.database import get_db
from app.models.user import User
from app.services.user_service import get_user_by_username
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
except ValueError:
raise credentials_exception
username = payload.get("sub")
if username is None:
raise credentials_exception
user = get_user_by_username(db, username)
if user is None:
raise credentials_exception
return user
app/db/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app/models/user.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(255), default=None)
app/schemas/user.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
username: Annotated[str, Field(min_length=3, max_length=30)]
email: Annotated[EmailStr, Field()]
password: Annotated[str, Field(min_length=8, max_length=64)]
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
avatar_url: str | None
model_config = ConfigDict(from_attributes=True)
app/schemas/token.py
from pydantic import BaseModel
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
app/services/user_service.py
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate
def create_user(db: Session, user_in: UserCreate) -> User:
new_user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hash_password(user_in.password),
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
def get_user_by_username(db: Session, username: str) -> User | None:
return db.query(User).filter(User.username == username).first()
app/services/avatar_service.py
import io
from pathlib import Path
from fastapi import HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from sqlalchemy.orm import Session
from app.models.user import User
AVATAR_DIR = Path("uploads/avatars")
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB
AVATAR_SIZE = (200, 200)
async def process_and_save_avatar(db: Session, user: User, file: UploadFile) -> User:
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported file type: {file.content_type}.",
)
contents = await file.read()
if len(contents) > MAX_FILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File too large. Maximum allowed size is 5 MB.",
)
# Verify the file is genuinely a valid, undamaged image.
try:
image = Image.open(io.BytesIO(contents))
image.verify()
except UnidentifiedImageError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The uploaded file is not a valid image.",
)
# image.verify() invalidates the file object, so we must re-open it to actually use it.
image = Image.open(io.BytesIO(contents))
image = image.convert("RGB") # normalizes PNG/WEBP transparency before saving as JPEG
image.thumbnail(AVATAR_SIZE)
destination_path = AVATAR_DIR / f"{user.id}.jpg"
image.save(destination_path, format="JPEG", quality=85)
user.avatar_url = f"/uploads/avatars/{user.id}.jpg"
db.commit()
db.refresh(user)
return user
app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password
from app.db.database import get_db
from app.schemas.token import TokenResponse
from app.services.user_service import get_user_by_username
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/login", response_model=TokenResponse)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = get_user_by_username(db, form_data.username)
if user is None or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password.")
return TokenResponse(access_token=create_access_token(subject=user.username))
app/routers/users.py
from fastapi import APIRouter, Depends, UploadFile, status
from sqlalchemy.orm import Session
from app.core.dependencies import get_current_user
from app.db.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserOut
from app.services.avatar_service import process_and_save_avatar
from app.services.user_service import create_user
router = APIRouter(prefix="/users", tags=["Users"])
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
return create_user(db, user_in)
@router.get("/me", response_model=UserOut)
def read_current_user(current_user: User = Depends(get_current_user)):
return current_user
@router.post("/me/avatar", response_model=UserOut)
async def upload_avatar(
file: UploadFile,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return await process_and_save_avatar(db, current_user, file)
app/main.py
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.core.config import settings
from app.db.database import Base, engine
from app.models import user # noqa: F401
from app.routers import auth, users
Path("uploads/avatars").mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name, version=settings.app_version)
app.include_router(users.router)
app.include_router(auth.router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
@app.get("/", tags=["Root"])
def read_root():
return {"message": f"Welcome to {settings.app_name}"}
Detailed Code Explanation
User.avatar_url: Mapped[str | None]— nullable, since a freshly registered user hasn’t uploaded a picture yet.UserOutreflects this withavatar_url: str | None, so the API honestly represents “no avatar yet” asnull, not an empty string or a broken link.- The real integrity check happens here:
image = Image.open(io.BytesIO(contents))
image.verify()
Image.open(...) alone doesn’t fully decode the image — it can succeed even on some malformed files. .verify() forces Pillow to actually check the file’s internal structure is valid, raising UnidentifiedImageError (or similar) if it’s corrupted or not a real image at all — this is exactly the check that a spoofed Content-Type header can’t fool.
- The re-open after
.verify()is not a mistake — it’s a documented Pillow requirement. Calling.verify()leaves the image object unusable for further processing, so we deliberately create a freshImage.open(...)call afterward to actually work with the image data. image.convert("RGB")— PNG and WEBP files can include a transparency channel that JPEG doesn’t support. Converting to RGB before saving avoids a crash or a strange-looking output when we save everything as.jpgfor consistency.image.thumbnail(AVATAR_SIZE)resizes the image to fit within200x200pixels while preserving its aspect ratio — unlike a hard resize,.thumbnail()won’t stretch or distort a non-square photo.destination_path = AVATAR_DIR / f"{user.id}.jpg"— notice this filename is predictable, not random, unlike Program 1’s upload. That’s deliberate: every time this specific user uploads a new avatar, it overwrites their previous one at the exact same path, soavatar_urlnever needs to change once it’s set, and old avatar files don’t pile up on disk.process_and_save_avataris called from a protected route (Depends(get_current_user)) — combining everything from Chapters 15–18: only a logged-in user can upload an avatar, and it always updates their own record (current_user, never an ID taken from the request).
Execution & Testing
uv run fastapi dev app/main.py
- Register a user and log in via
POST /auth/loginto get a token; Authorize in Swagger UI using that token. - Call
POST /users/me/avatar, uploading a real photo. Confirm the response’savatar_urlfield looks like/uploads/avatars/1.jpg. - Open
http://127.0.0.1:8000/uploads/avatars/1.jpgdirectly in your browser — confirm you see a resized, square-fitted version of your photo. - Upload a different photo through the same endpoint again — confirm it overwrites the same file (same URL, new image content), rather than creating a second file.
- Try uploading a renamed non-image file (e.g., rename a
.txtfile tofake.jpgand force its content type, or simply try uploading any corrupted image file you have) — confirm you get a clean400 The uploaded file is not a valid image, proving Pillow’s.verify()step is doing real work, not just checking the file extension.
Tips & Instructions for Developers
- ✅ Always validate content type, size, and (for images) integrity — in that order, cheapest checks first — before doing any real processing.
- ✅ Use
uuid4()-based filenames for general uploads, but predictable, per-owner filenames (like{user_id}.jpg) when a resource should only ever have one current file, like an avatar. - ✅ Always convert images to a consistent format and color mode (like RGB JPEG) before saving, to avoid format-specific surprises later.
- ✅ Store uploaded files outside your application code directory, and serve them through a dedicated static route or a proper object storage service in production — never assume local disk storage scales indefinitely.
- ✅ Tie file uploads to the authenticated user wherever ownership matters, exactly like the ownership patterns from Chapter 18.
- ❌ Don’t trust
file.content_typeas your only validation — it’s client-supplied and easy to fake; for images, Pillow’s.verify()is your real safety net. - ❌ Don’t save uploaded files using their original filename — it invites both overwrites/collisions and, on some systems, path traversal risks if the filename isn’t sanitized.
Official FastAPI Documentation References
- Request Files
- Static Files
- Pillow (PIL Fork) Documentation
- Pillow — Image.verify()
- Starlette — UploadFile
Theory Questions
- Why is
file.content_typenot sufficient on its own to guarantee an uploaded file is genuinely the type it claims to be? - Why does validating file size happen after reading the file’s contents, rather than before?
- Why is
Image.open(...).verify()a stronger check than simply confirming the file opens without an error? - Why does the avatar upload use a predictable filename (
{user_id}.jpg) while the general upload in Program 1 uses a random one? - Why does the avatar endpoint use
current_user.idfor the filename and database update, rather than accepting auser_idfrom the request?
Practical Assignment
- Add a dimensions check: Starting from Program 1’s project, after validating type and size, open the file with Pillow and reject any image smaller than
100x100pixels, returning a clear400error. - Generate a thumbnail alongside the original: Starting from Program 1’s project, in addition to saving the original uploaded image, also generate and save a
150x150thumbnail version alongside it (e.g.<uuid>_thumb.jpg), and return both URLs in the response. - Add avatar deletion: Starting from Program 2’s project, add a
DELETE /users/me/avatarendpoint that removes the user’s avatar file from disk (if it exists) and setsavatar_urlback toNonein the database.
Summary
In this chapter, you learned:
- Why file uploads require validation that Pydantic alone can’t provide
- How to check content type and size before trusting an uploaded file
- How to use Pillow’s
.verify()to catch corrupted or fake image files that a spoofedContent-Typeheader can’t hide - How to resize images consistently and store them with either collision-proof or predictable filenames, depending on the use case
- How to tie file uploads to the authenticated user, combining everything from the authentication and authorization chapters
