Hiring BarSupport

Postgres vs DynamoDB

Comparison18 min read3 diagrams

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 whenPick 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 hardwareWrites 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?" questionYou 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 queryYou need active-active multi-region with a managed replication story

The middle options are worth naming, because interviewers like to push past the binary:

OptionWhat it buysWhat it costs
Aurora PostgresPostgres semantics, storage that grows on its own, faster failover, up to 15 replicasStill one writer; I/O-based pricing can surprise
Postgres with JSONBSchemaless attributes without leaving SQLNot a scaling answer; just flexibility
Distributed SQLSQL and transactions across many write nodesHigher per-query latency, more complex operations, cross-region transactions are slow
DynamoDB + Streams → warehouse or searchKey-value hot path plus ad-hoc queries elsewhereTwo 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."

Diagram: The Verdict

At a Glance#

DimensionPostgresDynamoDB
Data modelTables, rows, typed columns, foreign keys, JSONB, extensionsItems (≤ 400 KB) in tables; partition key + optional sort key; schemaless attributes
Query modelArbitrary SQL: joins, aggregates, window functions, CTEsGetItem, Query (one partition key, sort-key range), Scan; 1 MB per page
Secondary indexesAny number, any columns, transactionally consistentGSIs (20 per table by default, eventually consistent) and LSIs (5, created with the table only)
ConsistencySerializable available; read committed by default; replicas asynchronousStrong or eventual reads on the base table; GSIs eventual only
TransactionsAny rows, any size (within reason), any isolation level≤ 100 items, ≤ 4 MB, single region; 2× capacity cost
OrderingORDER BY anythingSort key order within one partition key only
Throughput (order of magnitude)One primary: ~10K–50K simple writes/s, ~100K+ reads/s with replicasEffectively unbounded per table; ~1,000 WCU and 3,000 RCU per partition (as of 2026)
LatencySub-ms to a few ms in-region for indexed lookups; unbounded for bad queriesSingle-digit ms for key operations at any scale
Scaling modelVertical primary + read replicas; sharding is your project (app-level, Citus)Automatic partition splits; you design keys to spread load
Operational burdenMedium: 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 optionsAmazon RDS and Aurora, Google Cloud SQL and AlloyDB, Azure, Neon, Supabase, othersAWS only
Cost shapeInstance-hours (paid 24/7) + storage + IOPSPer request (on-demand) or per provisioned capacity, + storage per GB-month
Multi-regionAsync cross-region replicas, manual or managed promotionGlobal 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.

Diagram: Where the Write Ceiling Lives

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.

RequirementPostgresDynamoDB
Unique emailUNIQUE (email)Transaction: put USER#id + put EMAIL#addr with attribute_not_exists
Transfer between accountsBEGIN; UPDATE ...; UPDATE ...; COMMIT;TransactWriteItems with condition balance >= :amt
Prevent overlapping bookingsExclusion constraint on a time rangeModel each slot as an item; conditional put per slot
Cascade delete a customer's dataON DELETE CASCADEQuery 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#

FailureWhat happensDetectionOwner
Connection exhaustionEach connection is a process (~5–10 MB); a deploy of 400 pods × 20 connections exceeds max_connectionsActive connections vs max, connection errorsApp team + DBA
Vacuum falls behindDead tuples bloat tables and indexes; at the extreme, transaction ID wraparound forces the database to stop accepting writesn_dead_tup, age of oldest XIDDBA / platform
Long-running transactionHolds back vacuum and replication; locks block a migration and a queue forms behind itpg_stat_activity oldest xact ageApp team
Replica lag on read-your-writesUser saves, reloads from a replica, sees old dataReplication lag in secondsApp team
Primary failover30s–2 min of write errors on managed services; app must reconnect and retryWrite error rate, failover eventsPlatform
Bad query planStats change, planner picks a sequential scan on a 500M-row table; p99 goes from 5 ms to 30 sSlow query log, pg_stat_statementsApp team

DynamoDB#

FailureWhat happensDetectionOwner
Hot partition keyOne key exceeds ~1,000 writes/s; throttling for that key while the table looks idleThrottledRequests, CloudWatch Contributor Insights top keysApp team
GSI back-pressureGSI under-provisioned or hot; base table writes throttleGSI WriteThrottleEventsApp team
New access patternProduct wants a query the keys don't support; answer is a Scan (full table, expensive) or a new GSI plus backfillScan usage, RCU spikesProduct + app team
Large itemsItems near 400 KB cost hundreds of read units per strong read; reads get slow and expensiveConsumed capacity per requestApp team
On-demand surprise billA retry storm or runaway job doubles requests; cost follows instantlyDaily cost anomaly alertApp team + FinOps
Pagination bugsQuery returns 1 MB and LastEvaluatedKey; code assumes it got everythingMissing results reportsApp 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 itPlatform/DBA team for upgrades, parameter tuning, vacuum, capacity; vendor patches hostsAWS runs everything; app team owns keys, capacity mode, alarms
Bill scales withInstance size × hours (+ replicas), storage, I/ORequests (on-demand) or provisioned capacity; storage; each GSI repeats writes
Idle costFull instance 24/7 (serverless variants reduce this)~$0 on-demand beyond storage
List prices to anchor onA production Multi-AZ instance with replicas is typically low thousands of $/monthOn-demand: $0.625 per million write units, $0.125 per million read units, $0.25 per GB-month (us-east-1, as of 2026)
People0.5–1 engineer of database operations per fleet of important clustersNear zero for operations; real time on data modeling reviews
What on-call watchesPostgresDynamoDB
The SLO metricp99 query latency and error rate on the primaryp99 latency and ThrottledRequests per table and GSI
The "act now" alertReplication lag > 30s; oldest XID age approaching the wraparound threshold; storage < 15% freeSustained throttling; GSI write throttles; cost anomaly
Routine workMinor/major version upgrades, index and vacuum tuning, failover drillsReviewing 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#

MoveHowWhat's hard
Postgres → DynamoDB (one slice)Pick a key-shaped table (sessions, carts, idempotency keys); dual-write, backfill, compare reads, cut overVery 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 projectEvery ad-hoc query, report and admin screen needs a new home; teams discover the patterns they forgot
DynamoDB → PostgresExport to S3 or consume Streams; transform to relational schema; dual-write; cut overSingle-table designs must be untangled into entities; throughput may exceed one primary
Postgres → sharded Postgres or distributed SQLShard key choice, proxy layer, or move to a distributed SQL engineCross-shard queries and transactions; the shard key is a one-way door
Add analytics to DynamoDBStreams or export to S3 → warehouseNot 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.

Diagram: Switching Later

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."


  1. Loading the index…