Managed Databases

AWS offers fully managed relational, NoSQL, graph, time-series, and ledger databases. Choosing the right engine is one of the most consequential architectural decisions you will make.

Introduction

Running databases on EC2 means managing OS patching, replication, backups, and failover yourself. AWS managed database services hand all of that to AWS. You provision, connect, and query; AWS handles the rest. The right database depends entirely on your access patterns, consistency requirements, and scale. This lesson covers eight purpose-built AWS database services and when to use each one.

Quick Navigation

1. RDS

Managed relational SQL

2. Aurora

Cloud-native relational

3. DynamoDB

Serverless NoSQL key-value

4. ElastiCache

In-memory Redis/Memcached

5. DocumentDB

MongoDB-compatible docs

6. Neptune

Graph database

7. Timestream

Time-series / IoT metrics

8. QLDB

Immutable ledger

Typical Data Tier Architecture

DATA TIER (PRIVATE SUBNETS)EC2App TierRDSRDSMulti-AZAuAuroraServerless v2DDBDynamoDBEC$ElastiCacheRedisSQLSQLkey-valuecache-aside

RDS for standard relational, Aurora for cloud-native relational, DynamoDB for high-throughput key-value, ElastiCache for caching hot reads

1. RDS - Relational Database Service

RDS runs fully managed PostgreSQL, MySQL, MariaDB, Oracle, or SQL Server. AWS handles OS patching, automated backups (up to 35 days retention), and monitoring. You just run SQL.

Multi-AZ Deployment

AWS maintains a synchronous standby in a second AZ. On failure, DNS automatically fails over to the standby in ~60 seconds. Use for production, it adds ~2x cost but eliminates single-AZ risk.

Read Replicas

Asynchronous copies of the primary for read scaling. Point your reporting queries at a replica to offload the primary. Replicas can also be promoted to standalone instances.

# Create a PostgreSQL RDS instance (single-AZ for dev)
aws rds create-db-instance \
  --db-instance-identifier my-postgres \
  --db-instance-class db.t3.micro \
  --engine postgres \
  --engine-version 16.2 \
  --master-username admin \
  --master-user-password "S3cr3tP@ss!" \
  --allocated-storage 20 \
  --db-subnet-group-name my-db-subnet-group \
  --vpc-security-group-ids sg-0123456789abcdef0 \
  --no-publicly-accessible

# Wait until it's available (takes ~5 minutes)
aws rds wait db-instance-available \
  --db-instance-identifier my-postgres

# Get the endpoint
aws rds describe-db-instances \
  --db-instance-identifier my-postgres \
  --query 'DBInstances[0].Endpoint.Address' --output text

2. Aurora - AWS's Cloud-Native Relational Database

Aurora is AWS's proprietary database engine, compatible with both MySQL and PostgreSQL. Unlike standard RDS, which attaches EBS volumes to compute instances, Aurora uses a distributed storage layer: data is automatically replicated 6 ways across 3 AZs, decoupled from the compute tier. This eliminates the synchronous standby lag of Multi-AZ RDS and lets Aurora add read replicas in seconds rather than minutes.

Aurora vs Standard RDS
  • Storage: distributed, auto-scales 10 GB to 128 TB with no pre-provisioning
  • Read replicas: up to 15 per cluster (same as RDS MySQL/PostgreSQL), created in seconds vs minutes for standard RDS
  • Failover: typically under 30 seconds (vs ~60 s for RDS Multi-AZ)
  • Cost: higher per-instance price; offset by eliminating separate replica storage charges
Aurora Serverless v2

Compute scales in fine-grained ACU (Aurora Capacity Unit) increments from 0.5 to 256 ACUs. The database is always online: it scales within seconds, not minutes, and there is no cold start.

  • Billed per second for the compute actually used
  • Ideal for variable workloads: dev/test environments, APIs with spiky traffic
  • Works well behind Lambda and ECS; scales down during quiet periods automatically
# Create an Aurora PostgreSQL Serverless v2 cluster
aws rds create-db-cluster \
  --db-cluster-identifier my-aurora-cluster \
  --engine aurora-postgresql \
  --engine-version 15.4 \
  --serverless-v2-scaling-configuration MinCapacity=0.5,MaxCapacity=8 \
  --database-name appdb \
  --master-username admin \
  --master-user-password "S3cr3tP@ss!" \
  --db-subnet-group-name my-db-subnet-group \
  --vpc-security-group-ids sg-0123456789abcdef0

# Add a Serverless v2 writer instance to the cluster
aws rds create-db-instance \
  --db-instance-identifier my-aurora-writer \
  --db-instance-class db.serverless \
  --engine aurora-postgresql \
  --db-cluster-identifier my-aurora-cluster

# Get the cluster endpoint (writer)
aws rds describe-db-clusters \
  --db-cluster-identifier my-aurora-cluster \
  --query 'DBClusters[0].Endpoint' --output text

Aurora Global Database: for multi-region disaster recovery or low-latency global reads, Aurora Global Database replicates to up to 5 secondary regions with typically under 1 second lag. A secondary region can be promoted to primary in under 1 minute during a regional outage.

3. DynamoDB - Serverless NoSQL

DynamoDB is a fully managed key-value and document database that scales to any throughput automatically. There are no servers to manage, no connections to pool, and no schema to migrate. Every table has a partition key (required) and an optional sort key.

Design principle: DynamoDB access patterns must be known at design time. You design the table around your queries, not the other way around. Think "what queries will I run?" before creating the table.

Use GSIs (Global Secondary Indexes) to query on non-key attributes. Each GSI has its own partition and sort key and its own read/write capacity.

# Create a DynamoDB table (orders with composite key)
aws dynamodb create-table \
  --table-name Orders \
  --attribute-definitions \
    AttributeName=userId,AttributeType=S \
    AttributeName=orderId,AttributeType=S \
  --key-schema \
    AttributeName=userId,KeyType=HASH \
    AttributeName=orderId,KeyType=RANGE \
  --billing-mode PAY_PER_REQUEST

# Put an item
aws dynamodb put-item \
  --table-name Orders \
  --item '{
    "userId":  {"S": "user-123"},
    "orderId": {"S": "ord-456"},
    "status":  {"S": "pending"},
    "total":   {"N": "49.99"}
  }'

# Query all orders for a user (efficient - uses the partition key)
aws dynamodb query \
  --table-name Orders \
  --key-condition-expression "userId = :uid" \
  --expression-attribute-values '{":uid": {"S": "user-123"}}'

The Python boto3 resource interface is more ergonomic for application code:

import boto3
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("Orders")

# Query orders for a user
response = table.query(
    KeyConditionExpression=Key("userId").eq("user-123")
)
orders = response["Items"]

# Put a new order
table.put_item(Item={
    "userId":  "user-123",
    "orderId": "ord-789",
    "status":  "pending",
    "total":   49.99,
})

# Update status
table.update_item(
    Key={"userId": "user-123", "orderId": "ord-789"},
    UpdateExpression="SET #s = :status",
    ExpressionAttributeNames={"#s": "status"},
    ExpressionAttributeValues={":status": "shipped"},
)

4. ElastiCache - In-Memory Caching

ElastiCache provides managed Redis or Memcached clusters. Redis is by far the more popular choice, it supports rich data structures (lists, sets, sorted sets, hashes), pub/sub, Lua scripting, persistence, and cluster mode.

Cache-Aside Pattern

1. Check Redis. Hit - return cached value.
2. Miss - query RDS.
3. Store result in Redis with a TTL.
4. Return to caller.
Most API reads are cache hits after warm-up.

Other Use Cases
  • Session storage (stateless app servers)
  • Rate limiting counters (INCR + EXPIRE)
  • Leaderboards (sorted sets)
  • Pub/sub for lightweight messaging

Specialized Databases

Beyond the general-purpose engines above, AWS offers four purpose-built databases, each designed for a data model that relational and key-value stores handle poorly. You reach for one of these when your workload has a shape that a general-purpose engine would force you to work around: deeply nested documents with ad-hoc queries, highly connected data where traversing relationships is the query, append-only time-ordered measurements, or an audit trail that must be tamper-evident by construction.

Specialized AWS Databases

SPECIALIZED DATABASES (PRIVATE SUBNETS)EC2App TierDocDocumentDBDocument storeNptNeptuneGraph DBTSTimestreamTime-seriesQLDBQLDBLedgerMongoDB APIopenCyphermetricsPartiQL

Each specialized database targets a distinct workload shape: documents, graphs, time-ordered metrics, and immutable audit trails


5. DocumentDB - MongoDB-Compatible Document Database

DocumentDB is a fully managed document database with MongoDB API compatibility. It stores JSON-like documents with flexible schemas, supports rich ad-hoc queries, and includes a MongoDB-compatible aggregation pipeline. Unlike open-source MongoDB, DocumentDB is an AWS re-implementation that runs on a distributed storage layer similar to Aurora.

DocumentDB vs DynamoDB
  • Query flexibility: DocumentDB supports ad-hoc queries, $lookup (joins), and aggregation pipelines; DynamoDB requires access patterns to be defined at design time
  • Schema: DocumentDB is schema-free at the document level; DynamoDB is schema-free too, but its key structure must be fixed
  • Scale model: DynamoDB is truly serverless and scales to millions of req/s; DocumentDB uses a cluster model with always-on instances
  • Pricing: DynamoDB charges per request; DocumentDB charges per instance-hour regardless of load
When to Choose DocumentDB
  • Migrating an existing MongoDB workload to AWS without a rewrite
  • Content management, product catalogs, or user profiles where documents have variable nested structure
  • You need rich ad-hoc queries or aggregation pipelines across documents without knowing access patterns upfront
  • Your team already knows the MongoDB query language and you want to preserve that skill set
# Create a DocumentDB cluster (MongoDB-compatible)
aws docdb create-db-cluster \
  --db-cluster-identifier my-docdb-cluster \
  --engine docdb \
  --engine-version 5.0.0 \
  --master-username admin \
  --master-user-password "S3cr3tP@ss!" \
  --db-subnet-group-name my-db-subnet-group \
  --vpc-security-group-ids sg-0123456789abcdef0

# Add a primary instance to the cluster
aws docdb create-db-instance \
  --db-instance-identifier my-docdb-instance \
  --db-instance-class db.r6g.large \
  --engine docdb \
  --db-cluster-identifier my-docdb-cluster

# Connect and query with pymongo (DocumentDB uses TLS by default)
# Download the CA bundle first:
# wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem

Python application code uses pymongo, the standard MongoDB driver:

import pymongo

# Connect to DocumentDB (TLS required)
# Use host/username/password kwargs - avoid embedding special chars in the URI
client = pymongo.MongoClient(
    host="my-docdb-cluster.cluster-xyz.us-east-1.docdb.amazonaws.com",
    port=27017,
    username="admin",
    password="S3cr3tP@ss!",
    tls=True,
    tlsCAFile="global-bundle.pem",
    retryWrites=False,  # DocumentDB does not support retryWrites
)

db = client["catalog"]
products = db["products"]

# Insert a document (flexible schema - no migration needed)
products.insert_one({
    "sku": "WIDGET-42",
    "name": "Blue Widget",
    "tags": ["sale", "featured"],
    "specs": {"weight_kg": 0.5, "color": "blue"},
    "price": 19.99,
})

# Rich query: find all featured products under $25 sorted by price
results = products.find(
    {"tags": "featured", "price": {"$lt": 25}},
    sort=[("price", pymongo.ASCENDING)],
)

# Aggregation pipeline: average price by tag
pipeline = [
    {"$unwind": "$tags"},
    {"$group": {"_id": "$tags", "avg_price": {"$avg": "$price"}}},
    {"$sort": {"avg_price": -1}},
]
for bucket in products.aggregate(pipeline):
    print(bucket)

Cost note: DocumentDB always has at least one running instance (minimum db.t3.medium). Unlike DynamoDB, there is no "scale to zero." For lightweight or sporadic workloads, DynamoDB on-demand is almost always cheaper.

6. Neptune - Graph Database

Neptune is a fully managed graph database purpose-built for workloads where the relationships between data are as important as the data itself. It stores data as a property graph: vertices (nodes) connected by edges, each with arbitrary key-value properties. Neptune supports two graph query languages: openCypher (used by Neo4j) and Gremlin (Apache TinkerPop).

When Graphs Win
  • Social networks: friends-of-friends, influence scoring, community detection
  • Fraud detection: find rings of accounts connected through shared devices, addresses, or phone numbers
  • Recommendation engines: "users who bought X also bought Y" via multi-hop traversals
  • Knowledge graphs: entities and relationships in healthcare, finance, supply chain
  • Network topology: IT infrastructure dependencies, impact analysis
Neptune Analytics

Neptune Analytics is an in-memory graph engine for analytical traversals on large graphs. It loads a snapshot of your Neptune data and runs algorithms (PageRank, community detection, shortest paths) in seconds rather than minutes.

  • Serverless: pay per query, scales to zero
  • Supports openCypher and vector similarity search on graph embeddings
  • Ideal for offline analytics, not real-time OLTP graph queries
# Create a Neptune cluster (graph database)
aws neptune create-db-cluster \
  --db-cluster-identifier my-neptune-cluster \
  --engine neptune \
  --engine-version 1.3.1.0 \
  --db-subnet-group-name my-db-subnet-group \
  --vpc-security-group-ids sg-0123456789abcdef0 \
  --storage-encrypted

# Add a primary instance to the cluster
aws neptune create-db-instance \
  --db-instance-identifier my-neptune-instance \
  --db-instance-class db.r6g.large \
  --engine neptune \
  --db-cluster-identifier my-neptune-cluster

# Get the cluster endpoint
aws neptune describe-db-clusters \
  --db-cluster-identifier my-neptune-cluster \
  --query 'DBClusters[0].Endpoint' --output text

Neptune Analytics uses the neptune-graph boto3 client to run openCypher queries against an in-memory graph snapshot:

# Neptune supports openCypher (graph query language)
# Use the neptune-graph SDK or boto3 neptune-graph client

import boto3

# For Neptune Analytics (in-memory graph - fast traversals)
client = boto3.client("neptune-graph", region_name="us-east-1")

# openCypher query: find all fraud rings (accounts connected through shared devices)
query = """
MATCH (a1:Account)-[:USED]->(d:Device)<-[:USED]-(a2:Account)
WHERE a1 <> a2
  AND a1.flagged = true
RETURN a2.accountId AS suspicious, d.deviceId AS sharedDevice
LIMIT 100
"""

response = client.execute_query(
    graphIdentifier="my-neptune-graph-id",
    queryString=query,
    language="OPEN_CYPHER",
)

# openCypher query: shortest path between two users (social graph)
path_query = """
MATCH p = shortestPath(
  (u1:User {id: 'user-001'})-[*]-(u2:User {id: 'user-999'})
)
RETURN length(p) AS hops, [n IN nodes(p) | n.id] AS path
"""

Graph modeling tip: model entities as vertices and relationships as edges. Put properties that describe the relationship (e.g., since, weight) directly on the edge, not on a separate vertex. The more semantics you encode in edges, the more expressive your traversals become.

7. Timestream - Time-Series Database

Timestream is a fully serverless time-series database optimized for IoT telemetry, DevOps metrics, and application monitoring. Every record is a measurement with a timestamp, a set of dimensions (device ID, region, etc.), and one or more measure values. Timestream automatically tiers data from a fast in-memory store to cost-efficient magnetic storage as it ages.

Automatic Tiered Storage
  • Memory store: recent data (hours to days); optimized for real-time queries and fast ingestion
  • Magnetic store: historical data (months to years); S3-backed, queried less frequently
  • You set the retention window for each tier per table; Timestream moves data automatically
  • Queries span both tiers transparently, no join or union required
Built-in Time-Series Functions

Timestream extends SQL with functions that would require complex window aggregations in a general-purpose database:

  • bin(time, 5m) - group timestamps into fixed buckets
  • interpolate_linear() - fill gaps between sparse readings
  • time_series() - construct time-series objects for ML feature engineering
  • ago(1h) - relative time filter without epoch arithmetic
# Create a Timestream database and table
aws timestream-write create-database \
  --database-name IoTMetrics

aws timestream-write create-table \
  --database-name IoTMetrics \
  --table-name SensorReadings \
  --retention-properties '{
    "MemoryStoreRetentionPeriodInHours": 24,
    "MagneticStoreRetentionPeriodInDays": 365
  }'

Write records and run analytical queries with the Python boto3 client:

import boto3
from datetime import datetime, timezone

write_client = boto3.client("timestream-write", region_name="us-east-1")
query_client = boto3.client("timestream-query", region_name="us-east-1")

# Write a batch of sensor readings
now_ms = str(int(datetime.now(timezone.utc).timestamp() * 1000))

write_client.write_records(
    DatabaseName="IoTMetrics",
    TableName="SensorReadings",
    Records=[
        {
            "Dimensions": [
                {"Name": "device_id",  "Value": "sensor-42"},
                {"Name": "location",   "Value": "warehouse-A"},
            ],
            "MeasureName":  "temperature",
            "MeasureValue": "23.7",
            "MeasureValueType": "DOUBLE",
            "Time": now_ms,
        },
    ],
)

# Query: average temperature per device over the last hour
# Uses Timestream SQL with built-in time functions
sql = """
SELECT device_id,
       bin(time, 5m)                AS five_min_window,
       avg(measure_value::double)   AS avg_temp,
       max(measure_value::double)   AS peak_temp
FROM "IoTMetrics"."SensorReadings"
WHERE measure_name = 'temperature'
  AND time >= ago(1h)
GROUP BY device_id, bin(time, 5m)
ORDER BY five_min_window DESC
"""

response = query_client.query(QueryString=sql)
for row in response["Rows"]:
    print([col.get("ScalarValue") for col in row["Data"]])

8. QLDB - Quantum Ledger Database

QLDB is a fully managed ledger database with an immutable, cryptographically verifiable transaction journal. Every insert, update, and delete is appended to the journal as a new entry; nothing is ever overwritten or deleted. AWS uses a SHA-256 hash chain across journal entries, so any tampering with historical data is mathematically detectable. QLDB uses PartiQL, a SQL-compatible query language, for all data access.

How Immutability Works
  • Every transaction appends a new block to the journal; previous blocks are sealed and hashed
  • The history() function returns every revision of a document, not just the current state
  • You can request a cryptographic proof (digest + Merkle path) that any revision existed at a specific point in time
  • Designed for single-owner, trusted-party systems, not decentralized consensus
Use Cases
  • Financial systems: bank account ledgers, payment histories, regulatory audit trails
  • Supply chain: provenance tracking - who touched this item at every step
  • Medical records: immutable patient history with tamper-evident proof
  • Insurance claims: policy changes and claim lifecycle with full history
  • HR systems: salary and role changes with cryptographic audit trail
# Create a QLDB ledger
aws qldb create-ledger \
  --name my-finance-ledger \
  --permissions-mode STANDARD \
  --deletion-protection  # prevents accidental deletion

# Describe the ledger (wait until ACTIVE)
aws qldb describe-ledger --name my-finance-ledger

Application code uses the pyqldb driver to execute PartiQL transactions:

from datetime import datetime, timezone
from pyqldb.driver.qldb_driver import QldbDriver

driver = QldbDriver(ledger_name="my-finance-ledger")

def create_tables(transaction_executor):
    transaction_executor.execute_statement(
        "CREATE TABLE Accounts"
    )
    transaction_executor.execute_statement(
        "CREATE INDEX ON Accounts (accountId)"
    )

def record_transfer(transaction_executor, from_id, to_id, amount):
    # QLDB uses PartiQL (SQL-compatible)
    # Every INSERT/UPDATE is appended to the immutable journal
    now_iso = datetime.now(timezone.utc).isoformat()
    transaction_executor.execute_statement(
        "UPDATE Accounts SET balance = balance - ? WHERE accountId = ?",
        amount, from_id,
    )
    transaction_executor.execute_statement(
        "UPDATE Accounts SET balance = balance + ? WHERE accountId = ?",
        amount, to_id,
    )
    transaction_executor.execute_statement(
        "INSERT INTO Transfers VALUE {'from': ?, 'to': ?, 'amount': ?, 'ts': ?}",
        from_id, to_id, amount, now_iso,
    )

# Execute as a single ACID transaction
driver.execute_lambda(lambda txn: record_transfer(txn, "acct-001", "acct-002", 500))

# Query the full history of an account (immutable audit trail)
def get_history(transaction_executor, account_id):
    return transaction_executor.execute_statement(
        "SELECT * FROM history(Accounts) WHERE data.accountId = ?",
        account_id,
    )

history = driver.execute_lambda(lambda txn: get_history(txn, "acct-001"))

QLDB vs blockchain: QLDB is a centralized ledger operated by AWS on your behalf. It gives you immutability and cryptographic verification without the complexity or overhead of decentralized consensus. If you need multiple untrusted parties to agree on shared state, use Amazon Managed Blockchain instead.

9. Which Database for Which Problem?

Use CaseBest ChoiceWhy
Complex relational queries, JOINs, transactionsRDS PostgreSQLFull SQL, ACID, foreign keys
High-performance relational with variable or spiky loadAurora / Aurora Serverless v2Faster failover, auto-scaling storage, Serverless v2 scales compute with demand
High-throughput key lookups (millions/sec)DynamoDBSingle-digit ms latency at any scale, truly serverless
Unknown or variable access patternsRDS or Aurora (start here)Easier to refactor; optimize later with DynamoDB
Session data, hot cache, rate limitingElastiCache (Redis)Sub-millisecond, TTL support, rich data types
MongoDB migration or rich document queriesDocumentDBMongoDB-compatible API, ad-hoc queries, aggregation pipeline
Relationship-heavy data (social graphs, fraud, recommendations)NeptuneGraph model; Gremlin/openCypher multi-hop traversals
IoT telemetry, metrics, time-ordered dataTimestreamBuilt-in time functions, automatic memory/magnetic tiering
Immutable audit trail, financial or medical ledgerQLDBCryptographic journal, history() function, PartiQL
Analytics and reporting on large datasetsRedshift or AthenaColumnar storage optimized for aggregations (covered in Big Data course)

Key Takeaways

  • RDS - managed relational SQL; Multi-AZ for HA, read replicas for read scaling
  • Aurora - cloud-native relational engine; distributed storage across 3 AZs, faster failover, Serverless v2 for spiky workloads
  • DynamoDB - serverless NoSQL; design access patterns first, partition key determines performance
  • GSIs - add DynamoDB indexes for non-key query patterns without restructuring the table
  • ElastiCache Redis - in-memory store for caching, sessions, rate limiting; use cache-aside pattern
  • DocumentDB - MongoDB-compatible document store; use when you need rich ad-hoc queries, aggregation pipelines, or are migrating from MongoDB; cluster-based cost model differs from DynamoDB's serverless pricing
  • Neptune - graph database for relationship-centric workloads; Gremlin or openCypher; model vertices and edges when relationships are as important as the data itself
  • Timestream - serverless time-series; automatic memory/magnetic tiering; built-in time functions eliminate custom aggregation logic for IoT and metrics workloads
  • QLDB - cryptographically verifiable ledger; append-only journal guarantees audit integrity for financial, supply chain, and medical records
  • Always in private subnets - databases should never have public IPs; access via your app tier