Skip to main content

Database Selection — Postgres vs DynamoDB vs MongoDB vs Cassandra


The Staff-Level Framing

Junior engineers pick a database because they know it. Staff engineers pick a database because the access pattern demands it.

In an interview, saying "I'd use Postgres" without justification is a miss. Saying "I'd use Postgres because this workload is read-heavy with complex multi-table joins, needs ACID transactions for financial correctness, and the dataset fits comfortably in a single vertically-scaled instance for the next 3 years" — that's a staff answer.

The decision framework:

1. What is the access pattern? (point lookups? range scans? joins? aggregations?)
2. What is the consistency need? (strong? eventual? read-your-writes?)
3. What is the write throughput? (100/s? 100K/s?)
4. What is the data shape? (relational? document? wide-column? key-value?)
5. What is the scale trajectory? (GB? TB? PB? growth rate?)
6. What is the operational cost? (team expertise? managed vs self-hosted?)

Quick Decision Matrix

DimensionPostgresDynamoDBMongoDBCassandra
Data ModelRelationalKey-value + documentDocument (BSON)Wide-column
Query FlexibilityExcellent (SQL, joins, CTEs)Limited (PK/SK only)Good (rich queries, aggregation)Poor (partition key required)
TransactionsFull ACID, multi-rowLimited (25 items, single region)Multi-document ACID (4.0+)Lightweight only (Paxos)
Write ThroughputModerate (~10K/s single node)Very high (unlimited w/ partitioning)High (~50K/s sharded)Extreme (~1M/s+)
Read Latency1–10msSingle-digit ms (consistent)1–10ms1–10ms
Horizontal ScaleHard (Citus, manual sharding)Automatic, transparentSharding (built-in, operational overhead)Native, linear
ConsistencyStrong (default)Configurable (eventual/strong)TunableTunable (ONE/QUORUM/ALL)
Secondary IndexesYes (unlimited, powerful)GSI/LSI (limited, costly)Yes (rich)Limited (anti-pattern at scale)
Best ForRelational, transactionalPredictable KV at scaleFlexible schema, iteration speedWrite-heavy, time-series
Ops BurdenMedium (self-hosted) / Low (RDS)Very low (fully managed)Medium-HighHigh

Postgres

When to Pick It

Default choice. Start here unless you have a specific reason not to. Postgres is the most versatile database and handles far more scale than most engineers assume — a well-tuned db.r6g.8xlarge handles ~50K QPS and 10TB comfortably.

Pick Postgres when:

  • You need ACID transactions across multiple entities (financial systems, inventory, bookings)
  • Your queries involve joins across normalized tables
  • You need complex aggregations (GROUP BY, window functions, CTEs)
  • Data has strong relational integrity requirements (foreign keys matter)
  • You need flexible querying where access patterns will evolve
  • Dataset fits in a single instance (up to ~10TB) for the foreseeable future
  • You want rich extensions: PostGIS (geo), pgvector (embeddings), TimescaleDB (time-series), full-text search

Don't pick Postgres when:

  • Write throughput exceeds ~20K/s sustained and can't be batched
  • You need seamless multi-region active-active writes
  • Dataset will exceed 50TB with no natural partitioning key

Design Pattern Examples

1. Payment / Order System

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 'sender' AND balance >= 100;
UPDATE accounts SET balance = balance + 100 WHERE id = 'receiver';
INSERT INTO transactions (from_id, to_id, amount, status) VALUES (...);
COMMIT;

Why Postgres: Multi-row ACID transaction is non-negotiable. A partial write (debit without credit) is a business catastrophe. No other database on this list handles this as cleanly.

2. Yelp Business Metadata + Geo Search

CREATE INDEX idx_businesses_geo ON businesses USING GIST (ll_to_earth(lat, lng));

SELECT b.*, AVG(r.rating) as avg_rating
FROM businesses b
JOIN reviews r ON r.business_id = b.id
WHERE earth_distance(ll_to_earth(b.lat, b.lng), ll_to_earth(37.77, -122.41)) < 5000
AND b.category = 'restaurant'
GROUP BY b.id
HAVING AVG(r.rating) >= 4.0;

Why Postgres: PostGIS geo indexing + joins + aggregation in one query. Business metadata is relational (business → categories → hours → owner).

3. Multi-Tenant SaaS

-- Row-level security enforces tenant isolation
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.current_tenant')::uuid);

Why Postgres: RLS gives database-enforced tenant isolation. Combined with schema-per-tenant or shared-table-with-tenant-id patterns.

4. Ticketmaster Seat Reservation

