How to Upload and Process Images with Python FastAPI
This tutorial will guide you through implementing file upload functionality in your Python FastAPI application, specifically focusing on handling profile pictures. You’ll learn how to accept image uploads, process them using the Pillow library for resizing and format conversion, and securely store them on disk with unique filenames. We’ll also cover validation, error handling, and integrating this feature into your existing user profile management.
Prerequisites
- A working FastAPI application with user registration, login, and profile management features.
- Basic understanding of Python, FastAPI, asynchronous programming, and Pydantic settings.
- Familiarity with handling form data and file uploads in web applications.
Steps
Install Pillow
Pillow is a powerful Python imaging library essential for image manipulation. Install it using pip or your preferred package manager.
Command:
pip install pilloworuvicorn add pillowNote: If you installed FastAPI with standard extras,
python-multipart, which is required for file uploads, is likely already included.Create Image Utility Functions
Create a new Python file (e.g.,
image_utils.py) to house reusable image processing and utility functions. This keeps your router files clean.Imports:
Include necessary imports for unique filenames, in-memory byte operations, path manipulation, and image processing:
from uuid import uuid4 from io import BytesIO from pathlib import Path from PIL import Image, ImageOpsProfile Pictures Directory:
Define a constant for the directory where profile pictures will be stored. Using
pathlibprovides an object-oriented approach to file paths.PROFILE_PICS_DIR = Path('media/profile_pics') # Ensure the directory exists (optional, can be handled in the upload endpoint) PROFILE_PICS_DIR.mkdir(parents=True, exist_ok=True)Process Profile Image Function:
This function handles the core image processing. It opens the image, fixes orientation, resizes and crops to 300×300 pixels, converts to RGB, generates a unique filename, and saves it as a JPEG.
def process_profile_image(image_bytes: bytes) -> str: """Processes an image, resizes it, and saves it as a JPEG. Returns the filename of the saved image. """ try: original = Image.open(BytesIO(image_bytes)) except Exception as e: # Catching a broad exception here, but PIL's UnidentifiedImageError is more specific raise ValueError("Invalid image file") from e # Fix orientation issues original = ImageOps.exif_transpose(original) # Resize and crop to 300x300 image = original.copy() image.thumbnail((300, 300), Image.Resampling.LANCZOS) image = ImageOps.fit(image, (300, 300), Image.Resampling.LANCZOS, centering=(0.5, 0.5)) # Convert to RGB if needed if image.mode != 'RGB': image = image.convert('RGB') # Generate a unique filename filename = f"{uuid4()}.jpg" filepath = PROFILE_PICS_DIR / filename # Save the image image.save(filepath, 'JPEG', quality=85, optimize=True) return filenameDelete Profile Image Function:
A helper function to safely delete an existing profile picture file.
def delete_profile_image(filename: str | None): """Deletes a profile image file if it exists. """ if filename is None: return file_path = PROFILE_PICS_DIR / filename if file_path.exists(): file_path.unlink()Configure Maximum Upload Size
Add a configuration setting for the maximum allowed file size using Pydantic settings. This is crucial for security and resource management.
In your
config.py(or similar settings file), add:from pydantic_settings import BaseSettings class Settings(BaseSettings): # ... other settings ... MAX_UPLOAD_SIZE_BYTES: int = 5 * 1024 * 1024 # 5MB defaultCreate Upload Endpoint in FastAPI
In your user router (e.g.,
routers/users.py), define a newPATCHendpoint for uploading profile pictures. This endpoint needs to handleUploadFilefrom FastAPI.Imports:
Add the following imports to your user router:
from fastapi import UploadFile, HTTPException, Depends, APIRouter from starlette.concurrency import run_in_threadpool from PIL import UnidentifiedImageError from .image_utils import process_profile_image, delete_profile_image from ..config import settings # Assuming your settings are here # ... other imports ...Endpoint Definition:
Create the endpoint, ensuring it’s asynchronous and uses
run_in_threadpoolfor the CPU-bound image processing.@router.patch("/users/me/profile-picture", response_model=UserResponse) # Adjust response_model as needed async def upload_profile_picture( user_id: int = Depends(get_current_user_id), # Assuming a dependency to get current user ID db: Session = Depends(get_db), # Assuming a DB dependency file: UploadFile = File(...) ): # Authorisation check (ensure user can only update their own profile) user = db.query(User).filter(User.id == user_id).first() # Fetch user if not user: raise HTTPException(status_code=404, detail="User not found") contents = await file.read() if len(contents) > settings.MAX_UPLOAD_SIZE_BYTES: raise HTTPException(status_code=400, detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE_BYTES / (1024*1024):.0f}MB") old_image_filename = user.image_file # Store old filename for deletion later try: # Process image in a thread pool to avoid blocking the event loop new_image_filename = await run_in_threadpool(process_profile_image, contents) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) # Update user's image file in the database user.image_file = new_image_filename db.commit() db.refresh(user) # Delete the old image file after successful DB commit await run_in_threadpool(delete_profile_image, old_image_filename) return userExplanation:
- The endpoint uses
Dependsfor authentication and database access. UploadFileis used for the file parameter.- The file is read using
await file.read(). - A size check is performed against
settings.MAX_UPLOAD_SIZE_BYTES. run_in_threadpoolis crucial for offloading the CPU-intensiveprocess_profile_imagefunction.- Error handling for invalid images (e.g.,
UnidentifiedImageErrorfrom Pillow) is included. - The database is updated with the new filename.
- The old image file is deleted after a successful database commit to prevent data loss.
- The endpoint uses
Create Delete Endpoint
Add an endpoint to allow users to remove their profile picture.
@router.delete("/users/me/profile-picture", response_model=UserResponse) # Adjust response_model as needed async def delete_profile_picture( user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db) ): user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") if user.image_file is None: raise HTTPException(status_code=400, detail="User does not have a profile picture to delete") old_image_filename = user.image_file user.image_file = None db.commit() db.refresh(user) # Delete the old image file after successful DB commit await run_in_threadpool(delete_profile_image, old_image_filename) return userUpdate Account Deletion
Modify your existing user deletion endpoint to also remove the associated profile picture file from disk.
In your user deletion endpoint (e.g.,
routers/users.py):# Inside the delete_user endpoint user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") old_image_filename = user.image_file # Capture filename before deletion db.delete(user) db.commit() # Delete the image file after successful DB commit if old_image_filename: await run_in_threadpool(delete_profile_image, old_image_filename) return {"message": "User deleted successfully"}Secure the User Update Schema
In your
schemas.py, removeimage_filefrom any user update schemas to prevent direct manipulation of filenames via the general update endpoint. Profile pictures should only be managed through the dedicated upload/delete endpoints.In
schemas.py(e.g.,schemas/user.py):# Example UserUpdate schema - remove image_file class UserUpdate(BaseModel): username: Optional[str] = None email: Optional[str] = None # image_file: Optional[str] = None <-- REMOVE THIS LINEAlso, remove the handling of
image_filefrom your corresponding user update endpoint in the router.Update Frontend (HTML and JavaScript)
Modify your account page’s HTML template to include a form for file uploads. Add JavaScript to handle file selection, display a preview, and send the file using
FormData.HTML (e.g.,
templates/account.html):<div class="profile-picture-section"> <h3>Profile Picture</h3> <div id="profile-picture-preview-container" class="hidden"> <img id="profile-picture-preview" src="#" alt="Profile Picture Preview" class="object-cover"> </div> <input type="file" id="profile-picture-input" accept="image/*"> <button id="upload-profile-picture-button" disabled>Upload Picture</button> <p class="text-sm text-gray-500">Supports JPG, PNG. Max 5MB.</p> </div>JavaScript (within your template’s script tags):
// --- Image Preview Logic --- const profilePictureInput = document.getElementById('profile-picture-input'); const profilePicturePreviewContainer = document.getElementById('profile-picture-preview-container'); const profilePicturePreview = document.getElementById('profile-picture-preview'); const uploadProfilePictureButton = document.getElementById('upload-profile-picture-button'); profilePictureInput.addEventListener('change', function(event) { const file = event.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = function(e) { profilePicturePreview.src = e.target.result; profilePicturePreviewContainer.classList.remove('hidden'); uploadProfilePictureButton.disabled = false; } reader.readAsDataURL(file); } else { profilePicturePreview.src = '#'; profilePicturePreviewContainer.classList.add('hidden'); uploadProfilePictureButton.disabled = true; } }); // --- Upload Handler --- uploadProfilePictureButton.addEventListener('click', async function() { const file = profilePictureInput.files[0]; if (!file) return; const formData = new FormData(); formData.append('file', file); // 'file' must match the endpoint parameter name try { const token = localStorage.getItem('access_token'); if (!token) { window.location.href = '/login'; return; } const response = await fetch('/api/users/me/profile-picture', { // Adjust API endpoint if needed method: 'PATCH', headers: { 'Authorization': `Bearer ${token}` // IMPORTANT: Do NOT set 'Content-Type'. Browser sets it automatically for FormData. }, body: formData }); if (response.ok) { const result = await response.json(); // Update UI, clear form, show success message alert('Profile picture updated successfully!'); // Potentially refresh user data or update the displayed image directly profilePictureInput.value = ''; // Clear the input profilePicturePreviewContainer.classList.add('hidden'); uploadProfilePictureButton.disabled = true; // Update the displayed image if you have a direct reference const userImageElement = document.querySelector('.user-profile-image'); // Example selector if (userImageElement) { userImageElement.src = result.image_path; // Assuming result contains image_path } } else if (response.status === 401) { window.location.href = '/login'; } else { const errorData = await response.json(); alert(`Error: ${errorData.detail || 'Failed to upload image'}`); } } catch (error) { alert('An unexpected error occurred.'); console.error('Upload error:', error); } }); // Add handler for delete button if implemented // ...Key Frontend Points:
- Use
FormDatato send file uploads. - Append the file to
FormDatawith a key matching the FastAPI endpoint’s parameter name (e.g., ‘file’). - Crucially, do not manually set the
Content-Typeheader when usingFormData; the browser handles it correctly with the appropriate boundary. - Include the
Authorizationheader if your endpoint requires authentication. - Handle success and error responses appropriately, updating the UI and providing feedback to the user.
- Use
Test Thoroughly
Run your FastAPI server and test the file upload functionality through the frontend. Verify that images are uploaded, processed correctly (resized, converted to JPEG), stored with unique names, and that old images are deleted. Test error cases such as uploading files that are too large or non-image files.
Production Considerations
For development and small-scale applications, storing files locally on disk is acceptable. However, for production environments, especially at scale, consider using dedicated object storage services like Amazon S3 or Google Cloud Storage. These services offer better scalability, durability, and often integrate with Content Delivery Networks (CDNs) for efficient file serving.
Source: Python FastAPI Tutorial (Part 12): File Uploads – Image Processing, Validation, and Storage (YouTube)