This comparison comes up in nearly every design round on AWS, usually phrased as "SQL or NoSQL?" That framing is the trap. Postgres is a general-purpose relational engine on one primary: any query you can write in SQL, joins and transactions across any rows, and a write ceiling set by the biggest machine you can buy. DynamoDB is a partitioned key-value service with no servers to run: predictable single-digit-millisecond latency at almost any scale, provided every request names a partition key you designed in advance. The question that decides it: do you know all your access patterns today, and will they still be true in two years? If you do and they are key-shaped at high scale, DynamoDB removes a whole category of operational work. If you don't — and most product teams don't — Postgres lets you be wrong cheaply.
The Verdict#
Default to Postgres. Choose DynamoDB when the access patterns are few, known and key-based, and the scale, spikiness or zero-ops requirement is real — not hoped for.
| Pick Postgres when | Pick DynamoDB when |
|---|---|
| The data is relational and queries will change (admin screens, reporting, new features every sprint) | Every read is "get by key" or "range within a key": sessions, carts, device state, user settings, idempotency keys |
| You need multi-row transactions across arbitrary rows and constraints (foreign keys, unique across columns) | Transactions are small and bounded (≤ 100 items) and you can name the items up front |
| Peak writes fit one primary: up to ~10K–50K simple writes/s on large hardware | Writes exceed what one primary handles, or traffic swings 10–100× (launches, sales, sports events) |
| You want SQL for analytics, ad-hoc debugging and the 3 a.m. "which rows are broken?" question | You want no patching, no failover drills, no vacuum, no connection pooling |
| The team knows SQL and you want one database to serve many shapes of query | You need active-active multi-region with a managed replication story |
The middle options are worth naming, because interviewers like to push past the binary:
| Option | What it buys | What it costs |
|---|---|---|
| Aurora Postgres | Postgres semantics, storage that grows on its own, faster failover, up to 15 replicas | Still one writer; I/O-based pricing can surprise |
| Postgres with JSONB | Schemaless attributes without leaving SQL | Not a scaling answer; just flexibility |
| Distributed SQL | SQL and transactions across many write nodes | Higher per-query latency, more complex operations, cross-region transactions are slow |
| DynamoDB + Streams → warehouse or search | Key-value hot path plus ad-hoc queries elsewhere | Two systems, eventual consistency between them |
🎯 Staff Move: "I'll start with Postgres because I don't trust my access-pattern list yet. If one slice of this — sessions, or the idempotency table — turns into a high-throughput key lookup, I'll move that slice to DynamoDB. Choosing DynamoDB for everything on day one is betting that we already know every query the product will ever need."
At a Glance#
| Dimension | Postgres | DynamoDB |
|---|---|---|
| Data model | Tables, rows, typed columns, foreign keys, JSONB, extensions | Items (≤ 400 KB) in tables; partition key + optional sort key; schemaless attributes |
| Query model | Arbitrary SQL: joins, aggregates, window functions, CTEs | GetItem, Query (one partition key, sort-key range), Scan; 1 MB per page |
| Secondary indexes | Any number, any columns, transactionally consistent | GSIs (20 per table by default, eventually consistent) and LSIs (5, created with the table only) |
| Consistency | Serializable available; read committed by default; replicas asynchronous | Strong or eventual reads on the base table; GSIs eventual only |
| Transactions | Any rows, any size (within reason), any isolation level | ≤ 100 items, ≤ 4 MB, single region; 2× capacity cost |
| Ordering | ORDER BY anything | Sort key order within one partition key only |
| Throughput (order of magnitude) | One primary: ~10K–50K simple writes/s, ~100K+ reads/s with replicas | Effectively unbounded per table; ~1,000 WCU and 3,000 RCU per partition (as of 2026) |
| Latency | Sub-ms to a few ms in-region for indexed lookups; unbounded for bad queries | Single-digit ms for key operations at any scale |
| Scaling model | Vertical primary + read replicas; sharding is your project (app-level, Citus) | Automatic partition splits; you design keys to spread load |
| Operational burden | Medium: vacuum, bloat, connection pooling, failover, major upgrades (lower on managed) | Very low: no servers; you own key design, capacity mode and hot-key monitoring |
| Managed options | Amazon RDS and Aurora, Google Cloud SQL and AlloyDB, Azure, Neon, Supabase, others | AWS only |
| Cost shape | Instance-hours (paid 24/7) + storage + IOPS | Per request (on-demand) or per provisioned capacity, + storage per GB-month |
| Multi-region | Async cross-region replicas, manual or managed promotion | Global tables: multi-region eventual (last writer wins) or strong (3 regions) |
How They Actually Differ#
Query-First vs Data-First Modeling#
In Postgres you model the data — entities, relationships, normal forms — and write queries later. A new feature that needs "all orders over $100 from customers in Ohio last month" is a new index at worst.
In DynamoDB you model the queries. You list every access pattern first, then design partition keys, sort keys and GSIs so each pattern is a single Query. A common production pattern is single-table design: several entity types share one table, with keys like PK = CUSTOMER#42, SK = ORDER#2026-09-30#9001, so "customer and their recent orders" is one request.
Access pattern Postgres DynamoDB
------------------------------------ -------------------------------- ----------------------------------------
Get customer 42 SELECT ... WHERE id = 42 GetItem PK=CUSTOMER#42, SK=PROFILE
Customer 42's last 20 orders ... WHERE customer_id=42 Query PK=CUSTOMER#42,
ORDER BY created DESC LIMIT 20 SK begins_with ORDER#, descending, limit 20
Order 9001 by ID ... WHERE id = 9001 GSI1: PK=ORDER#9001
All orders > $100 in Ohio last month one SQL query + an index not served — export to a warehouse,
or add a GSI designed for exactly this
Who pays: in Postgres the database pays at query time (a slow query, a missing index). In DynamoDB the product team pays at design time — and again every time a new access pattern arrives, because a GSI backfill or a data migration is the price of a query you didn't plan for.
Where the Write Ceiling Lives#
Postgres has one primary. Everything — every insert, update, index maintenance, WAL write — goes through it. On large managed instances that is comfortably thousands to tens of thousands of simple writes per second, and many companies never outgrow it. When they do, the options are vertical scaling, splitting tables into separate databases by domain, then horizontal sharding — a multi-quarter project (see How Real Companies Chose).
DynamoDB has no single primary. A table is split into partitions by hash of the partition key; each partition serves up to ~1,000 write units and ~3,000 read units per second and splits as it grows or heats up. The ceiling is per key, not per table: a table can take millions of writes per second, but one partition key value still lives on one partition.
The standard fix for a hot DynamoDB key is to spread it across N synthetic keys and fan reads back in — a common production pattern that trades read cost for write headroom:
# Write sharding for a hot partition key (e.g. a global counter or a viral post's likes)
N = 10 # 10 shards → ~10,000 writes/s headroom for this key
write(post_id, event):
shard = hash(event.id) % N # deterministic, so retries land on the same shard
PutItem PK = "LIKES#" + post_id + "#" + shard, SK = event.id
read_count(post_id):
total = 0
for shard in 0..N-1: # N parallel GetItem/Query calls
total += Query(PK = "LIKES#" + post_id + "#" + shard).count_attr
return total # or keep a rolled-up total refreshed every few seconds
In Postgres the equivalent hot row (one counter updated by thousands of transactions a second) serializes on a row lock instead; the fix is the same idea — split the counter into N rows and sum them — or move the counter out of the database entirely.
Who pays: Postgres makes the platform team pay when the primary runs out (a resharding project). DynamoDB makes the application team pay when one key gets hot (write sharding, caching, redesign).
Transactions and Constraints#
Postgres gives you the full relational toolkit: foreign keys, unique constraints over any column set, check constraints, SELECT ... FOR UPDATE, serializable isolation. A double-booking prevention rule can be one exclusion constraint.
DynamoDB gives you conditional writes on one item (the workhorse: attribute_not_exists(PK) for idempotency, version numbers for optimistic locking) and TransactWriteItems across up to 100 items and 4 MB, at twice the capacity cost, in one region. Uniqueness on a non-key attribute (say, email) requires a second item whose key is the email, written in the same transaction. It works; it is code you own.
| Requirement | Postgres | DynamoDB |
|---|---|---|
| Unique email | UNIQUE (email) | Transaction: put USER#id + put EMAIL#addr with attribute_not_exists |
| Transfer between accounts | BEGIN; UPDATE ...; UPDATE ...; COMMIT; | TransactWriteItems with condition balance >= :amt |
| Prevent overlapping bookings | Exclusion constraint on a time range | Model each slot as an item; conditional put per slot |
| Cascade delete a customer's data | ON DELETE CASCADE | Query all items under CUSTOMER#42, batch delete (25 per call), handle partial failure |
Read Consistency and Indexes#
Postgres secondary indexes are updated in the same transaction as the row: an index read is as fresh as a table read on the primary. Replicas lag — usually milliseconds, sometimes seconds under heavy write load.
DynamoDB strong reads exist only on the base table and LSIs. Every GSI is eventually consistent, typically sub-second but unbounded under pressure, and a GSI that cannot keep up with writes can throttle writes to the base table. "Look up the order by ID through GSI1 right after creating it" can miss.
Multi-Region#
Postgres multi-region is primary in one region, async replicas elsewhere, and a promotion during a regional failure that loses the replication lag (RPO of seconds) unless you add more machinery. Writes from far regions pay a cross-region round trip.
DynamoDB global tables offer two modes, fixed at creation. Multi-region eventual consistency (MREC): write anywhere, replicate asynchronously (typically within a second), resolve conflicts per item with last writer wins. Multi-region strong consistency (MRSC): exactly three regions (or two plus a witness), RPO of zero, higher write latency, and — as of 2026 — no transactions, TTL or LSIs on those tables.
🎯 Staff Insight: Last writer wins means concurrent updates to the same item in two regions silently lose one. If the item is a counter or a balance, route all writes for a key to one home region, or use MRSC and accept cross-region write latency.
Where Each One Breaks#
Postgres#
| Failure | What happens | Detection | Owner |
|---|---|---|---|
| Connection exhaustion | Each connection is a process (~5–10 MB); a deploy of 400 pods × 20 connections exceeds max_connections | Active connections vs max, connection errors | App team + DBA |
| Vacuum falls behind | Dead tuples bloat tables and indexes; at the extreme, transaction ID wraparound forces the database to stop accepting writes | n_dead_tup, age of oldest XID | DBA / platform |
| Long-running transaction | Holds back vacuum and replication; locks block a migration and a queue forms behind it | pg_stat_activity oldest xact age | App team |
| Replica lag on read-your-writes | User saves, reloads from a replica, sees old data | Replication lag in seconds | App team |
| Primary failover | 30s–2 min of write errors on managed services; app must reconnect and retry | Write error rate, failover events | Platform |
| Bad query plan | Stats change, planner picks a sequential scan on a 500M-row table; p99 goes from 5 ms to 30 s | Slow query log, pg_stat_statements | App team |
DynamoDB#
| Failure | What happens | Detection | Owner |
|---|---|---|---|
| Hot partition key | One key exceeds ~1,000 writes/s; throttling for that key while the table looks idle | ThrottledRequests, CloudWatch Contributor Insights top keys | App team |
| GSI back-pressure | GSI under-provisioned or hot; base table writes throttle | GSI WriteThrottleEvents | App team |
| New access pattern | Product wants a query the keys don't support; answer is a Scan (full table, expensive) or a new GSI plus backfill | Scan usage, RCU spikes | Product + app team |
| Large items | Items near 400 KB cost hundreds of read units per strong read; reads get slow and expensive | Consumed capacity per request | App team |
| On-demand surprise bill | A retry storm or runaway job doubles requests; cost follows instantly | Daily cost anomaly alert | App team + FinOps |
| Pagination bugs | Query returns 1 MB and LastEvaluatedKey; code assumes it got everything | Missing results reports | App team |
Launch Day, Both Ways#
A product launch sends traffic from 2,000 to 40,000 requests/s in five minutes.
Postgres (RDS, 1 primary + 2 replicas) DynamoDB (on-demand)
t=0 CPU 25%, 300 connections ~2K req/s, no throttles
t=+2min new pods scale out → connections 1,800 on-demand absorbs up to 2× previous peak
→ PgBouncer saturates, queueing begins instantly; partitions start splitting
t=+5min primary CPU 95%, p99 5ms → 800ms throttling on one hot key (launch item),
replicas lag 4s → stale reads rest of table fine
t=+8min app timeouts → retries → more load client retries with backoff; hot item
served from a cache in front
t=+15min shed load: disable recommendations widget, steady at 40K req/s; bill for the hour
read-only mode for non-critical paths is 20× normal
Fix next time pre-scale instance, pooler sized for pods, pre-warm (warm throughput) for the known
cache the launch page peak; cache the hot item
Who pays: on Postgres, users pay during the incident and the platform team pays afterwards. On DynamoDB, finance pays for the hour and the app team pays for the hot key. The DynamoDB failure is narrower; the Postgres failure is more recoverable by hand.
The production surprise on each side: Postgres fails at the edges of the machine (connections, vacuum, one big primary). DynamoDB fails at the edges of the key design (one hot key, one missing pattern). Both are survivable; they need different people watching.
Cost and Operations#
| Postgres (managed, e.g. RDS/Aurora) | DynamoDB | |
|---|---|---|
| Who runs it | Platform/DBA team for upgrades, parameter tuning, vacuum, capacity; vendor patches hosts | AWS runs everything; app team owns keys, capacity mode, alarms |
| Bill scales with | Instance size × hours (+ replicas), storage, I/O | Requests (on-demand) or provisioned capacity; storage; each GSI repeats writes |
| Idle cost | Full instance 24/7 (serverless variants reduce this) | ~$0 on-demand beyond storage |
| List prices to anchor on | A production Multi-AZ instance with replicas is typically low thousands of $/month | On-demand: $0.625 per million write units, $0.125 per million read units, $0.25 per GB-month (us-east-1, as of 2026) |
| People | 0.5–1 engineer of database operations per fleet of important clusters | Near zero for operations; real time on data modeling reviews |
| What on-call watches | Postgres | DynamoDB |
|---|---|---|
| The SLO metric | p99 query latency and error rate on the primary | p99 latency and ThrottledRequests per table and GSI |
| The "act now" alert | Replication lag > 30s; oldest XID age approaching the wraparound threshold; storage < 15% free | Sustained throttling; GSI write throttles; cost anomaly |
| Routine work | Minor/major version upgrades, index and vacuum tuning, failover drills | Reviewing Contributor Insights hot keys, capacity mode, TTL and backup settings |
Worked example. 5,000 writes/s of 1 KB items plus 20,000 eventually consistent reads/s of ≤ 4 KB, steady all month:
Writes: 5,000/s × 2.59M s/month ≈ 13.0B WRU × $0.625/M ≈ $8,100
Reads: 20,000/s × 2.59M ≈ 51.8B reads × 0.5 RRU ≈ 25.9B RRU × $0.125/M ≈ $3,240
Two GSIs projecting all attributes → writes × 3 ≈ +$16,200
On-demand total ≈ $11K/month (no GSIs) to $27K (two GSIs)
The same steady load on provisioned capacity with auto scaling is typically several times cheaper, and reserved capacity cheaper still. A Postgres primary that can take 5,000 writes/s with two replicas for the reads costs low thousands per month on managed hardware. Steady, predictable load favors Postgres or provisioned DynamoDB; spiky or tiny load favors on-demand.
🧭 Principal Insight: "Postgres costs a fixed amount plus people; DynamoDB on-demand costs nothing plus every request. Neither is cheaper in general. The question I ask finance is whether we would rather pay for idle capacity or for a database team, and which one we'd still be paying in three years."
Switching Later#
| Move | How | What's hard |
|---|---|---|
| Postgres → DynamoDB (one slice) | Pick a key-shaped table (sessions, carts, idempotency keys); dual-write, backfill, compare reads, cut over | Very little if the slice really is key-shaped; any joins against it move to the app |
| Postgres → DynamoDB (everything) | Rewrite the data layer around access patterns; usually a multi-quarter project | Every ad-hoc query, report and admin screen needs a new home; teams discover the patterns they forgot |
| DynamoDB → Postgres | Export to S3 or consume Streams; transform to relational schema; dual-write; cut over | Single-table designs must be untangled into entities; throughput may exceed one primary |
| Postgres → sharded Postgres or distributed SQL | Shard key choice, proxy layer, or move to a distributed SQL engine | Cross-shard queries and transactions; the shard key is a one-way door |
| Add analytics to DynamoDB | Streams or export to S3 → warehouse | Not a migration; it's the normal pattern, plan it from day one |
One-way doors: DynamoDB partition and sort key design (changing it is a full rewrite of every item); global table consistency mode (fixed at creation); a Postgres shard key once you shard. Two-way doors: DynamoDB capacity mode (switchable, with limits on how often); adding a GSI; Postgres instance size; adding read replicas.
How Real Companies Chose#
Figma: Stayed on Postgres and Built Horizontal Sharding#
Figma's database stack grew almost 100× from 2020, moving from one RDS Postgres instance through caching, replicas and a dozen vertically partitioned databases. When the largest tables outgrew single primaries, the team evaluated distributed SQL options and NoSQL and chose to shard Postgres in-house with a Go query proxy (DBProxy) and "colos" — groups of tables sharing one shard key. Their stated reasons: migrating to a different database would take longer than their remaining runway, they had deep RDS Postgres expertise, and their relational data model needed SQL. The first horizontally sharded table shipped in September 2023 (Figma Engineering).
Staff insight: "Rewrite on NoSQL" was rejected not because DynamoDB couldn't scale, but because the switching cost of a complex relational model exceeded the cost of scaling the database they knew.
Notion: Sharded Postgres by Workspace#
Notion sharded its Postgres monolith into 480 logical shards over 32 physical databases, keyed by workspace ID so that most queries stay within one shard. The trigger was operational: vacuum began to stall, risking transaction ID wraparound. They considered DynamoDB and rejected it as too risky for the migration (Notion Engineering).
Staff insight: The failure that forced the move was a Postgres-specific one — vacuum on a giant single primary — and the fix stayed within Postgres. Many logical shards on fewer physical machines kept future splits cheap.
Amazon: DynamoDB at Prime Day Scale#
AWS reports that during Prime Day 2025 DynamoDB served Amazon's workloads at a peak of 151 million requests per second with single-digit-millisecond responses (AWS News Blog). The 2022 USENIX paper on DynamoDB describes the design goal behind this: predictable performance for a multi-tenant service, with partitions split and moved automatically (USENIX ATC '22).
Staff insight: This is the workload DynamoDB was built for — huge, spiky, key-shaped traffic where the business cannot afford a database team scrambling on the busiest day of the year.
Follow-Ups to Expect#
| After You Say... | They Will Ask... | What They're Testing |
|---|---|---|
| "Postgres" | "Writes grow 20×. What breaks first and what do you do?" | Knowing the single-primary ceiling and the order of moves: tune, scale up, split by domain, shard |
| "DynamoDB" | "Product wants a new filter on the listing page next month." | Whether you planned for unplanned queries: GSI cost, Streams to a search index |
"Partition key = user_id" | "One user has 50M items and 5K writes/s." | Hot keys; write sharding with a suffix; per-partition limits |
| "DynamoDB transactions" | "What are the limits?" | 100 items, 4 MB, single region, 2× cost |
| "Global tables" | "Two regions update the same balance at once." | Last writer wins in MREC; home-region routing or MRSC |
| "Read replicas for scale" | "User updates profile and immediately sees the old one." | Read-your-writes: route to primary for a window after writes |
| "On-demand mode" | "What does a retry storm cost?" | Variable cost risk; budgets, alarms, client backoff |
What to Say in the Interview#
"The deciding question is whether I can list every access pattern today. For this product I can't, so I'm starting on Postgres and keeping the option to move hot, key-shaped tables to DynamoDB later."
"The session store is different: it's get-by-key, 30,000 reads a second, spiky at launch. That one goes to DynamoDB on-demand with a TTL attribute, and nothing ever joins against it."
"If I pick DynamoDB, I'll write down the access patterns first and design keys from them, and I'll stream changes to a warehouse on day one, because someone will ask a question the keys can't answer."
"Postgres's limit is one primary; DynamoDB's limit is one key. I'd rather pick the one whose limit we're further from, and know who owns it when we hit it."
Related Guides#
- PostgreSQL — MVCC, vacuum, replication and scaling one primary
- DynamoDB — key design, GSIs, capacity modes and global tables
- Choosing a Database — the broader decision across engine families
- Cassandra vs DynamoDB — when the question is which wide-column store
- Distributed SQL — the option when you outgrow one primary but need SQL
- Shard a Live Database — what "we'll shard Postgres later" actually involves
- Schema Design — modeling data before choosing an engine
- Hot Keys — the DynamoDB failure mode in depth
- Multi-Region — replication and conflict choices across regions