Module 5: AWS Services for AI (S3, Lambda, SageMaker Basics)
2. S3 for AI Assets
Overview
In this capsule you'll master S3 as the data layer of your AI system. S3 isn't "generic storage" — it's where the assets that make your system work live: serialized models, pre-computed embedding files, documents for RAG, versioned prompt templates, and inference logs. You'll learn to organize a bucket for AI projects, run operations with boto3 (put_object, get_object, list_objects_v2, presigned URLs), and design storage patterns that scale.
Context: In Module 4 you created buckets in LocalStack and uploaded test files. Now the focus shifts from "how to use S3" to "how to use S3 for AI." Every operation you learn here has an AI-specific purpose: you don't upload just any .txt, you upload a prompt template that your Lambda will read to build inference. By the end, you'll have a reusable Python module to manage all the AI assets in your system.
S3 Concepts for AI Engineers
Buckets, objects, and prefixes
S3 Bucket: ai-assets-123456789012
├── prompts/ ← Prefix (not a real "folder")
│ ├── summarizer/v1/system.txt ← Object (prompt template)
│ ├── summarizer/v2/system.txt
│ └── classifier/v1/system.txt
├── documents/ ← RAG knowledge base
│ ├── product-docs/manual-v3.pdf
│ └── faq/faq-2026-q1.json
├── embeddings/ ← Pre-computed vectors
│ ├── product-docs/embeddings.npy
│ └── faq/embeddings.npy
├── models/ ← Serialized models
│ └── classifier/sentiment-v2.pkl
└── responses/ ← Inference logs
└── 2026/03/08/
├── resp-001.json
└── resp-002.json
Key concepts:
- Bucket: Top-level container. Globally unique name in AWS. One per project or per environment (dev, staging, prod).
- Object: A file stored in S3. Each object has a key (path), content (bytes), and metadata.
- Prefix: S3 has no real folders.
prompts/summarizer/v1/system.txtis a flat key. The "/" are a convention for organizing. - Versioning: S3 can keep multiple versions of the same object. Useful for prompt templates: roll back to a previous version if the new one worsens results.
Why S3 for AI and not a database?
| Criterion | S3 | Database (PostgreSQL, etc.) |
|---|---|---|
| Large files (models, embeddings) | ✅ No practical limit (5TB/object) | ❌ Not designed for large BLOBs |
| Cost per GB/month | $0.023/GB (S3 Standard) | $0.10-0.30/GB (RDS) |
| Access over HTTP (presigned URLs) | ✅ Native | ❌ Requires a proxy |
| Triggers to Lambda | ✅ S3 Event Notifications | ❌ Requires polling or CDC |
| Complex queries | ❌ Only key-based | ✅ Full SQL |
| ACID transactions | ❌ Eventually consistent | ✅ Native |
Practical rule: Files + key-based access + triggers → S3. Structured data + complex queries → database. For AI, most assets are files (models, embeddings, documents), so S3 is the natural choice.
Bucket Organization for AI Projects
Recommended pattern
BUCKET_STRUCTURE = {
"prompts/": "Versioned prompt templates. Your Lambda reads them before invoking the LLM.",
"documents/": "Source documents for RAG. PDFs, JSONs, markdown.",
"embeddings/": "Pre-computed vectors. Generated offline, consumed at inference time.",
"models/": "Serialized models (pickle, joblib, ONNX). For local inference or SageMaker.",
"responses/": "Inference logs. Every LLM response is saved for auditing.",
"config/": "System configuration. Feature flags, model parameters.",
}
Naming conventions
# ✅ Good key names
"prompts/summarizer/v2/system.txt" # Explicit versioning
"documents/product-docs/manual-2026-q1.pdf" # Temporal
"responses/2026/03/08/req-a1b2c3.json" # Partitioned by date
"embeddings/product-docs/chunk-embeddings.npy"
# ❌ Bad key names
"prompt.txt" # No context
"data/file1.json" # Not descriptive
"embeddings.npy" # No organization
"responses/latest.json" # Gets overwritten
Bucket per environment
# Option 1: One bucket per environment (recommended for teams)
BUCKETS = {
"dev": "ai-assets-dev-123456789012",
"staging": "ai-assets-staging-123456789012",
"prod": "ai-assets-prod-123456789012",
}
# Option 2: One bucket with a prefix per environment (simpler)
BUCKET = "ai-assets-123456789012"
PREFIXES = {
"dev": "dev/",
"staging": "staging/",
"prod": "prod/",
}
S3 Operations with boto3
Client setup
import boto3
import json
import os
from datetime import datetime
def get_s3_client():
"""Creates an S3 client compatible with LocalStack and AWS."""
endpoint_url = os.environ.get("AWS_ENDPOINT_URL")
if endpoint_url:
return boto3.client(
"s3",
endpoint_url=endpoint_url,
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID", "test"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY", "test"),
region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"),
)
return boto3.client("s3")
s3 = get_s3_client()
Create bucket
def create_ai_bucket(bucket_name: str, region: str = "us-east-1") -> dict:
"""Creates an S3 bucket for AI assets."""
create_params = {"Bucket": bucket_name}
if region != "us-east-1":
create_params["CreateBucketConfiguration"] = {
"LocationConstraint": region
}
response = s3.create_bucket(**create_params)
print(f"Bucket created: {bucket_name}")
return response
bucket_name = f"ai-assets-{os.environ.get('AWS_ACCOUNT_ID', 'dev')}"
create_ai_bucket(bucket_name)
put_object — Upload AI assets
def upload_prompt_template(
bucket: str, name: str, version: str, content: str
) -> dict:
"""Uploads a versioned prompt template to S3."""
key = f"prompts/{name}/{version}/system.txt"
response = s3.put_object(
Bucket=bucket,
Key=key,
Body=content.encode("utf-8"),
ContentType="text/plain",
Metadata={
"template-name": name,
"version": version,
"created-at": datetime.utcnow().isoformat(),
},
)
print(f"Prompt template uploaded: s3://{bucket}/{key}")
return response
upload_prompt_template(
bucket=bucket_name,
name="summarizer",
version="v1",
content=(
"You are an assistant specialized in generating summaries.\n"
"Generate a concise summary of the following document.\n"
"Maximum 3 paragraphs. Use clear, direct language.\n"
"Include the most important points."
),
)
def upload_rag_document(bucket: str, collection: str, doc: dict) -> dict:
"""Uploads a document for RAG to S3."""
doc_id = doc.get("id", datetime.utcnow().strftime("%Y%m%d%H%M%S"))
key = f"documents/{collection}/{doc_id}.json"
response = s3.put_object(
Bucket=bucket,
Key=key,
Body=json.dumps(doc, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
Metadata={
"collection": collection,
"doc-id": doc_id,
},
)
print(f"RAG document uploaded: s3://{bucket}/{key}")
return response
upload_rag_document(
bucket=bucket_name,
collection="product-docs",
doc={
"id": "doc-001",
"title": "Installation guide",
"content": "To install the system, follow these steps...",
"metadata": {"category": "setup", "language": "en"},
},
)
def upload_inference_response(
bucket: str, request_id: str, response_data: dict
) -> dict:
"""Saves an inference response for auditing."""
now = datetime.utcnow()
key = f"responses/{now.strftime('%Y/%m/%d')}/{request_id}.json"
payload = {
"request_id": request_id,
"timestamp": now.isoformat(),
**response_data,
}
result = s3.put_object(
Bucket=bucket,
Key=key,
Body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
print(f"Response saved: s3://{bucket}/{key}")
return result
get_object — Retrieve assets
def get_prompt_template(bucket: str, name: str, version: str) -> str:
"""Reads a prompt template from S3."""
key = f"prompts/{name}/{version}/system.txt"
response = s3.get_object(Bucket=bucket, Key=key)
content = response["Body"].read().decode("utf-8")
return content
template = get_prompt_template(bucket_name, "summarizer", "v1")
print(template)
def get_rag_document(bucket: str, collection: str, doc_id: str) -> dict:
"""Reads a RAG document from S3."""
key = f"documents/{collection}/{doc_id}.json"
response = s3.get_object(Bucket=bucket, Key=key)
content = response["Body"].read().decode("utf-8")
return json.loads(content)
doc = get_rag_document(bucket_name, "product-docs", "doc-001")
print(doc["title"])
list_objects_v2 — List assets
def list_ai_assets(bucket: str, prefix: str, max_keys: int = 100) -> list:
"""Lists AI assets by prefix."""
response = s3.list_objects_v2(
Bucket=bucket,
Prefix=prefix,
MaxKeys=max_keys,
)
objects = []
for obj in response.get("Contents", []):
objects.append({
"key": obj["Key"],
"size_bytes": obj["Size"],
"last_modified": obj["LastModified"].isoformat(),
})
return objects
prompts = list_ai_assets(bucket_name, "prompts/")
for p in prompts:
print(f" {p['key']} ({p['size_bytes']} bytes)")
def list_all_with_pagination(bucket: str, prefix: str) -> list:
"""Lists all objects, handling pagination for >1000 results."""
paginator = s3.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=bucket, Prefix=prefix)
all_objects = []
for page in pages:
for obj in page.get("Contents", []):
all_objects.append(obj["Key"])
return all_objects
generate_presigned_url — Temporary URLs
def generate_download_url(
bucket: str, key: str, expires_in: int = 3600
) -> str:
"""Generates a temporary URL to download an object without AWS credentials."""
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=expires_in,
)
return url
url = generate_download_url(bucket_name, "documents/product-docs/doc-001.json")
print(f"Temporary URL (1h): {url}")
Presigned URLs are useful when:
- Your frontend needs to download a RAG document directly from S3 (without going through Lambda)
- You want to share a serialized model with a teammate
- A Lambda generates a report and sends the link by email/webhook
def generate_upload_url(
bucket: str, key: str, content_type: str = "application/json", expires_in: int = 900
) -> str:
"""Generates a temporary URL for a client to upload a file directly to S3."""
url = s3.generate_presigned_url(
"put_object",
Params={
"Bucket": bucket,
"Key": key,
"ContentType": content_type,
},
ExpiresIn=expires_in,
)
return url
upload_url = generate_upload_url(
bucket_name,
"documents/uploads/user-doc-abc123.pdf",
content_type="application/pdf",
)
print(f"Upload URL (15min): {upload_url}")
delete_object — Clean up obsolete assets
def delete_old_responses(bucket: str, prefix: str, dry_run: bool = True) -> list:
"""Deletes old responses from S3."""
objects = list_all_with_pagination(bucket, prefix)
if dry_run:
print(f"[DRY RUN] Would delete {len(objects)} objects:")
for key in objects[:5]:
print(f" - {key}")
if len(objects) > 5:
print(f" ... and {len(objects) - 5} more")
return objects
if not objects:
return []
delete_keys = [{"Key": key} for key in objects]
response = s3.delete_objects(
Bucket=bucket,
Delete={"Objects": delete_keys},
)
deleted = response.get("Deleted", [])
print(f"Deleted: {len(deleted)} objects")
return [d["Key"] for d in deleted]
Reusable Module: S3 AI Assets Manager
"""s3_ai_assets.py — AI asset management in S3."""
import boto3
import json
import os
from datetime import datetime
from typing import Optional
class S3AIAssets:
"""Manages AI assets in an S3 bucket."""
def __init__(self, bucket_name: str, endpoint_url: Optional[str] = None):
self.bucket = bucket_name
client_kwargs = {}
if endpoint_url:
client_kwargs["endpoint_url"] = endpoint_url
self.s3 = boto3.client("s3", **client_kwargs)
def put_prompt(self, name: str, version: str, content: str) -> str:
key = f"prompts/{name}/{version}/system.txt"
self.s3.put_object(
Bucket=self.bucket,
Key=key,
Body=content.encode("utf-8"),
ContentType="text/plain",
Metadata={"version": version, "updated": datetime.utcnow().isoformat()},
)
return key
def get_prompt(self, name: str, version: str) -> str:
key = f"prompts/{name}/{version}/system.txt"
resp = self.s3.get_object(Bucket=self.bucket, Key=key)
return resp["Body"].read().decode("utf-8")
def put_document(self, collection: str, doc_id: str, data: dict) -> str:
key = f"documents/{collection}/{doc_id}.json"
self.s3.put_object(
Bucket=self.bucket,
Key=key,
Body=json.dumps(data, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
return key
def get_document(self, collection: str, doc_id: str) -> dict:
key = f"documents/{collection}/{doc_id}.json"
resp = self.s3.get_object(Bucket=self.bucket, Key=key)
return json.loads(resp["Body"].read().decode("utf-8"))
def put_response(self, request_id: str, data: dict) -> str:
now = datetime.utcnow()
key = f"responses/{now.strftime('%Y/%m/%d')}/{request_id}.json"
payload = {"request_id": request_id, "timestamp": now.isoformat(), **data}
self.s3.put_object(
Bucket=self.bucket,
Key=key,
Body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
return key
def list_keys(self, prefix: str) -> list:
paginator = self.s3.get_paginator("list_objects_v2")
keys = []
for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
for obj in page.get("Contents", []):
keys.append(obj["Key"])
return keys
def presigned_download(self, key: str, expires: int = 3600) -> str:
return self.s3.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": key},
ExpiresIn=expires,
)
# Usage
assets = S3AIAssets(
bucket_name="ai-assets-dev",
endpoint_url=os.environ.get("AWS_ENDPOINT_URL"),
)
assets.put_prompt("summarizer", "v1", "Summarize the following document in 3 points.")
template = assets.get_prompt("summarizer", "v1")
print(template)
S3 Lifecycle: Automatic Cost Management
For an AI system in production, responses pile up. Lifecycle rules automate the transition to cheaper storage or deletion:
def configure_ai_lifecycle(bucket: str) -> dict:
"""Configures lifecycle rules for AI assets."""
response = s3.put_bucket_lifecycle_configuration(
Bucket=bucket,
LifecycleConfiguration={
"Rules": [
{
"ID": "archive-old-responses",
"Filter": {"Prefix": "responses/"},
"Status": "Enabled",
"Transitions": [
{
"Days": 30,
"StorageClass": "STANDARD_IA",
},
{
"Days": 90,
"StorageClass": "GLACIER",
},
],
"Expiration": {"Days": 365},
},
{
"ID": "clean-temp-uploads",
"Filter": {"Prefix": "temp/"},
"Status": "Enabled",
"Expiration": {"Days": 7},
},
],
},
)
print(f"Lifecycle configured for {bucket}")
return response
Lifecycle result:
responses/*
├── 0-30 days: S3 Standard ($0.023/GB)
├── 30-90 days: S3 Standard-IA ($0.0125/GB) ← 46% cheaper
├── 90-365 days: S3 Glacier ($0.004/GB) ← 83% cheaper
└── >365 days: Deleted automatically
temp/*
└── >7 days: Deleted automatically
Troubleshooting
Problem 1: "NoSuchBucket" when calling put_object
The bucket doesn't exist. You need to create it before uploading objects.
try:
s3.head_bucket(Bucket=bucket_name)
except s3.exceptions.ClientError as e:
if e.response["Error"]["Code"] == "404":
print(f"Bucket {bucket_name} doesn't exist. Creating it...")
s3.create_bucket(Bucket=bucket_name)
else:
raise
Problem 2: "AccessDenied" when reading from S3
Your IAM role doesn't have permissions for the operation. In LocalStack it doesn't happen; on real AWS, it does.
# Check the role's permissions
aws iam get-role-policy --role-name your-lambda-role --policy-name s3-access
# The policy needs s3:GetObject on the specific bucket
Problem 3: get_object returns bytes, not a string
response["Body"].read() returns bytes. You need to decode it.
# ❌ Common mistake
content = s3.get_object(Bucket=b, Key=k)["Body"].read()
data = json.loads(content) # Works because json.loads accepts bytes
# ✅ Explicit and safe
content = s3.get_object(Bucket=b, Key=k)["Body"].read().decode("utf-8")
data = json.loads(content)
Problem 4: Presigned URLs don't work in LocalStack
LocalStack generates URLs with localhost, which aren't accessible from outside your machine.
# In LocalStack, the generated URL is:
# http://localhost:4566/bucket/key?...
# If you access from a Docker container, use the service name:
# http://localstack:4566/bucket/key?...
# For local testing, localhost works.
Problem 5: list_objects_v2 returns only 1000 objects
S3 pages at 1000 objects per request. Use boto3's paginator.
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket, Prefix="responses/"):
for obj in page.get("Contents", []):
print(obj["Key"])
Practical Exercises
Exercise 1: Versioning prompt templates
Create a function that uploads multiple versions of a prompt template to S3, and another that retrieves the latest available version (the one with the highest number).
See solution
import boto3
import json
import os
import re
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
BUCKET = "ai-assets-dev"
def upload_prompt_versions(name: str, versions: dict[str, str]) -> list:
"""Uploads multiple versions of a prompt. versions = {"v1": "...", "v2": "..."}"""
keys = []
for version, content in versions.items():
key = f"prompts/{name}/{version}/system.txt"
s3.put_object(
Bucket=BUCKET,
Key=key,
Body=content.encode("utf-8"),
ContentType="text/plain",
)
keys.append(key)
print(f"Uploaded: {key}")
return keys
def get_latest_prompt(name: str) -> tuple[str, str]:
"""Returns (version, content) of the prompt's latest version."""
response = s3.list_objects_v2(
Bucket=BUCKET,
Prefix=f"prompts/{name}/",
)
versions = []
for obj in response.get("Contents", []):
match = re.search(r"/v(\d+)/", obj["Key"])
if match:
versions.append((int(match.group(1)), obj["Key"]))
if not versions:
raise ValueError(f"No versions found for prompt '{name}'")
versions.sort(key=lambda x: x[0], reverse=True)
latest_key = versions[0][1]
latest_version = f"v{versions[0][0]}"
resp = s3.get_object(Bucket=BUCKET, Key=latest_key)
content = resp["Body"].read().decode("utf-8")
return latest_version, content
upload_prompt_versions("summarizer", {
"v1": "Summarize the document in one paragraph.",
"v2": "Summarize the document in 3 bullets. Be concise.",
"v3": "Summarize the document in 3 bullets. Include numeric data if present.",
})
version, content = get_latest_prompt("summarizer")
print(f"Latest version: {version}")
print(f"Content: {content}")
Exercise 2: Bulk upload of RAG documents
Implement a function that takes a list of documents (dicts) and uploads them to S3 in a specific collection. Include a counter of successes and failures.
See solution
import boto3
import json
import os
from dataclasses import dataclass
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
BUCKET = "ai-assets-dev"
@dataclass
class UploadResult:
total: int
success: int
failed: int
errors: list
def bulk_upload_documents(collection: str, documents: list[dict]) -> UploadResult:
"""Uploads a list of documents to S3 for RAG."""
result = UploadResult(total=len(documents), success=0, failed=0, errors=[])
for doc in documents:
doc_id = doc.get("id")
if not doc_id:
result.failed += 1
result.errors.append({"doc": doc, "error": "Missing 'id' field"})
continue
key = f"documents/{collection}/{doc_id}.json"
try:
s3.put_object(
Bucket=BUCKET,
Key=key,
Body=json.dumps(doc, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
result.success += 1
except Exception as e:
result.failed += 1
result.errors.append({"doc_id": doc_id, "error": str(e)})
print(f"Upload completed: {result.success}/{result.total} successful")
if result.errors:
print(f" Errors: {result.failed}")
return result
docs = [
{"id": "faq-001", "question": "How do I install the SDK?", "answer": "Use pip install..."},
{"id": "faq-002", "question": "What's the pricing?", "answer": "Free plan + pro plan..."},
{"id": "faq-003", "question": "Is there a public API?", "answer": "Yes, documented at..."},
]
result = bulk_upload_documents("faq", docs)
print(f"Result: {result}")
Exercise 3: Export a complete RAG collection
Implement a function that downloads all the documents of a RAG collection from S3 and returns them as a list of dicts. It must handle pagination.
See solution
import boto3
import json
import os
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
BUCKET = "ai-assets-dev"
def export_collection(collection: str) -> list[dict]:
"""Downloads all documents from a RAG collection."""
prefix = f"documents/{collection}/"
paginator = s3.get_paginator("list_objects_v2")
documents = []
for page in paginator.paginate(Bucket=BUCKET, Prefix=prefix):
for obj in page.get("Contents", []):
key = obj["Key"]
if not key.endswith(".json"):
continue
try:
response = s3.get_object(Bucket=BUCKET, Key=key)
content = response["Body"].read().decode("utf-8")
doc = json.loads(content)
doc["_s3_key"] = key
doc["_s3_size"] = obj["Size"]
doc["_s3_modified"] = obj["LastModified"].isoformat()
documents.append(doc)
except Exception as e:
print(f"Error reading {key}: {e}")
print(f"Exported {len(documents)} documents from '{collection}'")
return documents
docs = export_collection("faq")
for doc in docs:
print(f" {doc['id']}: {doc.get('question', 'N/A')}")
Exercise 4: Storage usage audit
Create a function that analyzes a bucket's contents and generates a usage report by prefix (total size, number of objects, most recent object).
See solution
import boto3
import os
from collections import defaultdict
from datetime import datetime
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
BUCKET = "ai-assets-dev"
def storage_audit(bucket: str) -> dict:
"""Generates a storage usage report by first-level prefix."""
paginator = s3.get_paginator("list_objects_v2")
stats = defaultdict(lambda: {
"count": 0,
"total_bytes": 0,
"latest_modified": None,
"latest_key": None,
})
for page in paginator.paginate(Bucket=bucket):
for obj in page.get("Contents", []):
key = obj["Key"]
prefix = key.split("/")[0] + "/" if "/" in key else "(root)"
stats[prefix]["count"] += 1
stats[prefix]["total_bytes"] += obj["Size"]
mod_time = obj["LastModified"]
if (
stats[prefix]["latest_modified"] is None
or mod_time > stats[prefix]["latest_modified"]
):
stats[prefix]["latest_modified"] = mod_time
stats[prefix]["latest_key"] = key
report = dict(stats)
total_bytes = sum(s["total_bytes"] for s in report.values())
total_objects = sum(s["count"] for s in report.values())
print(f"=== Storage Audit: {bucket} ===")
print(f"Total: {total_objects} objects, {total_bytes / 1024:.1f} KB")
print()
for prefix, data in sorted(report.items()):
size_kb = data["total_bytes"] / 1024
print(f" {prefix:<20} {data['count']:>5} objects {size_kb:>8.1f} KB")
if data["latest_key"]:
print(f" {'':20} latest: {data['latest_key']}")
print()
estimated_monthly = (total_bytes / (1024**3)) * 0.023
print(f"Estimated S3 cost: ${estimated_monthly:.4f}/month (S3 Standard)")
return report
storage_audit(BUCKET)
Summary
- S3 is the data layer of your AI system. It's not generic storage — it's where your prompt templates, RAG documents, embeddings, models, and inference logs live.
- Organize by prefix with a clear convention:
prompts/,documents/,embeddings/,models/,responses/. Version explicitly. - Key boto3 operations:
put_objectto upload,get_objectto read,list_objects_v2with a paginator to list,generate_presigned_urlfor temporary URLs. - Lifecycle rules automate costs: Move old responses to cheap storage (IA, Glacier) and delete temporary files automatically.
- The S3AIAssets module is reusable. You'll use it in capsule 04 (S3+Lambda integration) and in the module project.
- Everything works the same in LocalStack and AWS. The difference is
endpoint_urlin boto3.
Additional Resources
- S3 User Guide — Complete official documentation
- boto3 S3 Examples — Official boto3 examples for S3
- S3 Presigned URLs — Temporary URLs guide
- S3 Lifecycle Configuration — Lifecycle management
- S3 Storage Classes — Storage class comparison
- S3 Pricing — Detailed S3 pricing
- S3 Event Notifications — S3 → Lambda triggers
- S3 Best Practices — Performance optimization