KAHIBARO
Discord Login Register

5.9.3. Image Uploads

Why Image Uploads Are Special

Working with images is different from handling plain text or PDFs. Images are:

Your backend must handle image uploads in a way that is:

In this chapter, you will see how to accept image uploads, validate them, and prepare them for storage and later use.

Accepting Image Uploads in an API

Most image uploads in web backends arrive as part of an HTTP request using multipart/form-data, often from an HTML form or JavaScript client.

A typical HTML form for image upload might look like:

html
<form action="/upload-avatar" method="post" enctype="multipart/form-data">
  <input type="file" name="avatar" accept="image/*">
  <button type="submit">Upload</button>
</form>

Key points:

On the backend, your framework will usually give you an object that represents the uploaded file. For example, in a Python FastAPI backend:

python
from fastapi import FastAPI, File, UploadFile
app = FastAPI()
@app.post("/upload-avatar")
async def upload_avatar(avatar: UploadFile = File(...)):
    return {"filename": avatar.filename, "content_type": avatar.content_type}

The UploadFile object provides:

You will later combine this with validation and storage.

Validating Uploaded Images

You should always validate image uploads before storing or processing them.

There are several things to check.

File size limits

Large images can:

You should:

You can enforce size:

Example in FastAPI, checking size manually:

python
from fastapi import FastAPI, UploadFile, File, HTTPException
MAX_IMAGE_SIZE = 5 * 1024 * 1024  # 5 MB
app = FastAPI()
@app.post("/upload-photo")
async def upload_photo(photo: UploadFile = File(...)):
    content = await photo.read()
    if len(content) > MAX_IMAGE_SIZE:
        raise HTTPException(status_code=413, detail="Image too large")
    # reset file pointer if you will reuse photo.file
    await photo.seek(0)
    return {"size": len(content)}

Always enforce a maximum file size for image uploads, otherwise users can fill your storage or cause memory issues.

Allowed file types

You should restrict image types to a safe, known list, such as:

Start small, and allow more later only if needed.

You can use:

Using only the client-provided MIME type is not enough for security, but it is a quick first filter.

Simple MIME type validation:

python
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png"}
if photo.content_type not in ALLOWED_IMAGE_TYPES:
    raise HTTPException(status_code=400, detail="Unsupported image type")

To be more confident, use an image library such as Pillow:

python
from PIL import Image
from io import BytesIO
content = await photo.read()
try:
    image = Image.open(BytesIO(content))
    image.verify()  # basic structural check
except Exception:
    raise HTTPException(status_code=400, detail="Invalid image file")

Image dimensions

Sometimes you want to limit image width and height, for example to avoid extremely large resolutions.

You can open the image and inspect its size:

python
from PIL import Image
from io import BytesIO
MAX_WIDTH = 4000
MAX_HEIGHT = 4000
content = await photo.read()
image = Image.open(BytesIO(content))
width, height = image.size
if width > MAX_WIDTH or height > MAX_HEIGHT:
    raise HTTPException(status_code=400, detail="Image dimensions too large")

Later you will see that you can also resize the image instead of rejecting it.

Renaming and Storing Images

You rarely want to store user uploads with the original filename. User filenames:

It is better to generate your own storage name.

Safe filenames and paths

A common pattern is to generate a new unique ID:

Example using UUID:

python
import uuid
from pathlib import Path
UPLOAD_DIR = Path("uploads")
def generate_image_filename(original_filename: str) -> str:
    ext = original_filename.split(".")[-1].lower()
    unique_id = uuid.uuid4().hex
    return f"{unique_id}.{ext}"

You should also decide where on disk to store the file:

python
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
new_name = generate_image_filename(photo.filename)
destination = UPLOAD_DIR / new_name
with destination.open("wb") as f:
    f.write(content)

You can keep a record in the database that links:

Example schema idea:

ColumnTypeDescription
idUUIDPrimary key
user_idUUIDWho owns this image
pathtextStorage path or key
widthintegerImage width
heightintegerImage height
content_typetextMIME type
size_bytesintegerOriginal file size
created_attimestampWhen it was uploaded

Resizing, Thumbnails, and Formats

You often do not want to serve the original image to users. Instead, you may want:

This is where image processing comes in.

Creating thumbnails

You can generate a smaller version at upload time:

python
from PIL import Image
from io import BytesIO
THUMBNAIL_SIZE = (256, 256)  # width, height
def create_thumbnail(image_bytes: bytes) -> bytes:
    image = Image.open(BytesIO(image_bytes))
    image.thumbnail(THUMBNAIL_SIZE)
    out = BytesIO()
    image.save(out, format="JPEG", quality=85)
    return out.getvalue()

You can store both:

For example:

