This tutorial implements file upload functionality in FastAPI, specifically enabling users to upload profile pictures. The application now supports user registration, login, post creation, editing, and deletion, along with an account page for profile management. However, the profile picture section on the account page displays a “coming soon” message with a disabled interface.
By the end of this tutorial, the application will upload real images using multipart form data, process images with Pillow (resizing, converting to consistent formats, and applying other transformations), save files to disk with unique filenames, and update user profiles so profile pictures appear on the account page and alongside posts.
Installing Pillow
Pillow is the standard Python imaging library, providing functionality for resizing images, converting formats, and performing various processing tasks. Install it using pip:
uv add pillowFor file uploads to work in FastAPI, Python’s python-multipart package is also required. However, if FastAPI was installed with the standard extras (as demonstrated earlier in the series), python-multipart is already included and requires no additional installation.
Creating Image Processing Utilities
Create a new file named image_utils.py in the project root. This separates image processing logic from router code, keeping the router clean and making the utilities reusable.
Add the necessary imports:
import uuid
from io import BytesIO
from pathlib import Path
from PIL import Image, ImageOpsThe imports provide uuid for generating unique filenames, BytesIO for working with image bytes in memory, Path from pathlib for file operations, and Image plus ImageOps from Pillow for image functionality and convenient operations.
Define the storage directory constant:
PROFILE_PICS_DIR = Path("media/profile_pics")Pathlib provides a modern, object-oriented approach to file path handling instead of string manipulation. The user model already includes an image_path property that returns either /media/profile_pics/{filename} if an image file is set, or /static/profile_pics/default.jpg if not. The main.py file already mounts the /media directory as static files, making anything saved to media/profile_pics accessible by the browser.
Important consideration for async applications: The FastAPI application uses async endpoints, but image processing with Pillow is CPU-bound work. Performing CPU-bound work directly in an async endpoint blocks the event loop, preventing other requests from being processed. The solution is to write image processing as a regular synchronous function and call it using run_in_threadpool, which offloads the work to a separate thread. The endpoint implementation will demonstrate this pattern.
Create the main image processing function:
def process_profile_image(content: bytes) -> str:
"""
Process uploaded profile image: resize, convert format, save with unique name. Returns the saved filename (not the full path). """ with Image.open(BytesIO(content)) as original:
# Fix orientation based on EXIF data
img = ImageOps.exif_transpose(original)
# Resize to 300x300, maintaining aspect ratio and cropping to fit
img = ImageOps.fit(img, (300, 300), method=Image.Resampling.LANCZOS)
# Convert to RGB if needed (handles PNG transparency, etc.)
if img.mode in ("RGBA", "LA", "P"):
img = img.convert("RGB")
# Generate unique filename
filename = f"{uuid.uuid4().hex}.jpg"
filepath = PROFILE_PICS_DIR / filename
# Ensure directory exists
PROFILE_PICS_DIR.mkdir(parents=True, exist_ok=True)
# Save as JPEG with quality optimization
img.save(filepath, "JPEG", quality=85, optimize=True)
return filenameThis function handles common image processing tasks. It opens the image from bytes using a context manager so Pillow cleans up resources when finished. If the image is invalid, Pillow raises an UnidentifiedImageError, which will be caught in the endpoint.
The function uses two variables: original for the image as opened, and image for the version after transformations. The ImageOps.exif_transpose() call fixes orientation issues—photos taken on phones often contain metadata indicating rotation (e.g., “rotate me 90 degrees”). Without handling this metadata, profile pictures can appear sideways.
ImageOps.fit() resizes and crops the image to exactly 300×300 pixels while maintaining aspect ratio. The LANCZOS resampling method provides high-quality results. The function converts to RGB if needed because some formats like PNG support transparency while JPEG does not—conversion must occur before saving.
The function generates a unique filename using uuid.uuid4().hex. This is critical for security. The original filename uploaded by the user is completely ignored. Generating random filenames prevents filename collisions and mitigates certain security issues.
The mkdir() call with parents=True creates parent directories if needed, and exist_ok=True prevents errors if the directory already exists. The image saves as JPEG with quality 85 and optimize=True, providing a good balance between quality and file size.
The function returns only the filename, not the full path. This filename is what gets stored in the database.
Add a helper function for deleting profile images:
def delete_profile_image(filename: str | None) -> None:
if filename is None:
return
filepath = PROFILE_PICS_DIR / filename
if filepath.exists():
filepath.unlink()This function is needed when users upload new pictures (replacing old ones) or delete their accounts. It returns early if the filename is None. Otherwise, it builds the full path and deletes the file if it exists. The unlink() method is pathlib’s way of deleting files.
Adding Configuration for File Size Limits
Open config.py and add a maximum upload size setting. Centralizing this configuration makes it easy to modify later.
Add the setting after existing configuration fields:
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8"
)
secret_key: SecretStr
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
max_upload_size_bytes: int = 5 * 1024 * 1024 # 5 MB defaultThis defaults to 5 megabytes (5 × 1024 × 1024 bytes), which is plenty for profile pictures and helps protect the server from huge file uploads.
Creating the Upload Endpoint
Open routers/users.py and update the imports. Add UploadFile to the FastAPI imports:
from fastapi import APIRouter, Depends, HTTPException, status, UploadFileImport the exception for catching invalid images from Pillow:
from PIL import UnidentifiedImageErrorImport run_in_threadpool from Starlette for handling CPU-bound work:
from starlette.concurrency import run_in_threadpoolImport the image utility functions:
from image_utils import delete_profile_image, process_profile_imageThe settings are already imported from a previous tutorial, making the file size limit accessible.
Add the upload endpoint at the bottom of the file:
@router.patch("/{user_id}/picture", response_model=UserPrivate)
async def upload_profile_picture(
user_id: int,
file: UploadFile,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
if current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update this user's picture",
)
content = await file.read()
if len(content) > settings.max_upload_size_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size is {settings.max_upload_size_bytes // (1024 * 1024)}MB",
)
try:
new_filename = await run_in_threadpool(process_profile_image, content)
except UnidentifiedImageError as err:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid image file. Please upload a valid image (JPEG, PNG, GIF, WebP).",
) from err
old_filename = current_user.image_file
current_user.image_file = new_filename
await db.commit()
await db.refresh(current_user)
if old_filename:
delete_profile_image(old_filename)
return current_userWhy use PATCH? PATCH is semantically correct for updating an existing resource (the user’s profile).
The function signature takes the user ID from the path, the current user from the authentication dependency, the database session, and a file parameter of type UploadFile. UploadFile is a special FastAPI type for handling file uploads. When browsers upload files, they use a content type called multipart/form-data instead of JSON. FastAPI handles this automatically through the UploadFile object.
UploadFile provides useful attributes and methods:
file.filename- Original filename from the clientfile.content_type- MIME type (cannot be fully trusted)file.size- File sizefile.read()- Reads file contents as bytes
The function first checks authorization using the same pattern from earlier tutorials—only the user can update their own profile picture.
It reads the file content with await file.read() and checks the size using len(content). Use len(content) rather than file.size because file.size is not always reliable until after reading the file. If the file is too large, it returns a 400 Bad Request.
The image processing step demonstrates the async/CPU-bound work solution. Image processing with Pillow is CPU-bound work. Performing CPU-bound work directly in an async endpoint would block the event loop. Normally, FastAPI runs synchronous functions in a thread pool automatically, but this endpoint must be async to await database calls. The solution is run_in_threadpool() from Starlette. This wraps the CPU-bound process_profile_image() function and offloads it to a thread pool while keeping the endpoint async—the best of both worlds.
If the file is not a valid image, Pillow raises UnidentifiedImageError, which gets caught and converted to a 400 Bad Request.
The order of operations at the end is critical:
- Store the old filename
- Update the database with the new filename
- Commit the transaction
- Only after successful commit, delete the old profile image
Why this order? If the old file were deleted first and the database commit failed, the user’s profile picture would be lost entirely. By saving the new file first and deleting the old file last, the worst-case scenario leaves an orphan file on disk—better than losing someone’s profile picture.
The function returns the updated user, which includes the new image_path that the frontend will use.
One critical point about content type validation: The content_type from UploadFile cannot be fully trusted because it comes from the client. Instead, validation occurs by attempting to open the file as an image with Pillow. If Pillow cannot identify it, the request is rejected. This provides much more reliable validation than trusting what the client reports.
Add an endpoint for deleting profile pictures:
@router.delete("/{user_id}/picture", response_model=UserPrivate)
async def delete_user_picture(
user_id: int,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
if current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this user's picture",
)
old_filename = current_user.image_file
if old_filename is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No profile picture to delete",
)
current_user.image_file = None
await db.commit()
await db.refresh(current_user)
delete_profile_image(old_filename)
return current_userThis follows the same pattern: check authorization first, verify there is actually a profile picture to delete, update the database to set image_file to None (which causes the model to return the default image path), and after the commit succeeds, delete the actual file.
Updating Account Deletion
When a user deletes their entire account, their profile picture file should also be deleted to avoid leaving orphan files on disk. Locate the delete_user endpoint and add cleanup logic:
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
if user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this user",
)
result = await db.execute(select(models.User).where(models.User.id == user_id))
user = result.scalars().first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
old_filename = user.image_file
await db.delete(user)
await db.commit()
if old_filename:
delete_profile_image(old_filename)This uses the same safe order of operations—the file is not deleted until after the database commit succeeds.
Fixing a Security Issue in User Updates
Open schemas.py and examine the UserUpdate schema. Currently, it includes image_file as a field that can be updated:
class UserUpdate(BaseModel):
username: str | None = None
email: EmailStr | None = None
image_file: str | None = None # SECURITY ISSUEThis was acceptable before upload functionality existed and during manual testing, but it now represents a security vulnerability. If users can set their image_file to any string through the PATCH endpoint, they could set it to another user’s filename and delete it through the delete picture endpoint.
Remove image_file from UserUpdate:
class UserUpdate(BaseModel):
username: str | None = None
email: EmailStr | None = NoneProfile pictures should only be changed through the dedicated upload or delete endpoints, which handle proper validation.
Updating the User Update Endpoint
Since the schema changed, the update_user endpoint must be updated. Open routers/users.py and locate the update_user function. Remove the image_file handling:
# Update only provided fields
if user_data.username is not None:
user.username = user_data.username
if user_data.email is not None:
user.email = user_data.email.lower()
# DELETE THIS:
# if user_data.image_file is not None:
# user.image_file = user_data.image_file
await db.commit()
await db.refresh(user)
return userThe update endpoint now handles only username and email. Profile pictures are managed separately through the new dedicated endpoints.
Testing the Backend API
Ensure the server is running and navigate to the API documentation at /docs. Verify that the new endpoints appear: “Upload Profile Picture” and “Delete User Picture”.
If no users exist from previous tutorials, create a new user for testing. Authorize in the documentation interface using existing credentials (remember that the “username” field expects an email address).
Verify authentication by calling the GET /api/users/me endpoint to confirm login status.
Testing Image Upload
Navigate to the “Upload Profile Picture” endpoint and click “Try it out”. Enter the user ID (likely 1 for the first user). Click “Choose File” and select a test image (PNG format works well for testing).
Execute the request. A 200 response returns with the image file and image path:
/attachments/Pasted-image-20260425152233.png)
Check the filesystem. Navigate to media/profile_pics in the project directory. A JPEG file with a long hexadecimal name should exist. Note that even though a PNG was uploaded, the saved file is JPEG. The image should be 300×300 pixels (the exact size may be difficult to verify in some editors, but it should appear square and relatively small).
Upload a second image to verify cleanup of old files. Execute the upload with a different test image. After success, check the media/profile_pics directory again. The old image file should be gone, replaced by the new one. Only one profile picture should exist in the directory.
Testing File Size Validation
Attempt to upload a file larger than 5 MB. If a large test image is not available, use any file that exceeds the limit. Execute the upload.
The response returns 400 Bad Request with the message: “File too large. Maximum size is 5 MB”.
Testing Invalid File Validation
Attempt to upload a non-image file such as a text file. Execute the upload.
The response returns 400 Bad Request with the message: “Invalid image file. Please upload a valid image (JPG, PNG, etc.)“.
The backend is working correctly with proper validation and error handling.
Updating the Frontend
Open templates/account.html to add upload functionality. Locate the profile picture section that displays “coming soon” (look at the code in the repo).
The preview container includes an image tag that starts hidden with the d-none Bootstrap class. When the user selects a file, JavaScript shows a preview before upload. The object-fit: cover CSS ensures the preview looks good even if the image is not perfectly square.
The file input uses accept="image/*", which tells the browser to show only image files in the file picker. This is convenience for the user, not security—validation still occurs on the backend.
The upload button starts disabled and enables once a file is selected. Small text notes the maximum file size and supported formats.
Adding Image Preview JavaScript
Scroll to the script section and add the preview functionality after the loadUserData() function:
// Image preview handler
const pictureInput = document.getElementById('pictureInput');
const imagePreview = document.getElementById('imagePreview');
const uploadBtn = document.getElementById('uploadPictureBtn');
pictureInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
imagePreview.src = e.target.result;
imagePreview.classList.remove('d-none');
};
reader.readAsDataURL(file);
uploadBtn.disabled = false;
} else {
imagePreview.classList.add('d-none');
uploadBtn.disabled = true;
}
});This code uses the FileReader API to show a preview of the selected image before upload. When a file is selected, it reads the file as a data URL, sets it as the source of the preview image, shows the preview, and enables the upload button. If the selection is cleared, it hides the preview and disables the button.
Adding Upload Handler
Add the upload form handler:
// Upload Profile Picture Handler
uploadBtn.addEventListener('click', async () => {
const token = getToken();
if (!token) {
window.location.href = '/login';
return;
}
const file = pictureInput.files[0];
if (!file) {
return;
}
// Use FormData for file uploads (not JSON)
const formData = new FormData();
formData.append('file', file);
uploadBtn.disabled = true;
uploadBtn.textContent = 'Uploading...';
try {
const response = await fetch(`/api/users/${currentUserId}/picture`, {
method: 'PATCH',
headers: {
// Don't set Content-Type — browser sets multipart boundary automatically
'Authorization': `Bearer ${token}`,
},
body: formData,
});
if (response.status === 401) {
window.location.href = '/login';
return;
}
if (response.status === 403) {
document.getElementById('errorMessage').textContent =
'You are not authorized to update this profile picture.';
showModal('errorModal');
return;
}
if (response.ok) {
const data = await response.json();
clearUserCache();
document.getElementById('profileImage').src = data.image_path;
pictureInput.value = '';
imagePreview.classList.add('d-none');
document.getElementById('successMessage').textContent =
'Profile picture updated successfully!';
showModal('successModal');
} else {
const error = await response.json();
document.getElementById('errorMessage').textContent = getErrorMessage(error);
showModal('errorModal');
}
} catch (error) {
document.getElementById('errorMessage').textContent =
'Network error. Please check your connection and try again.';
showModal('errorModal');
} finally {
uploadBtn.disabled = pictureInput.files.length === 0;
uploadBtn.textContent = 'Upload';
}
});The handler ensures a token exists, redirecting to login if not. Two critical points about this implementation:
First, FormData is used instead of JSON. For file uploads, FormData is required. The file is appended with the key file to match the endpoint parameter name.
Second, the Content-Type header is not set. When using FormData, the browser automatically sets Content-Type to multipart/form-data with the correct boundary string. Setting it manually would break the upload. The authorization header is still included for authentication.
The rest follows the standard error handling pattern used throughout the series: 401 redirects to login, 403 shows an error message. On success, it clears the user cache (in case user data is cached elsewhere in the application), updates the profile image display, clears the file input, hides the preview, and shows a success message. The finally block re-enables the button and resets its text.
Testing the Complete Feature
Navigate to the login page and authenticate with test credentials. After successful login, navigate to the account page.
Click “Choose File” in the profile picture section and select a test image. The preview appears immediately, showing how the image will look after upload. Click “Upload”. A success message appears: “Profile picture updated successfully”.
Refresh the page to verify persistence. The profile picture remains visible.
Navigate to the homepage to verify the image appears alongside posts. The profile picture should display next to the username for all posts by this user.
Upload a different image to test replacement. The new image should replace the old one both on the account page and in post listings.
Summary
This tutorial implemented complete file upload functionality in FastAPI. Pillow was installed for image processing. Image processing utilities were created to resize images, convert them to JPEG format, and apply other transformations. run_in_threadpool() was used to properly handle CPU-bound work in async endpoints, preventing event loop blocking.
File upload backend endpoints were built with proper validation for file size and image format. The frontend was updated with image preview functionality and proper FormData handling for multipart uploads.
Production considerations: The current implementation saves files to disk in the media folder, which works well for development and small-scale applications. For production at scale, object storage services like Amazon S3 or Google Cloud Storage are typically preferred over local file storage. A Content Delivery Network (CDN) efficiently serves static files. A future tutorial in this series will cover cloud storage integration.
The next tutorial covers pagination. As the application grows, returning all posts at once becomes inefficient. Query parameters will be added to API endpoints so clients can request specific pages of data. The backend will handle pagination logic in database queries, and basic pagination controls will be added to the frontend to request a specific number of posts at a time.