Oct 23, 20265 min read

How to Add File Uploads and S3 Storage to FastAPI

Storing uploaded files from a FastAPI app in S3 or Google Cloud Storage with Encore, boto3 or aioboto3, including presigned URLs, local storage, tests and production buckets.

FastAPI can receive files through UploadFile or a raw request body, and it leaves storing them to you. Files belong in object storage rather than on the container disk, which disappears on the next deploy, so adding uploads means choosing a client, creating buckets for every environment, deciding whether bytes pass through the API or go straight to the bucket and giving developers a bucket to test against. This guide covers Encore, boto3 and aioboto3.

In the 2024 Python Developers Survey by the PSF and JetBrains, 38% of respondents used FastAPI, up from 21% in 2021, and 35% used Django.

What you need to decide:

  • Whether files go through the API or from the browser to the bucket with a presigned URL.
  • Which client talks to the bucket and whether it blocks the event loop.
  • Which buckets are private and which serve public files.
  • Where the bucket comes from locally and in tests.

Object storage options for FastAPI at a glance

CapabilityEncoreboto3aioboto3
API styleAsync methods on objects.BucketSynchronous, run in a thread poolAsync wrapper around boto3
Presigned URLssigned_upload_url and signed_download_urlgenerate_presigned_urlgenerate_presigned_url
Local bucketencore run provides oneYou run an S3-compatible server or use a dev bucketSame as boto3
Production bucketProvisioned by Encore in your AWS or GCP accountYou create it and its IAM policySame as boto3

Encore: add buckets to your existing FastAPI app

Encore

Encore combines the bucket declaration in your FastAPI code with the storage behind it, a local backend under encore run and S3 or Cloud Storage when deployed. The SDK adds infrastructure to the app you have and leaves routing and validation to FastAPI.

from contextlib import asynccontextmanager import encore from encore.storage import objects from fastapi import FastAPI, HTTPException, Request # Encore finds this declaration and provisions the bucket. avatars = objects.Bucket("avatars") @asynccontextmanager async def lifespan(app: FastAPI): await encore.start() yield app = FastAPI(lifespan=lifespan) @app.put("/avatars/{user_id}") async def upload_avatar(user_id: int, request: Request) -> dict: data = await request.body() attrs = await avatars.upload(f"{user_id}.png", data, content_type="image/png") return {"key": attrs.name, "size": attrs.size} @app.post("/avatars/{user_id}/upload-url") async def avatar_upload_url(user_id: int) -> dict: # The browser PUTs the file to this URL; it expires after ten minutes. url = await avatars.signed_upload_url(f"{user_id}.png", ttl=600) return {"url": url} @app.get("/avatars/{user_id}/url") async def avatar_url(user_id: int) -> dict: key = f"{user_id}.png" if not await avatars.exists(key): raise HTTPException(status_code=404, detail="no avatar") return {"url": await avatars.signed_download_url(key, ttl=3600)}

A bucket meant for public files is declared with objects.Bucket("public-uploads", public=True), and public_url(name) then returns a URL for an object without signing. Versioned buckets keep earlier versions with versioned=True, and the SDK raises objects.ObjectNotFound and objects.PreconditionFailed, which the Encore FastAPI example maps to 404 and 412 responses with exception handlers.

encore run serves the declared buckets locally, so uploads work on a laptop with no AWS account. On deploy, Encore provisions the infrastructure in your AWS or GCP account, including the declared buckets. An agent asked to add avatar uploads can declare the bucket in the same change as the route and exercise it locally, and cloud access sits with Encore's IAM role, not the agent.

Without Encore

Without Encore you create a bucket per environment, write an IAM policy that lets the service read and write it, configure CORS on the bucket for browser uploads and give developers either a shared dev bucket or a local S3-compatible server. The boto3 code below is what then runs in the app.

Try Encore →

boto3

boto3 is the official AWS SDK for Python, and since it is synchronous in an async route the upload runs in a thread pool to keep the event loop free:

import os import boto3 from fastapi import FastAPI, UploadFile from starlette.concurrency import run_in_threadpool s3 = boto3.client("s3") BUCKET = os.environ["AVATAR_BUCKET"] app = FastAPI() @app.put("/avatars/{user_id}") async def upload_avatar(user_id: int, file: UploadFile) -> dict: key = f"{user_id}.png" await run_in_threadpool( s3.upload_fileobj, file.file, BUCKET, key, ExtraArgs={"ContentType": file.content_type or "application/octet-stream"}, ) return {"key": key} @app.post("/avatars/{user_id}/upload-url") def avatar_upload_url(user_id: int) -> dict: url = s3.generate_presigned_url( "put_object", Params={"Bucket": BUCKET, "Key": f"{user_id}.png"}, ExpiresIn=600, ) return {"url": url}

UploadFile needs python-multipart installed to parse form uploads.

Good to know

A single PUT to S3 can carry up to 5 GB, and upload_fileobj switches to multipart uploads for large files on its own. Presigned URLs created with the CLI last at most 7 days, and anyone holding one can use it until then, so short expiries are the usual choice.

aioboto3

aioboto3 wraps boto3 with async clients, which removes the thread pool calls at the cost of creating clients inside an async context:

import aioboto3 session = aioboto3.Session() async def put_avatar(bucket: str, key: str, data: bytes) -> None: async with session.client("s3") as s3: await s3.put_object(Bucket=bucket, Key=key, Body=data, ContentType="image/png")

Good to know

Opening a client per call is simple and slower under load, so busy apps open one client in the lifespan and keep it on app.state.

Buckets in production

New S3 buckets block public access by default, so serving public files means a deliberate bucket policy or a CDN in front, and private files are served through presigned download URLs. With Encore, buckets are declared in code and Encore provisions them in your own account.

How to choose

  • Encore if you want buckets working locally and created in your AWS or GCP account from your FastAPI code.
  • boto3 if you already manage the buckets and IAM and want the official client.
  • aioboto3 if you want boto3 without thread pools in async routes.

Python support in Encore is in beta, and you can sign up for access to try it with your FastAPI app.

Frequently asked questions

How do I upload a file to S3 from FastAPI?

Accept the file with UploadFile or the raw request body, then write it to a bucket. With boto3 you call upload_fileobj in a thread pool because boto3 is synchronous. With Encore you declare objects.Bucket in your code and await bucket.upload(name, data), and encore run serves the bucket locally.

Should FastAPI handle uploads or should the browser upload directly to S3?

Small files can go through the API. Large files are better sent straight from the browser to the bucket with a presigned URL that the API issues, which keeps the bytes off your servers and avoids request size and timeout limits.

How do I generate a presigned S3 upload URL in FastAPI?

With boto3, call generate_presigned_url for put_object with the bucket, key and expiry. With Encore, await bucket.signed_upload_url(name, ttl=600). Both return a URL anyone holding it can upload to until it expires.

How do I test S3 uploads locally?

Point boto3 at an S3-compatible server such as MinIO running on your machine, or mock the client. With Encore, encore run and encore test give the declared buckets a local backend, so the same code runs without AWS.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders