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:
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 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.
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.
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 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")
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.
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.
Python support in Encore is in beta, and you can sign up for access to try it with your FastAPI app.
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.
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.
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.
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.