Template
Warehouse or Postgres Sizing Worksheet
Row count is the wrong argument. Three signals justify a warehouse - contention, sources, unpredictable concurrency - and a read replica settles most of them.
The question arrives as “have we outgrown Postgres.” It is almost never a question about size. Postgres holds more than the people asking believe, and teams who move for size alone find that whatever was actually slowing them down came along.
This worksheet makes you write down the signal before you write down a vendor. Most of the work is four queries against your own database, read honestly.
Why this exists
The failure mode is a migration argued from row count. It is the one number everybody in the room can produce, so it becomes the argument by default. It is also close to meaningless alone: a billion narrow rows scanned on a partition key is a different problem from fifty million wide rows joined six ways by eleven people at once.
Three signals actually justify the move, and you need at least one of them by name:
- Contention. Analytical scans compete with the application’s own reads and writes on one instance. The symptom is that checkout slows down when somebody opens a report.
- Sources. The questions require joining data that does not live in Postgres and never will — an ad platform, a billing processor, a spreadsheet somebody maintains. You are buying ingestion, scheduling and a transformation layer; the query engine is incidental.
- Unpredictable concurrency. Many readers, each writing their own unbounded query, at hours you do not control. This is where separating storage from compute pays: you stop sizing one box for the worst thing anybody might type.
If you cannot name which of the three you have, you have a tuning problem inside Postgres, and it will follow you across the migration — harder to see, and costing more per hour to look at.
The step that gets skipped is the read replica. Pointing reporting traffic at a replica takes the analytical load off the primary at a fraction of the cost of a migration. A great many “we need a warehouse” symptoms are really “our dashboard is competing with the application.” Try it, measure again, and if the case still holds it is a stronger one.
How to use it
Fill in the measurements before anybody names a product. A vendor named on page one turns every measurement after it into supporting evidence.
Measure at the worst hour, not on average. Contention is bursty by definition, so an average hides precisely the thing you are trying to detect.
Write the counterfactual: what becomes possible that is not today, and who notices. “The data work gets less painful” is a legitimate answer — it is only a problem when something more impressive gets offered in its place.
Ask how the candidate bills — per compute-second, per byte scanned, or a flat instance — and price the most expensive plausible mistake on that model. An unfiltered scan inside a loop is a slow morning on a fixed instance and an invoice on a usage meter. Neither disqualifies anything; not knowing which you signed disqualifies you from the budget conversation.
And cost the migration, not the query engine. The engine is the cheap part. Ingestion per source, the transformation layer, a scheduler, access control, repointing every existing report — that is the project.
Where it goes wrong
The worksheet assumes the choice is open. If a warehouse already exists in the organization and people know how to run it, a second analytics home costs far more than any technical comparison shows, because two homes means two definitions of revenue. That cost is not on this sheet, and it is larger than everything that is.
It does not cover latency either. If somebody needs a number within seconds of the event that produced it, neither side of this comparison is the answer, and this sheet will quietly mislead you about that.
Do not use it to slow a decision already made for other reasons — a hiring plan, a platform standard set above you. Those are real reasons. Write them on the sheet as the reason and skip the measurements, rather than dressing them in numbers that fool nobody.
The measurements also expire. A worksheet from last year is a record of a decision, not evidence for the next one.
Copy and Clear become available when the worksheet is ready.
WAREHOUSE OR POSTGRES -- SIZING WORKSHEET
Fill top to bottom. Do not write a product name until section 4.
SYSTEM: FILLED BY: DATE:
DECISION OWNER: name, role -- the person who signs the invoice
- MEASURE -- against your own database, during your busiest hour
Instance RAM: GB vCPU: Storage used: GB
SHOW shared_buffers; ->
SHOW work_mem; ->
Largest tables:
SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC LIMIT 5;
Cache behavior on the tables reports touch:
SELECT relname, heap_blks_hit, heap_blks_read
FROM pg_statio_user_tables ORDER BY heap_blks_read DESC LIMIT 10;
Hit ratio on the worst offender: %
Low ratio on a big table = you read disk on every report run.
Worst queries (requires pg_stat_statements):
SELECT calls, mean_exec_time, total_exec_time, rows, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;
Slowest single report: ms runs/day:
Largest total time consumer: ms/day
Share of total db time that is reporting: %
Contention -- sample every 30s through the busy hour:
SELECT state, wait_event_type, wait_event, count(*)
FROM pg_stat_activity GROUP BY 1,2,3 ORDER BY 4 DESC;
Peak concurrent active sessions:
Sessions waiting on locks or IO:
Does application latency move when a report runs? Y / N
Evidence, not opinion:
Vacuum keeping up:
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;
Worst dead/live ratio: Last autovacuum there:
- THE THREE SIGNALS -- you need at least one, by name
Analytics and the application share one instance and fight.
Proof required: the latency evidence line above.
Systems you must join that are not in Postgres:
Name them:
If this is your signal, you are buying ingestion, scheduling
and a transformation layer. The query engine is incidental.
Distinct humans writing their own unbounded queries:
At hours you control? Y / N
Worst query anyone could plausibly type:
NONE CHECKED -> stay. What you have is a tuning problem, and it
migrates with you, where it is harder to see and costs more per hour.
- DO THIS FIRST -- the step that gets skipped
Read replica carrying all reporting traffic.
Already tried? Y / N
If N: stop here. Do it. Re-measure in two weeks.
If Y: what was still broken afterward:
Confirm these are exhausted, with dates:
- Indexes checked against the actual report predicates
- Rollup tables for the top three reports
- Partitioning on the largest table's filter column
- work_mem raised for the reporting role only
These are days of work. A migration is a quarter of it.
- COST THE MIGRATION, NOT THE QUERY ENGINE
Ingestion: sources x days = days
Transformation layer rebuilt = days
Scheduler and alerting = days
Access control and row-level rules = days
Repointing existing reports and exports = days
Dual-running both systems for weeks = days
Who does this work:
What stops while they do it:
BILLING MODEL OF THE CANDIDATE -- write it down before you sign
Most expensive plausible mistake on that model:
scenario:
what it costs: $
what prevents it:
- THE COUNTERFACTUAL -- one sentence, no hedging
After this, can that they cannot do today.
If the honest answer is "the data work gets less painful", write
that down. It is a real reason. It is only fatal when unstated.
WHAT THIS SHEET DOES NOT MEASURE
- Second-home cost. If a warehouse already exists elsewhere, the
real price is two definitions of revenue, not two bills.
- Sub-second latency needs. That is a streaming question and this
sheet will mislead you about it.
- Anything six months out. Re-measure; do not re-read.