Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 098 / 100

Uploading to S3 with boto3 in Python Backend Development

Store files the scalable way.

Expona Admin8 min read

Why S3 Matters in Production

File storage is one of those problems that looks simple until you're running it in production. You could store uploads on disk, but then you're managing capacity, backups, and serving files from compute instances. Uploading to S3 with boto3 in Python backend development shifts that burden to AWS, giving you scalable, durable object storage that integrates naturally with your infrastructure. We're going to walk through the patterns that actually work at scale: streaming uploads, pre-signed URLs for direct browser access, and the headers that keep your costs and user experience in check.

Use upload_fileobj for Streams

The most common mistake we see is loading entire files into memory before uploading. When you're handling user uploads—images from mobile apps, CSV exports from reports, video files—you can't afford to buffer everything in RAM. That's where upload_fileobj comes in. It reads from a file-like object in chunks, streaming directly to S3 without materializing the full payload in memory.

Here's the pattern we use for typical file uploads in production:

python
import boto3
from io import BytesIO

s3_client = boto3.client('s3', region_name='us-east-1')

def upload_user_file(bucket_name, key, file_stream):
    """Upload a file stream to S3 without loading it into memory."""
    s3_client.upload_fileobj(
        file_stream,
        bucket_name,
        key,
        ExtraArgs={
            'ContentType': 'application/octet-stream',
            'ServerSideEncryption': 'AES256'
        }
    )
    return f's3://{bucket_name}/{key}'

# From a Flask request:
from flask import request

@app.route('/upload', methods=['POST'])
def handle_upload():
    file = request.files['file']
    key = f'uploads/{file.filename}'
    upload_user_file('my-bucket', key, file.stream)
    return {'status': 'uploaded'}, 201

upload_fileobj accepts any file-like object—file handles, BytesIO objects, or streaming request bodies. It handles multipart uploads automatically for large files, retrying on transient network failures. The ExtraArgs dictionary lets you set metadata and encryption at upload time, which we'll cover in more detail below.

If you need progress tracking for long uploads, use the Callback parameter:

python
def upload_with_progress(bucket_name, key, file_stream, total_size):
    """Upload a file with progress callback."""
    def progress_callback(bytes_transferred):
        percent = 100 * (bytes_transferred / total_size)
        print(f'Upload progress: {percent:.1f}%')
    
    s3_client.upload_fileobj(
        file_stream,
        bucket_name,
        key,
        Callback=progress_callback,
        ExtraArgs={'ContentType': 'application/pdf'}
    )

This is particularly useful when integrating with background job queues—you can send progress updates to WebSocket clients or log them for debugging.

Pre-Signed URLs for Direct Browser Uploads

When you need users to upload files directly from their browser without routing through your backend, pre-signed URLs are your answer. These are time-limited, cryptographically signed URLs that grant temporary write access to a specific S3 location. The user's browser writes directly to S3, bypassing your servers entirely. This reduces load, latency, and transfer costs significantly.

Generate a pre-signed URL on your backend, send it to the client, and they upload directly:

python
def generate_upload_url(bucket_name, key, expiration=3600):
    """Generate a pre-signed URL for direct S3 uploads."""
    url = s3_client.generate_presigned_url(
        'put_object',
        Params={
            'Bucket': bucket_name,
            'Key': key,
            'ContentType': 'image/jpeg'
        },
        ExpiresIn=expiration
    )
    return url

# API endpoint:
@app.route('/upload-url', methods=['POST'])
def get_upload_url():
    user_id = current_user.id
    filename = request.json['filename']
    key = f'user-uploads/{user_id}/{filename}'
    
    url = generate_upload_url('my-bucket', key, expiration=900)
    return {'upload_url': url}, 200

On the client side, a simple JavaScript fetch uploads to the signed URL:

python
# JavaScript (for context):
# const response = await fetch(uploadUrl, {
#   method: 'PUT',
#   body: file,
#   headers: {'Content-Type': 'image/jpeg'}
# })

Set the expiration time based on your use case. A mobile app uploading a large video might need 30 minutes, while a quick form submission needs only 15 minutes. Always use the minimum expiration that makes sense—it's a security boundary.

For more control over what the client can upload, include conditions in the pre-signed URL:

python
def generate_restricted_upload_url(bucket_name, user_id, max_size_bytes=10_000_000):
    """Generate a pre-signed URL with upload restrictions."""
    key = f'user-uploads/{user_id}/{uuid.uuid4()}.jpg'
    
    url = s3_client.generate_presigned_post(
        bucket_name,
        key,
        Fields={
            'acl': 'private',
            'Content-Type': 'image/jpeg'
        },
        Conditions=[
            ['content-length-range', 0, max_size_bytes]
        ],
        ExpiresIn=900
    )
    return url  # Returns {'url': '...', 'fields': {...}}

Use generate_presigned_post when you need to send HTML form data with conditions. This is more secure than generate_presigned_url for browser uploads because it lets you enforce constraints server-side before the client even attempts the upload.

