The chapter where Cosmos DB intuition will bite you. DynamoDB looks like Cosmos Core but charges differently, partitions differently, and indexes differently. Meanwhile Aurora quietly outperforms Azure SQL on most workloads, and ElastiCache is the same Redis you know - just configured differently. Bring a pricing calculator.
Azure groups its data services into a tidy menu: Azure SQL for relational, Cosmos DB for multi-model NoSQL, Azure Cache for Redis for caching, Synapse for warehousing. AWS has more services, each more focused, and the boundaries between them are sharper. RDS is the relational umbrella with six engines. Aurora is the cloud-native flagship beneath that umbrella. DynamoDB is the NoSQL workhorse - a single-purpose key-value store that scales horizontally without you babysitting it, as long as you pick the right partition key. ElastiCache covers Redis and Memcached. Redshift handles the warehouse. We'll walk all of them.
| Concept | Azure | AWS |
|---|---|---|
| Managed SQL Server | Azure SQL Database / Managed Instance | RDS for SQL Server or Aurora (no SQL Server flavor) - choose RDS |
| Managed PostgreSQL | Azure Database for PostgreSQL - Flexible Server | Aurora PostgreSQL (preferred) or RDS PostgreSQL |
| Managed MySQL | Azure Database for MySQL - Flexible Server | Aurora MySQL (preferred) or RDS MySQL |
| NoSQL document/KV | Cosmos DB - Core (SQL) API | DynamoDB |
| MongoDB-compatible | Cosmos DB - MongoDB API | DocumentDB (MongoDB-compatible) |
| Cassandra-compatible | Cosmos DB - Cassandra API | Keyspaces (for Apache Cassandra) |
| Managed Redis | Azure Cache for Redis | ElastiCache for Redis (now: ElastiCache (Valkey) / Redis OSS) |
| Memcached | no first-party Memcached | ElastiCache for Memcached |
| Data warehouse | Azure Synapse / SQL DW | Redshift |
| Graph DB | Cosmos DB - Gremlin API | Neptune |
| Time-series | Azure Data Explorer / Time Series Insights | Timestream |
| Connection pooling | PgBouncer add-on (PG flex) | RDS Proxy (separate service, charged per vCPU/hour) |
| Serverless RDB | Azure SQL Serverless (auto-pause) | Aurora Serverless v2 (no auto-pause by default; v1 had it) |
RDS is the umbrella service for managed relational databases. You pick an engine, a size, a storage type, and AWS handles the rest of the housekeeping.
| Engine | Versions | Notable |
|---|---|---|
| PostgreSQL | 13 - 17 | Most popular open-source choice. Supports many extensions (PostGIS, pg_cron, pgvector). |
| MySQL | 5.7, 8.0, 8.4 | Mature; Aurora MySQL is faster but more expensive. |
| MariaDB | 10.5 - 11.4 | Niche; pick MySQL or Aurora unless you specifically want MariaDB features. |
| Oracle | 19c, 21c | BYOL or License Included. The expensive way out of on-prem Oracle. |
| SQL Server | 2017 - 2022 | The natural home for "lift Azure SQL workloads to AWS". License Included pricing. |
| Aurora | MySQL-, PG-compatible | Cloud-native flagship. Own section below. |
Azure SQL hides these behind one toggle. RDS exposes them as two distinct features, and you'll pick one or both depending on what you actually want.
az postgres flexible-server create \
--resource-group rg-data \
--name compass-pg \
--location eastus \
--tier GeneralPurpose \
--sku-name Standard_D2ds_v5 \
--storage-size 64 \
--version 16 \
--high-availability ZoneRedundant \
--admin-user pgadmin \
--admin-password ""
aws rds create-db-instance \
--db-instance-identifier compass-pg \
--db-instance-class db.m6g.large \
--engine postgres \
--engine-version 16.3 \
--allocated-storage 64 \
--storage-type gp3 \
--master-username pgadmin \
--master-user-password "" \
--multi-az \
--vpc-security-group-ids sg-0123abc \
--db-subnet-group-name compass-db-subnets
db-subnet-group (created in chapter 3) and a security group ready. Azure asks for those at create time and provisions a default if you don't pass one. AWS will refuse to provision until the network is shaped.
resource "aws_db_subnet_group" "compass" {
name = "compass-db-subnets"
subnet_ids = [for s in aws_subnet.private : s.id]
}
resource "aws_db_instance" "pg" {
identifier = "compass-pg"
engine = "postgres"
engine_version = "16.3"
instance_class = "db.m6g.large"
allocated_storage = 64
storage_type = "gp3"
username = "pgadmin"
password = var.db_password # move to Secrets Manager in chapter 8
multi_az = true
db_subnet_group_name = aws_db_subnet_group.compass.name
vpc_security_group_ids = [aws_security_group.db.id]
backup_retention_period = 14
deletion_protection = true
skip_final_snapshot = false
final_snapshot_identifier = "compass-pg-final"
tags = { Project = "Compass" }
}
deletion_protection = true stops a stray Terraform destroy from wiping the database. skip_final_snapshot = false ensures a snapshot is taken if it does get destroyed. Defaults for both are the unsafe value. Set them explicitly on every production DB.
AWS launched Aurora in 2014 with two claims: 5x MySQL performance and 3x PostgreSQL performance for typical OLTP workloads. The marketing numbers come with caveats, but the architecture is genuinely different and the wins on read-heavy workloads are real.
Aurora Serverless v2 lets you specify a min/max range of ACUs (Aurora Capacity Units) and the cluster scales compute up and down by half-ACU increments in response to load. Unlike v1 (which auto-paused to zero), v2 by default does not pause - the floor is whatever min you set, and you pay for that floor 24x7.
| Capability | Aurora Serverless v1 | Aurora Serverless v2 |
|---|---|---|
| Scale unit | ACU, doubles each step | 0.5 ACU, granular |
| Scale time | 30 s - 5 min | seconds |
| Auto-pause to zero | yes | no by default (added in 2024 for some engines) |
| Cold start cost | 5-30 s to wake | none (instance is always warm at min) |
| Status | deprecated for most engines | recommended |
Workload is small and steady (Aurora has a $0.10/hour floor for the writer; RDS Postgres on t4g.micro costs less than half).
You need a specific engine version that isn't in Aurora's compat matrix.
Aurora has no SQL Server or Oracle flavor. If you're lifting an Azure SQL workload, you're picking RDS for SQL Server.
Read-heavy workloads (Aurora's 15-replica ceiling and shared storage shine).
You want fast failover (~30 s, no DNS swap to a separate physical replica).
You want point-in-time restore granular to the second across years of history.
DynamoDB stores items (records with up to ~400 KB of attributes) in tables. Every table requires a primary key made of:
Also called "hash key". Any item with the same PK lives in the same physical partition. Hashed under the hood; you don't see the hash.
If your table has only a PK, the PK must be unique across the whole table.
Also called "range key". Items with the same PK are sorted by SK within the partition. The unique key is then (PK, SK) together.
Enables "give me all items for user X between dates Y and Z" with a single query.
u-7421 land on the same partition, sorted by sort key. Range queries within one user are fast. Cross-user queries are not - they require a Scan or a secondary index.az cosmosdb create \
--name compass-cosmos \
--resource-group rg-data \
--locations regionName=eastus
az cosmosdb sql container create \
--account-name compass-cosmos \
--database-name reports \
--name aggregates \
--partition-key-path "/report_id" \
--throughput 400
aws dynamodb create-table \
--table-name compass-reports-aggregates \
--attribute-definitions \
AttributeName=report_id,AttributeType=S \
AttributeName=period_start,AttributeType=S \
--key-schema \
AttributeName=report_id,KeyType=HASH \
AttributeName=period_start,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--tags Key=Project,Value=Compass
A team is building a global SaaS that tracks user activity. Day-one design uses country_code as the DynamoDB partition key, with event_ts as the sort key. Traffic is roughly 80% US, 12% UK, 8% rest of world. Six weeks in they start hitting ProvisionedThroughputExceededException. They've already raised RCU/WCU. Why does it still throttle?
resource "aws_dynamodb_table" "events" {
name = "global-events"
billing_mode = "PROVISIONED"
read_capacity = 5000
write_capacity = 5000
hash_key = "country_code" # PK
range_key = "event_ts" # SK
attribute { name = "country_code" ; type = "S" }
attribute { name = "event_ts" ; type = "S" }
}
"US" partition gets 80% of every write the table receives.
DynamoDB hashes the PK to assign physical partitions. With only ~5 distinct PK values (a handful of country codes), every "US" event lands on one partition. Each physical partition has a hard ceiling of 3,000 RCU / 1,000 WCU, no matter how much capacity you provision on the table - so once US traffic exceeds 1,000 writes per second, you throttle, even with 50,000 WCU provisioned table-wide.
Fix: use a high-cardinality PK or "compose" one. Two common patterns:
PK = country_code + "#" + user_id - cardinality jumps to "millions of users", traffic spreads evenly across partitions.
PK = country_code + "#" + (event_ts hour) - if access pattern is "events by country in the last hour", this keeps queries efficient while spreading load.
Never use a PK with low cardinality (status, country, category, environment). Hot-partition exceptions look like throughput problems but are shape problems.
status as partition key, 5-hour outage
$500K lost revenue
A mid-sized fintech built a payment-processing service backed by DynamoDB. Their main table held one item per payment, with attributes payment_id, merchant_id, status (PENDING / SUCCEEDED / FAILED), amount, and timestamps. The engineer who designed the schema picked status as the partition key because their dominant query was "give me all pending payments to process".
It worked beautifully for six months at low volume. Then a marketing push doubled traffic over a weekend. By Monday morning, the system started returning timeouts on every payment creation - because every new payment was created with status = "PENDING", which meant every write hit the same physical partition. With ~3,000 writes per second sustained, the PENDING partition started throttling.
The retries piled up. The worker fleet, designed to scale on backlog depth, scaled out and made things worse - more workers all writing to the same overloaded partition. Customer payments started failing. The outage lasted 5 hours while engineers wrote a migration to re-shard the table using merchant_id as the PK.
Lessons: (1) Never use a low-cardinality attribute as a partition key. "Status", "type", "category", "environment", "country" are all red flags. (2) DynamoDB on-demand mode would have absorbed the spike more gracefully, but the underlying physical-partition ceiling of 1,000 WCU still applies. (3) The query "give me all items with status = PENDING" is best served by a Global Secondary Index with status as the PK, scanned offline, never by making status the primary PK.
The single biggest cost lever in DynamoDB is the billing mode. Pick wrong and you'll over- or under-pay by an order of magnitude.
Pay per request. ~$1.25 per million writes, ~$0.25 per million reads (eventually consistent). No capacity planning.
Good for: unpredictable traffic, dev/test, low-volume production, "I have no idea what my workload looks like yet".
Bad for: steady high-throughput workloads (it costs 7x provisioned at scale).
Specify RCU/WCU floors and ceilings. Auto-scaling tracks actual usage and adjusts within bounds.
Good for: steady or predictable workloads where you can measure baseline RCU/WCU.
Bad for: spiky workloads with bursts more than 2x baseline - auto-scaling lags behind traffic.
A team picks on-demand mode for a new DynamoDB table because "we don't know the workload yet, we'll switch later". Six months on, the workload has stabilized at 5,000 requests per second (mix of reads and writes, 4 KB items) for ~720 hours per month. They're paying about $16,000/month for DynamoDB. Their finance team flags it. The engineer says "but on-demand is the recommended default". What's the actual math, and what's the fix?
5,000 req/s × 3,600 s/hr × 720 hr/mo = 12.96 billion requests/month
At a blended rate of ~$1.25 per million writes (assuming write-heavy mix; reads are cheaper at $0.25/M but let's be conservative):
12.96B × $1.25 / 1M = ~$16,200/month
Provisioned cost (with auto-scaling):
At steady state, 5K writes/sec needs 5,000 WCU (1 WCU = 1 write/sec for 1 KB items; assume a bit higher with 4 KB so ~6K WCU peak, scaling down at night).
5,000 WCU × $0.00065/WCU-hr × 720 hr = $2,340/month
Plus reads at maybe ~$200/month. Total: ~$2,200-2,500/month.
The markup: 7.3x. $14,000/month walking out the door because nobody flipped the switch.
Fix:
1. Enable Contributor Insights or CloudWatch ConsumedReadCapacityUnits/ConsumedWriteCapacityUnits metrics. Track the median and p95 over 4 weeks.
2. Once the workload looks predictable (p95 within 2x of p50), switch to provisioned with auto-scaling. Target utilization: 70%. Min: p50 estimate. Max: 2x p95.
3. For seasonal traffic (e.g., gaming workloads with 8 PM peaks), schedule auto-scaling adjustments via Application Auto Scaling. Or use Reserved Capacity for the 24x7 floor (38% discount with 1-year commit).
The rule of thumb: on-demand is the right default for new tables. It becomes the wrong default once the workload stabilizes - and almost nobody goes back to flip the switch unless finance reads the bill.
DynamoDB has two flavors of secondary index. Pick wrong and you'll either pay double or fail to add the index at all (LSI must be defined at table-create time).
| Aspect | LSI (Local Secondary Index) | GSI (Global Secondary Index) |
|---|---|---|
| Partition key | Must be the same as the base table | Any attribute |
| Sort key | Different from base table | Different from base table |
| Storage | Same partition as the item | Separate partitioning, replicated copy |
| Consistency | Strong reads supported | Eventually consistent only |
| Created | At table creation only (cannot add later) | Any time, online |
| Capacity | Shares the table's RCU/WCU | Own RCU/WCU (or on-demand) |
| Quantity | Max 5 per table | Max 20 per table (default; can request up to 50) |
| Cost | Storage only (no extra capacity) | Storage + capacity (essentially a 2nd table) |
Only the primary key (PK + SK) and the index keys. Smallest, cheapest. Forces a base-table lookup for every other attribute.
Use when: queries return tiny result sets you can afford to look up individually.
Keys plus a specific list of attributes you nominated. Middle ground.
Use when: you know exactly which attributes the index's queries need. Most common.
Every attribute. Index is a full copy. Largest, most expensive in storage and WCU.
Use when: queries return many items and you can't afford the base-table fan-out cost.
ConsistentRead=true, DynamoDB returns an error for GSI queries. If you absolutely need strong consistency, use an LSI - but remember LSI partition key must match the base table.
resource "aws_dynamodb_table" "orders" {
name = "orders"
billing_mode = "PAY_PER_REQUEST"
hash_key = "merchant_id" # base PK - high cardinality
range_key = "order_id" # base SK
attribute { name = "merchant_id" ; type = "S" }
attribute { name = "order_id" ; type = "S" }
attribute { name = "status" ; type = "S" }
attribute { name = "updated_ts" ; type = "S" }
global_secondary_index {
name = "status-updated_ts-index"
hash_key = "status" # GSI PK - ok here because
range_key = "updated_ts" # this index is scanned offline only
projection_type = "KEYS_ONLY" # worker just needs IDs, looks up rest
}
point_in_time_recovery { enabled = true }
server_side_encryption { enabled = true }
}
The trick here is making the base table partition cleanly by merchant_id (so the live write path is hot-partition-free), and exposing a status-based GSI just for the offline worker that periodically scans for new PENDING items. The GSI's PK can be low-cardinality because nothing is hammering it in real-time.
ElastiCache has two engines, and the choice is mostly settled by the data structures you need.
Default in 2025+. Valkey is the OSS fork after Redis Inc. relicensed Redis to SSPL in 2024. AWS contributed to and maintains Valkey now.
Supports lists, hashes, sorted sets, streams, geo, pub-sub. Persistence via AOF/snapshots. Multi-AZ with auto-failover. Cluster mode for sharding.
Pure KV cache. No persistence. No replication. Larger key counts per node (no overhead).
Use only if you need: multi-threaded engine (Redis is single-threaded per node), or simple cache-aside on raw string values.
| Mode | What it is | Use when |
|---|---|---|
| Cluster mode disabled | Single shard (primary) + up to 5 read replicas. All keys on one node. | Working set fits in one node's RAM. Simpler client config. |
| Cluster mode enabled | Multiple shards, hash-slot-partitioned. Up to 500 shards. | Working set exceeds one node. Need horizontal scale. |
az redis create \
--name compass-cache \
--resource-group rg-data \
--location eastus \
--sku Standard \
--vm-size C1 \
--enable-non-ssl-port false
aws elasticache create-cache-cluster \
--cache-cluster-id compass-cache \
--engine valkey \
--cache-node-type cache.t4g.small \
--num-cache-nodes 1 \
--engine-version 7.2 \
--cache-subnet-group-name compass-cache-subnets \
--security-group-ids sg-cache0123
Goal: get hands on with the DynamoDB and RDS CLI surface. The first three commands are pure reads ($0). The optional create/delete at the end uses on-demand DynamoDB and costs effectively zero if you don't write any data.
Step 1. List DynamoDB tables in your account.
aws dynamodb list-tables --profile compass
# empty {"TableNames": []} on a fresh account is fine
Step 2. Check your DynamoDB account-level limits (RCU/WCU ceilings).
aws dynamodb describe-limits --profile compass
# Returns per-account and per-table provisioned-capacity caps.
# Defaults: 80,000 RCU and 80,000 WCU per region, raisable via support.
Step 3. List available RDS engine versions (great for spotting deprecated ones).
aws rds describe-db-engine-versions \
--engine postgres \
--query 'DBEngineVersions[?contains(EngineVersion, `16`)].[EngineVersion,Status]' \
--output table \
--profile compass
# Look for "available" status; "deprecated" engines still work but won't
# get new features and have an end-of-support date.
Step 4 (optional). Create a tiny on-demand DynamoDB table, then delete it. Costs near zero if you write no items.
aws dynamodb create-table \
--table-name learn-aws-lab \
--attribute-definitions AttributeName=id,AttributeType=S \
--key-schema AttributeName=id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--profile compass
aws dynamodb wait table-exists --table-name learn-aws-lab --profile compass
aws dynamodb describe-table --table-name learn-aws-lab --profile compass \
--query 'Table.[TableName,TableStatus,BillingModeSummary.BillingMode]' \
--output table
aws dynamodb delete-table --table-name learn-aws-lab --profile compass
What you learned: DynamoDB tables can be created and torn down in seconds. There is no provisioning lead time like Cosmos (Cosmos typically takes 1-3 minutes for a new container). Use this freedom in dev: spin up a sandbox table per developer, throw it away when done.
Database design choices stick around for years. Spend a minute on each one before peeking.
1. What is the most common root cause of a DynamoDB hot partition?
country#user_id).
2. What's the difference between an RDS Multi-AZ deployment and an RDS read replica?
3. Does Aurora Serverless v2 have a "cold start" delay when scaling from zero?
4. At what sustained request rate does DynamoDB provisioned typically beat on-demand on cost?
5. Can you do a strongly consistent read against a DynamoDB Global Secondary Index (GSI)?
ConsistentRead=true on the query.ALL.customer_billing_address_postal_code repeated across millions of items burn storage and capacity. Many teams adopt 2-3 character attribute names in production (bz, cn) for hot tables. Map back to human names in the application layer. Ugly but real - the difference between "fits in 4 KB" (1 RCU) and "needs 8 KB" (2 RCU) is one long attribute name.
Picking up from chapter 5, where we added an S3 bucket for raw telemetry and extended the role's policy. In this chapter's slice we create the DynamoDB table that gfn-reports will write aggregated session metrics into, then attach read/write permissions on the table to gfn-reports-role.
gfn-reports-aggregates table
Why now: the worker (currently a Lambda) will read raw telemetry from S3 and write aggregated rows to DynamoDB. We need the table before chapter 7 introduces the SQS queue that triggers the worker.
Partition key: report_id (String) - high cardinality (one per merchant or game session, depending on the aggregation grain). Sort key: period_start (String, ISO-8601 timestamp) - so the same report's time-bucketed rows stay sorted within one partition.
Capacity mode: on-demand for now. We have no idea what the workload looks like yet. Chapter 12 will revisit this with real CloudWatch data and consider switching to provisioned.
aws dynamodb create-table \
--table-name gfn-reports-aggregates \
--attribute-definitions \
AttributeName=report_id,AttributeType=S \
AttributeName=period_start,AttributeType=S \
--key-schema \
AttributeName=report_id,KeyType=HASH \
AttributeName=period_start,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--sse-specification Enabled=true \
--tags Key=Project,Value=Compass Key=ManagedBy,Value=learn-aws \
--profile compass
aws dynamodb wait table-exists \
--table-name gfn-reports-aggregates \
--profile compass
aws dynamodb describe-table \
--table-name gfn-reports-aggregates \
--query 'Table.[TableName,TableStatus,KeySchema,BillingModeSummary.BillingMode]' \
--output table \
--profile compass
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AggregatesTableRW",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:<ACCOUNT>:table/gfn-reports-aggregates",
"arn:aws:dynamodb:us-east-1:<ACCOUNT>:table/gfn-reports-aggregates/index/*"
]
}]
}
aws iam put-role-policy \
--role-name gfn-reports-role \
--policy-name AggregatesTableRW \
--policy-document file:///tmp/compass-dynamo-policy.json \
--profile compass
# Verify - should now list S3 (from ch5), Lambda basic exec (from ch2), and the new policy
aws iam list-role-policies \
--role-name gfn-reports-role \
--profile compass
aws iam list-attached-role-policies \
--role-name gfn-reports-role \
--profile compass
We deliberately scoped the policy to just this one table (and its future indexes via index/*). No dynamodb:*, no Resource: "*". Future chapters add KMS permissions (ch 8) once we move from SSE-S3 to a customer-managed key.
aws dynamodb delete-table --table-name gfn-reports-aggregates --profile compass. Because we picked on-demand mode and added no items, deletion is instant and free.
| Trap | Fix |
|---|---|
| "My DynamoDB writes throttle even though I provisioned 10,000 WCU" | Low-cardinality PK creating a hot partition. Per-partition ceiling is 1,000 WCU regardless of table capacity. Re-shard the PK. |
| "My on-demand DynamoDB bill exploded as we grew" | Workload stabilized but billing mode didn't. Switch to provisioned + auto-scaling once p95 is within 2x of p50. |
| "My Lambda exhausts Postgres connections" | Add RDS Proxy. Connect Lambdas to the proxy endpoint instead of the DB endpoint. |
| "Aurora Serverless v2 is more expensive than I expected" | You set a min ACU that's too high, or you're paying for I/O on a high-IO workload. Lower min, or switch to I/O-Optimized cluster pricing. |
| "I can't reach my ElastiCache cluster from my laptop" | By design - ElastiCache is VPC-only. Use a bastion host, SSM Session Manager port-forward, or VPN. |