Skip to content
OVEX TECH
Education & E-Learning

Upload and Process Images with Python FastAPI

Upload and Process Images with Python FastAPI

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

  1. 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 pillow or uvicorn add pillow

    Note: If you installed FastAPI with standard extras, python-multipart, which is required for file uploads, is likely already included.

  2. 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, ImageOps
    

    Profile Pictures Directory:

    Define a constant for the directory where profile pictures will be stored. Using pathlib provides 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 filename
    

    Delete 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()
    
  3. 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 default
    
  4. Create Upload Endpoint in FastAPI

    In your user router (e.g., routers/users.py), define a new PATCH endpoint for uploading profile pictures. This endpoint needs to handle UploadFile from 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_threadpool for 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 user
    

    Explanation:

    • The endpoint uses Depends for authentication and database access.
    • UploadFile is 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_threadpool is crucial for offloading the CPU-intensive process_profile_image function.
    • Error handling for invalid images (e.g., UnidentifiedImageError from 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.
  5. 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 user
    
  6. Update 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"}
    
  7. Secure the User Update Schema

    In your schemas.py, remove image_file from 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 LINE
    

    Also, remove the handling of image_file from your corresponding user update endpoint in the router.

  8. 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 FormData to send file uploads.
    • Append the file to FormData with a key matching the FastAPI endpoint’s parameter name (e.g., ‘file’).
    • Crucially, do not manually set the Content-Type header when using FormData; the browser handles it correctly with the appropriate boundary.
    • Include the Authorization header if your endpoint requires authentication.
    • Handle success and error responses appropriately, updating the UI and providing feedback to the user.
  9. 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)

Leave a Reply

Your email address will not be published. Required fields are marked *

Written by

John Digweed

1,380 articles

Life-long learner.