python
original_bytes = content
thumb_bytes = create_thumbnail(original_bytes)
original_path = UPLOAD_DIR / f"{unique_id}_orig.jpg"
thumb_path = UPLOAD_DIR / f"{unique_id}_thumb.jpg"
original_path.write_bytes(original_bytes)
thumb_path.write_bytes(thumb_bytes)

You might also choose to discard the original or resize it to a reasonable maximum.

Converting image formats

You can standardize all images to a single format, such as JPEG or WebP.

Example, always saving as JPEG:

python
def save_as_jpeg(image_bytes: bytes, dest_path: Path):
    image = Image.open(BytesIO(image_bytes))
    rgb_image = image.convert("RGB")
    rgb_image.save(dest_path, format="JPEG", quality=85)

This can simplify your frontend, which then expects one consistent format.

However, remember:

Serving Uploaded Images

After storing images, you need a way to serve them back to clients.

There are several options.

Serving from your backend

Small projects can serve images directly from the backend application.

Example in FastAPI:

python
from fastapi.responses import FileResponse
from pathlib import Path
UPLOAD_DIR = Path("uploads")
@app.get("/images/{image_name}")
async def get_image(image_name: str):
    file_path = UPLOAD_DIR / image_name
    if not file_path.is_file():
        raise HTTPException(status_code=404, detail="Image not found")
    return FileResponse(file_path)

Then the frontend uses URLs like:

html
<img src="/images/1234abcd_thumb.jpg" alt="Avatar">

Static file servers and CDNs

For larger projects or higher traffic:

You already saw the basics of static files and possibly S3-compatible storage in other chapters, so here you only need to connect them with image uploads:

This keeps your backend free from frequently repeated image transfers.

Multiple Image Uploads

Sometimes you want to accept more than one image in a single request, for example uploading several product photos at once.

In HTML:

html
<input type="file" name="photos" multiple accept="image/*">

In your backend, your framework usually lets you accept a list of files.

Example with FastAPI:

python
from typing import List
from fastapi import UploadFile, File
@app.post("/products/{product_id}/photos")
async def upload_photos(
    product_id: int,
    photos: List[UploadFile] = File(...)
):
    results = []
    for photo in photos:
        # validate and store each image
        results.append({"filename": photo.filename})
    return {"uploaded": results}

You should:

A typical approach is:

Common Patterns for Image Upload APIs

To close this chapter, here is a small, realistic example endpoint that combines several ideas:

python
import uuid
from pathlib import Path
from io import BytesIO
from fastapi import FastAPI, UploadFile, File, HTTPException
from PIL import Image
app = FastAPI()
UPLOAD_DIR = Path("uploads")
THUMB_DIR = UPLOAD_DIR / "thumbs"
MAX_SIZE_BYTES = 5 * 1024 * 1024  # 5 MB
ALLOWED_TYPES = {"image/jpeg", "image/png"}
THUMB_SIZE = (256, 256)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
THUMB_DIR.mkdir(parents=True, exist_ok=True)
def generate_name(ext: str) -> str:
    return f"{uuid.uuid4().hex}.{ext}"
def create_thumbnail(image_bytes: bytes) -> bytes:
    image = Image.open(BytesIO(image_bytes))
    image.thumbnail(THUMB_SIZE)
    out = BytesIO()
    image.save(out, format="JPEG", quality=85)
    return out.getvalue()
@app.post("/users/{user_id}/avatar")
async def upload_avatar(user_id: int, avatar: UploadFile = File(...)):
    if avatar.content_type not in ALLOWED_TYPES:
        raise HTTPException(status_code=400, detail="Unsupported image type")
    content = await avatar.read()
    if len(content) > MAX_SIZE_BYTES:
        raise HTTPException(status_code=413, detail="Image too large")
    try:
        image = Image.open(BytesIO(content))
        image.verify()
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid image file")
    ext = "jpg"  # decide to store as JPEG
    original_name = generate_name(ext)
    thumb_name = generate_name(ext)
    original_path = UPLOAD_DIR / original_name
    thumb_path = THUMB_DIR / thumb_name
    # Save original as JPEG, converting if needed
    image = Image.open(BytesIO(content)).convert("RGB")
    image.save(original_path, format="JPEG", quality=90)
    thumb_bytes = create_thumbnail(content)
    thumb_path.write_bytes(thumb_bytes)
    # Normally you would save these paths in the database
    return {
        "user_id": user_id,
        "avatar_url": f"/images/{original_name}",
        "avatar_thumb_url": f"/images/thumbs/{thumb_name}",
    }

This gives you a solid pattern:

You can adapt this pattern to:

Views: 8

Comments

Please login to add a comment.

Don't have an account? Register now!