Set Content Type and Cache Headers

S3 stores objects as blobs. Without proper metadata, browsers treat everything as a download, and your CloudFront cache won't work efficiently. We always set ContentType at upload time, and we use cache headers strategically based on whether the object is versioned or mutable.

For immutable assets (images with hashes in the filename, CSS bundles), use aggressive caching:

python
def upload_static_asset(bucket_name, key, file_stream, content_type):
    """Upload a static asset with long-lived cache headers."""
    s3_client.upload_fileobj(
        file_stream,
        bucket_name,
        key,
        ExtraArgs={
            'ContentType': content_type,
            'CacheControl': 'public, max-age=31536000, immutable',  # 1 year
            'Metadata': {
                'uploaded-by': 'backend-service',
                'upload-date': datetime.now().isoformat()
            }
        }
    )

For user-generated content that might change, use shorter cache times or cache-busting headers:

python
def upload_user_profile_image(bucket_name, user_id, file_stream):
    """Upload a user profile image with moderate cache headers."""
    key = f'profiles/{user_id}/avatar.jpg'
    
    s3_client.upload_fileobj(
        file_stream,
        bucket_name,
        key,
        ExtraArgs={
            'ContentType': 'image/jpeg',
            'CacheControl': 'public, max-age=3600',  # 1 hour
            'Metadata': {
                'user-id': str(user_id),
                'updated-at': datetime.now().isoformat()
            }
        }
    )

These headers control both S3's internal caching behavior and how CloudFront (if you're using it in front of S3) caches objects. For content you serve through CloudFront, CacheControl is critical—without it, you'll see higher origin bandwidth and slower user experience.

Always set ContentType based on file extension or MIME type detection. Incorrect content types cause images to display as downloads and JavaScript to fail with CORS errors. Use the python-magic library for reliable MIME type detection if you're accepting arbitrary uploads:

python
import magic

def upload_with_mime_detection(bucket_name, key, file_stream):
    """Upload a file with automatic MIME type detection."""
    file_stream.seek(0)
    mime_type = magic.from_buffer(file_stream.read(1024), mime=True)
    file_stream.seek(0)
    
    s3_client.upload_fileobj(
        file_stream,
        bucket_name,
        key,
        ExtraArgs={'ContentType': mime_type}
    )

Never Make Buckets Public by Default

This is the non-negotiable rule. Every production incident we've seen around S3 starts with accidentally public buckets. AWS now blocks public access by default, but the default isn't enough. We follow these practices:

  • Use BlockPublicAccess on every bucket. Even if you intend some objects to be public, enforce it through pre-signed URLs or CloudFront, never bucket policies.
  • Set object ACL to private on upload. Never use public-read unless you have a very specific reason and it's documented.
  • Use IAM roles and bucket policies to control access, not ACLs. ACLs are a legacy access control mechanism and harder to audit.
  • Enable versioning and MFA delete for critical buckets. This protects against accidental or malicious deletion.
  • Enable S3 Block Public Access at the account level, not just the bucket level.

Here's a bucket policy that allows your backend service to read and write, but never makes anything public:

python
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::ACCOUNT_ID:role/backend-service-role"
      },
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::my-bucket/*"
    },
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ],
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        }
      }
    }
  ]
}

This policy grants your service full access to the bucket while denying any unencrypted (non-HTTPS) operations. For serving content to users, route everything through CloudFront with Origin Access Identity (OAI) or Origin Access Control (OAC)—never make S3 publicly readable.

Common Mistakes

Loading entire files into memory before upload: We mentioned this, but it's worth repeating. Don't do s3_client.put_object(Body=file.read()). Use upload_fileobj or the resource interface with streaming.

Forgetting to set ContentType: Your images display as downloads, your JavaScript fails silently. Always set it, always validate the MIME type.

Using public ACLs for "public" content: Use pre-signed URLs or CloudFront instead. ACLs are harder to audit and easier to misconfigure. Pre-signed URLs give you control and time-limits built in.

Long expiration times on pre-signed URLs: If a pre-signed URL leaks, anyone can use it until expiration. Keep expirations short—15 minutes for most uploads, a few hours for long-running batch jobs. Don't set them to days or weeks.

Not retrying failed uploads: Network partitions happen. boto3's upload_fileobj retries automatically, but if you're building custom upload logic, add exponential backoff and retry logic.

Key Takeaways

  • Use upload_fileobj to stream files to S3 without loading them into memory. It handles multipart uploads and retries transparently.
  • Generate pre-signed URLs for direct browser uploads, reducing backend load and latency. Keep expiration times short and use conditions to enforce constraints.
  • Always set ContentType at upload time. Use cache headers strategically: aggressive caching for immutable assets, shorter times for user-generated content.
  • Never make buckets public. Use Block Public Access, enforce private ACLs, control access through IAM roles and bucket policies, and serve public content through CloudFront with pre-signed URLs.
  • Test your upload flow with different file sizes and types. Use boto3's callback mechanism to track progress for long uploads.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles