Skip to content
DynamoDB single-table or Postgres? How we chose, per workload, and where we got it wrong
← ← Back to Thinking Development

DynamoDB single-table or Postgres? How we chose, per workload, and where we got it wrong

The DynamoDB versus Postgres debate is usually argued as a religion. One side says relational data belongs in a relational database and single-table design is a puzzle you solve once and regret forever. The other side says Postgres does not scale without a caretaker and DynamoDB is the only store that lets you stop thinking about capacity. Both are right about the cases they have in mind and wrong that there is a single answer.

We run both, on the same platform, for a US client. Six DynamoDB tables and one Aurora Serverless v2 Postgres cluster, chosen workload by workload. Here is the rule we use, the four workloads it produced, the one we put in the wrong store and moved, and the cost of each side.

The rule

Ask two questions about the workload, and the answer falls out.

1. Do you know the access patterns, and are there fewer than about ten of them? DynamoDB is a key-value store with secondary indexes. You design the table around the queries. If the queries are known and few, that design is a pleasant afternoon and the table will serve them at any scale for a fixed low cost per request. If the queries are unknown, evolving, or ad hoc (an admin who needs "all orders for customers in Texas who bought product X in March"), every new question is a new index or a scan, and you will be building a second copy of the data somewhere queryable within a year.

2. Do you need transactions across many rows, joins, or aggregates? DynamoDB has transactions, up to 100 items. It does not have joins or GROUP BY. If the workload is "sum revenue by product by week", or "update the order, the inventory and the ledger atomically", Postgres does that in one statement and DynamoDB does it in application code that you will get subtly wrong at least once.

Known and few access patterns, no joins or aggregates: DynamoDB. Anything else: Postgres. Ties go to Postgres, because the cost of being wrong is asymmetric: a Postgres table that turns out to have a hot key-value pattern can grow an index or a cache; a DynamoDB table that turns out to need joins needs a migration.

access patterns known,fewer than ~10? yes joins, aggregates, ortransactions over 100 items? no DynamoDB no yes Postgres · Aurora Serverless v2ties go here: a wrong guess grows an index; in DynamoDB a wrong guess is a migration Two questions per workload, not one answer per company.

The four workloads

Sessions and rate-limit buckets → DynamoDB. One access pattern each: get by key, write by key, expire by TTL. Millions of small items, spiky traffic, zero interest in ever querying them by anything but the key. This is the textbook DynamoDB workload and it costs about $4 a month at our volume, on-demand, with no capacity to think about. Putting these in Postgres would mean a table that is 90 % of the write volume for 0 % of the business value, and a vacuum job to keep it from bloating.

Product catalogue and per-tenant configuration → DynamoDB, single-table. Seven access patterns, all known: product by id, products by tenant, products by tenant and category, config by tenant, a few more. Read-heavy, write-rare, and the shape of a product is a document, not a set of joined rows. One table with a composite key and two GSIs serves all seven. The restore drill showed the settings-loss gotcha, but the table itself has never needed a change in eighteen months.

Orders, payments, ledger → Postgres. This is the workload where the second question says no. An order touches the order, its lines, the inventory reservation and the ledger entry in one transaction; finance wants revenue by product by region by week; support wants "every order for this customer with a refund in the last 90 days". Joins, aggregates, ad hoc questions, and a strong preference for the invariants that foreign keys and constraints give you. Aurora Serverless v2 at 0.5 ACU minimum, reviewed separately.

Event log for the search index → DynamoDB with Streams. Append-only, read by the stream consumer only, expire after 30 days. Known pattern, no joins, and Streams gives the change feed for free.

The one we got wrong

Customer profiles started in DynamoDB. The access patterns looked known: profile by id, profile by email, profiles by tenant. Three patterns, a document shape, obvious fit.

Then the product grew. Support wanted customers by signup date. Marketing wanted customers who had not ordered in 60 days, which is a join against orders. Finance wanted customer lifetime value, an aggregate. Each request became either a new GSI (we added three), a scan with a filter that took minutes and cost real money, or a nightly export to Postgres that was stale by morning. By the fourth request we had two copies of customer data, one of them always slightly wrong.

We moved profiles to Postgres. The migration was a week: dual-write for a few days, backfill, cut reads over, remove the DynamoDB writes, delete the table a month later. The three GSIs went away. The nightly export went away. Every question anyone has asked about customers since has been a query, not a project.

The lesson is in the first question. "Known access patterns" means known for the life of the data, not known today. Profiles are the entity everyone in the business eventually wants to slice differently. Sessions are not. The store should follow that.

Where each workload lives workloadstorewhy$/month sessions · rate-limit bucketsDynamoDB1 pattern, TTL, spiky~4 catalogue · tenant configDynamoDB single-table7 patterns, 2 GSIs, read-heavy~6 orders · payments · ledgerPostgrestransactions, joins, aggregates~45 search event logDynamoDB + Streamsappend-only, change feed for free~3 customer profilesDynamoDB → Postgres3 GSIs + nightly export = wrong storemoved "Known access patterns" means known for the life of the data, not today.

What each side costs, honestly

DynamoDB (4 tables) Aurora Serverless v2 Postgres
Monthly bill ~$13 on-demand ~$45 at the 0.5 ACU floor, more under load
Capacity planning None Set min and max ACU; the floor is the bill
Schema changes Free for attributes; a new access pattern is a new GSI, backfilled ALTER TABLE, migrations, occasionally a lock to plan around
Backups PITR on, restore loses settings PITR on, restore is a new cluster
Ad hoc questions Scan, or export somewhere else A query
Operational surprises in 18 months One: hot partition on a badly chosen tenant key, fixed with a suffix One: a long transaction held a lock during a migration
The thing you cannot do Joins, aggregates, unplanned queries Stop thinking about it entirely

The DynamoDB side is cheaper and quieter for what it is good at. The Postgres side costs more and answers more. Neither number is the argument; the workload is.

The short version

Per workload, two questions: are the access patterns known and few, for the life of the data; and does it need joins, aggregates or wide transactions. Yes and no means DynamoDB. Anything else means Postgres. Expect to get one wrong, and expect the wrong one to be the entity that everyone in the business eventually wants to slice differently.

If you are choosing a store for a new platform, or have a DynamoDB table with its fourth GSI and a nightly export, we can help you sort the workloads. It is a whiteboard session, not a religion.