5.9.3. Image Uploads
Table of Contents
Why Image Uploads Are Special
Working with images is different from handling plain text or PDFs. Images are:
- Binary data, not human-readable text.
- Often much larger than other files.
- Frequently displayed back to users in a browser.
- Common targets for abuse, such as malware hidden in images, or very large files.
Your backend must handle image uploads in a way that is:
- Safe.
- Efficient.
- Convenient for your frontend.
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:
<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:
enctype="multipart/form-data"tells the browser to send file content.input type="file"lets the user select a file.name="avatar"becomes the field name in the request.accept="image/*"hints to the browser to show only image files.
On the backend, your framework will usually give you an object that represents the uploaded file. For example, in a Python FastAPI backend:
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:
filename: the name sent by the client.content_type: MIME type such asimage/png.file: a file-like object to read bytes from.
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:
- Fill your disk.
- Slow down processing.
- Allow a user to attack your server by uploading very big files.
You should:
- Set a maximum accepted size, for example 5 MB.
- Reject files that exceed it with an appropriate HTTP status code.
You can enforce size:
- At the web server or reverse proxy level.
- In your application code by checking the length of the data.
Example in FastAPI, checking size manually:
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:
image/jpegimage/pngimage/gifimage/webp
Start small, and allow more later only if needed.
You can use:
- The content type reported by the client.
- A server-side check of the file header or attempt to load the image with an image library.
Using only the client-provided MIME type is not enough for security, but it is a quick first filter.
Simple MIME type validation:
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:
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:
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:
- May contain unsafe characters.
- May conflict with existing files.
- Can leak personal information.
It is better to generate your own storage name.
Safe filenames and paths
A common pattern is to generate a new unique ID:
- A UUID.
- A combination of a user ID and timestamp.
- A random string.
Example using UUID:
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:
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:
- User ID.
- Path or URL of the image.
- Upload timestamp.
- Any other metadata such as image type or size.
Example schema idea:
| Column | Type | Description |
|---|---|---|
| id | UUID | Primary key |
| user_id | UUID | Who owns this image |
| path | text | Storage path or key |
| width | integer | Image width |
| height | integer | Image height |
| content_type | text | MIME type |
| size_bytes | integer | Original file size |
| created_at | timestamp | When it was uploaded |
Resizing, Thumbnails, and Formats
You often do not want to serve the original image to users. Instead, you may want:
- A small avatar.
- A medium image for list pages.
- The original only for download.
This is where image processing comes in.
Creating thumbnails
You can generate a smaller version at upload time:
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:
- The original image.
- The thumbnail.
For example:
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:
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:
- JPEG is not ideal for images with transparency.
- PNG keeps transparency and is lossless.
- WebP can offer good compression but may not be supported everywhere in older environments.
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:
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:
<img src="/images/1234abcd_thumb.jpg" alt="Avatar">Static file servers and CDNs
For larger projects or higher traffic:
- Put uploads on object storage, such as S3.
- Serve them through a CDN.
- Or configure your reverse proxy to serve files directly from disk, bypassing your application process.
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:
- Application receives image, validates, maybe resizes.
- Application stores to disk or S3.
- Application saves only the storage URL or key.
- Frontend uses the URL directly to fetch the image.
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:
<input type="file" name="photos" multiple accept="image/*">In your backend, your framework usually lets you accept a list of files.
Example with FastAPI:
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:
- Apply the same validation rules to each image.
- Decide if you want to fail the whole request if one image is invalid, or skip only invalid ones.
A typical approach is:
- Validate all.
- If any are invalid, abort the request and delete any already stored files for that request.
- Or, for a more forgiving API, accept what is valid and list any errors separately.
Common Patterns for Image Upload APIs
To close this chapter, here is a small, realistic example endpoint that combines several ideas:
- Single avatar upload.
- Size and type checking.
- Storing with a generated name.
- Creating a thumbnail.
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:
- Accept image upload.
- Validate.
- Convert and resize as needed.
- Store in a controlled location with a generated name.
- Return URLs that the frontend can use.
You can adapt this pattern to:
- Product images.
- Gallery uploads.
- Banners and covers.
- Any other image type in your backend.
Views: 8
KAHIBARO