BEGIN;
SELECT * FROM seats WHERE event_id = ? AND seat_no = ? FOR UPDATE; -- pessimistic lock
UPDATE seats SET status = 'reserved', held_until = now() + interval '10 min' WHERE id = ?;
COMMIT;

Why Postgres: SELECT ... FOR UPDATE prevents double-booking. Row-level locking with proper isolation levels is exactly what this problem needs.


DynamoDB

When to Pick It

DynamoDB is a fully managed key-value store with predictable single-digit millisecond latency at any scale. The trade: you must know your access patterns upfront and design your keys around them.

Pick DynamoDB when:

  • Access patterns are known and stable (point lookups by ID, range queries on a sort key)
  • You need predictable latency at massive scale with zero ops burden
  • Traffic is spiky or unpredictable (on-demand mode auto-scales)
  • You're on AWS and want serverless integration (Lambda, API Gateway)
  • Write throughput is very high and naturally partitionable
  • You need TTL-based expiry (built-in, free)

Don't pick DynamoDB when:

  • Access patterns will change frequently (schema migration is painful)
  • You need ad-hoc queries or analytics (requires export to S3/Athena)
  • You need joins or complex aggregations
  • Your data has low cardinality partition keys (hot partition problem)

Key Design Is Everything

Single-table design (the DynamoDB way):

PK | SK | Attributes
--------------------|-----------------------|------------------
USER#123 | PROFILE | name, email, created
USER#123 | ORDER#2026-08-01#a1 | total, status, items
USER#123 | ORDER#2026-08-02#b2 | total, status, items
ORDER#a1 | METADATA | user_id, restaurant_id
RESTAURANT#456 | MENU#item1 | name, price

Query patterns satisfied:
- Get user profile: PK=USER#123, SK=PROFILE
- Get user's orders: PK=USER#123, SK begins_with ORDER#
- Get orders in range: PK=USER#123, SK between ORDER#2026-08-01 and ORDER#2026-08-31

Design Pattern Examples

1. URL Shortener (TinyURL / Bitly)

Table: short_urls
PK: short_code (e.g., "abc123")
Attributes: long_url, user_id, created_at, click_count, ttl

Access pattern: GetItem(short_code) → redirect

Why DynamoDB: Pure key-value lookup. Billions of URLs, single-digit ms latency, no joins ever needed. Auto-scales to any read volume. TTL handles expiring links for free.

2. Session Store / Feature Flags

PK: session_id
Attributes: user_id, permissions, expires_at (TTL attribute)

Why DynamoDB: Point lookups, high read volume, automatic expiry via TTL, no relational needs.

3. Shopping Cart

PK: USER#123
SK: CART#ITEM#sku456
Attributes: quantity, price_snapshot, added_at

Why DynamoDB: Query all cart items with PK=USER#123, SK begins_with CART#. High write volume (add/remove), no joins, needs low latency.

4. IoT / Device State

PK: device_id
SK: timestamp (for time-range queries)
Attributes: telemetry payload

Why DynamoDB: Massive write throughput, partitioned naturally by device, range queries on time.

Hot partition warning: If one partition key gets disproportionate traffic (e.g., a viral URL), you'll throttle. Mitigate with write sharding: PK = short_code#\{random(0-9)\} and scatter-gather on read, or cache hot keys in DAX/Redis.


MongoDB

When to Pick It

MongoDB is a document database — data stored as nested BSON documents. Its advantage is schema flexibility and the ability to store an entire aggregate in one document (no joins needed).

Pick MongoDB when:

  • Data is naturally hierarchical/nested (product catalogs with varying attributes, CMS content)
  • Schema evolves rapidly (startups iterating on data model)
  • You need rich queries without a rigid schema (unlike DynamoDB)
  • Read pattern is "fetch the whole document" (denormalized aggregate)
  • You need aggregation pipelines for analytics-lite workloads
  • Team velocity matters more than storage efficiency

Don't pick MongoDB when:

  • You need strict multi-entity ACID guarantees at high volume (possible since 4.0, but Postgres is better)
  • Data is highly relational with many-to-many relationships
  • You need mature SQL tooling / BI integration
  • Cross-document joins ($lookup) become a hot path — this is a design smell

Design Pattern Examples

1. E-Commerce Product Catalog

{
_id: ObjectId("..."),
sku: "LAPTOP-X1",
name: "ThinkPad X1 Carbon",
category: ["Electronics", "Laptops"],
price: { amount: 1499, currency: "USD" },
attributes: { // varies wildly by product type
cpu: "Intel i7-1355U",
ram_gb: 16,
screen: { size_inch: 14, resolution: "1920x1200" }
},
variants: [
{ sku: "LAPTOP-X1-16-512", ram: 16, storage: 512, stock: 42 },
{ sku: "LAPTOP-X1-32-1TB", ram: 32, storage: 1024, stock: 8 }
],
reviews_summary: { count: 1203, avg: 4.6 }
}

Why MongoDB: Product attributes differ radically between categories (laptop vs t-shirt vs book). A relational schema would need EAV tables or dozens of nullable columns. One document = one product page render.

2. CMS / Content Platform

{
_id: "article-123",
title: "System Design Guide",
blocks: [ // arbitrary nested content blocks
{ type: "heading", level: 2, text: "..." },
{ type: "code", lang: "python", content: "..." },
{ type: "image", url: "...", caption: "..." }
],
metadata: { author, tags, published_at, seo: {...} }
}

Why MongoDB: Content structure is arbitrary and evolves. Fetching an article = one document read.

3. User Profile with Varying Fields

{
_id: "user-456",
email: "...",
preferences: { theme: "dark", notifications: { email: true, push: false } },
integrations: { // some users have none, some have many
slack: { workspace_id, token_ref },
github: { username, scopes: [...] }
}
}

Why MongoDB: Sparse, nested, optional fields. In Postgres this would be a JSONB column — at which point ask: why not just use Mongo?

Counter-argument: Postgres JSONB gives you 80% of MongoDB's flexibility plus full relational capability. Many teams that chose MongoDB for flexibility later regret losing joins and transactions. In an interview, mention this trade-off — it signals maturity.


Cassandra

When to Pick It

Cassandra is a wide-column store optimized for extreme write throughput and linear horizontal scaling. It uses an LSM-tree storage engine (writes go to an in-memory memtable, flushed sequentially to disk).

Pick Cassandra when:

  • Write throughput is extreme (100K–1M+ writes/sec)
  • Data is append-only or time-series (events, logs, metrics, messages)
  • You need linear scalability — add nodes, get proportional capacity
  • You need multi-region active-active writes with tunable consistency
  • Query pattern is always "give me rows for partition X, ordered by clustering key"
  • No single point of failure is a hard requirement (masterless architecture)

Don't pick Cassandra when:

  • You need ad-hoc queries or joins
  • Read patterns are unknown or will change (schema is query-driven, hard to change)
  • Data volume is small (<1TB) — operational complexity isn't justified
  • You need strong consistency by default
  • Your team lacks Cassandra operational expertise

The Cardinal Rule: Model Tables Around Queries

In Cassandra you denormalize aggressively and create one table per query pattern.

-- Query: "get recent messages in a chat room"
CREATE TABLE messages_by_room (
room_id UUID,
created_at TIMESTAMP,
message_id TIMEUUID,
sender_id UUID,
content TEXT,
PRIMARY KEY ((room_id), created_at, message_id)
) WITH CLUSTERING ORDER BY (created_at DESC);

-- Query: "get all messages sent by a user" — SEPARATE TABLE, duplicated data
CREATE TABLE messages_by_sender (
sender_id UUID,
created_at TIMESTAMP,
message_id TIMEUUID,
room_id UUID,
content TEXT,
PRIMARY KEY ((sender_id), created_at, message_id)
) WITH CLUSTERING ORDER BY (created_at DESC);

Writing a message writes to both tables. Storage is cheap; query latency is not.

Design Pattern Examples

1. WhatsApp / Chat Messages

PRIMARY KEY ((conversation_id), sent_at DESC, message_id)

Why Cassandra: Billions of messages, append-only, always queried as "latest N messages in conversation X". Write throughput is enormous. Perfect partition key (conversation_id) with natural time ordering.

2. Yelp Reviews

CREATE TABLE reviews_by_business (
business_id UUID,
created_at TIMESTAMP,
review_id UUID,
user_id UUID,
rating INT,
text TEXT,
PRIMARY KEY ((business_id), created_at, review_id)
) WITH CLUSTERING ORDER BY (created_at DESC);

Why Cassandra: Append-only, 500K writes/day, always read as "latest reviews for business X". Business metadata stays in Postgres; reviews live in Cassandra.

3. Time-Series Metrics / Monitoring

CREATE TABLE metrics (
metric_name TEXT,
bucket_day DATE, -- bucket to prevent unbounded partitions
ts TIMESTAMP,
value DOUBLE,
tags MAP<TEXT,TEXT>,
PRIMARY KEY ((metric_name, bucket_day), ts)
) WITH CLUSTERING ORDER BY (ts DESC)
AND default_time_to_live = 2592000; -- 30-day TTL

Why Cassandra: Millions of writes/sec, time-ordered reads, automatic TTL expiry, linear scale. (TimescaleDB or ClickHouse are also strong candidates here.)

4. Activity Feed / Event Log

PRIMARY KEY ((user_id), event_time DESC, event_id)

Why Cassandra: Write-heavy fanout, read as "recent activity for user X", no joins needed.

Partition size warning: Keep partitions under ~100MB / 100K rows. An unbounded partition (e.g., all messages for a 10-year-old chat room) causes hotspots and slow reads. Bucket by time ((room_id, year_month)) to bound partition growth.


Polyglot Persistence — The Realistic Answer

Real systems use multiple databases, each for what it's best at. In an interview, showing this is a strong signal.

Example: Yelp

Postgres → business metadata, users, categories (relational, ACID)
Cassandra → reviews (append-only, high write, time-ordered reads)
Elasticsearch → search index (geo + full-text + faceted filters)
Redis → hot business profile cache, trending sorted sets
S3 + CloudFront → photos

Example: DoorDash / Local Delivery

Postgres → orders (ACID, state machine, payment correctness)
Redis → driver live locations (geospatial index, sub-second updates)
Cassandra → delivery event log / driver location history
Kafka → event backbone (order created → matching → notification)
DynamoDB → session store, feature flags

Example: Netflix / Video Streaming

Cassandra → viewing history, playback bookmarks (write-heavy, per-user partition)
DynamoDB → user session state, A/B test assignments
Postgres/MySQL → billing, subscriptions (ACID)
Elasticsearch → title search
S3 + CDN → video segments

Decision Flowchart


Interview Talking Points

"Why not just use Postgres for everything?"

Often you should. Postgres handles far more than people assume. The honest limits:

  • Write ceiling: ~20–50K writes/s on a large instance before you need sharding
  • Storage ceiling: ~10–50TB before operations get painful
  • Multi-region writes: Postgres has no native active-active; you need Citus/CockroachDB/Spanner
  • Elastic scale: Adding capacity requires planning; DynamoDB/Cassandra do it transparently

Answer framing: "I'd start with Postgres because it maximizes optionality — access patterns will change and Postgres handles that. I'd move a specific workload off Postgres when I have evidence it's hitting a limit: sustained write throughput above ~20K/s, or a table growing past ~5TB with a clear partition key."

"How would you migrate from Postgres to Cassandra?"

  1. Identify the specific table hitting write limits (usually append-only: events, messages, reviews)
  2. Design Cassandra schema query-first — one table per access pattern
  3. Dual-write phase: write to both Postgres and Cassandra, read from Postgres
  4. Backfill historical data via batch job with rate limiting
  5. Verify parity — run shadow reads against both, diff results
  6. Cut over reads to Cassandra behind a feature flag, gradual percentage rollout
  7. Stop writes to Postgres, archive old table

"What about NewSQL — CockroachDB, Spanner, Vitess?"

Worth mentioning as a middle ground: SQL interface + ACID + horizontal scale. Trade-offs: higher write latency (distributed consensus), higher cost, smaller ecosystem. Good answer when the interviewer pushes on "what if you need Postgres semantics at DynamoDB scale?"

Red Flags to Avoid

Don't SaySay Instead
"MongoDB because it's web scale""MongoDB because the product catalog has heterogeneous attributes per category"
"NoSQL is faster than SQL""DynamoDB gives predictable single-digit ms latency for point lookups because it avoids query planning"
"I'd shard from day one""I'd start single-instance and design a clear shard key so sharding is possible when we hit ~20K writes/s"
"Cassandra for everything write-heavy""Cassandra if writes are append-only with a natural partition key; otherwise batching into Postgres may suffice"

Cheat Sheet

Use CaseDatabaseWhy
Payments, orders, inventoryPostgresMulti-row ACID is mandatory
URL shortener, session storeDynamoDBPure KV, predictable latency, zero ops
Chat messages, activity feedCassandraAppend-only, time-ordered, extreme write volume
Product catalog, CMSMongoDBHeterogeneous nested documents
Geo search (Yelp, Uber)Postgres + PostGIS or ElasticsearchGeo indexing + filters
Time-series metricsCassandra / TimescaleDB / ClickHouseWrite-optimized, TTL, time-partitioned
Leaderboard, rate limitingRedisIn-memory sorted sets, atomic ops
Full-text + faceted searchElasticsearchInverted index, relevance scoring
Analytics / OLAPClickHouse / Snowflake / BigQueryColumnar, aggregation-optimized
Graph traversal (social, fraud)Neo4j / Postgres recursive CTEMulti-hop relationships