Database Performance Tuning and Scaling Questions
System-level performance work beyond a single query: configuration and resource tuning, capacity planning, handling large data volumes, and scaling read and write throughput. Covers identifying bottlenecks, growth management, the vertical-versus-horizontal scaling decision, materialized views for expensive queries, and maintenance work such as vacuuming, index rebuilds, bulk loads, and safe schema changes on large tables. Tests whether a candidate can keep a database healthy as load grows.
A database is projected to grow 5x in storage and IOPS over the next 12 months. Walk through how you would build a capacity plan for it: how you would model the growth, what safety margins you would build in, how you would project cost, and what automation or alerting you would put in place so capacity never becomes an unplanned outage.
Sample Answer
Direct answer
Capacity planning is forecasting a real growth curve from actual trend data, provisioning ahead of it with a safety margin sized to how long it takes you to actually add capacity, and wiring alerts so the response is scheduled work rather than a page. For a projected 5x in 12 months, the model you pick matters more than the headline number: a smooth compound curve behaves very differently from a step change tied to a known launch, and your alert thresholds should be set from your own provisioning lead time, not a round number borrowed from a blog post.
How to think about it
Model the growth. Compound growth is the safe default without better information: solve for the monthly rate that gets you from current usage to 5x over 12 months.
(1+r)12=5so r=51/12−1. If you instead know about a specific step (a big customer signing, a feature launch), layer that as a discrete jump on top of the smooth trend rather than smoothing it away.
Safety margin sized to lead time. WARN and PAGE thresholds should not be arbitrary round numbers. WARN needs to fire early enough that the provisioning action (a resize request, an approval, the resize itself) finishes before usage reaches PAGE. If it cannot, lower WARN, do not accept the risk.
Cost projection. Project the same curve onto the billing metric, storage dollars per GB-month, provisioned input/output operations per second (IOPS, how many read or write operations the storage layer can do each second) dollars per IOPS-month, so finance sees the number rising before the invoice does, not after.
Forecasting inputs people forget. Compute and storage do not always grow at the same rate and should be modeled separately. Scheduled scaling events (a known launch, a marketing campaign) should be layered on top of the organic trend, not baked into it. Write-ahead log (WAL, the durability log a database writes before committing a change) volume and retention grow faster than data size on write-heavy workloads, and if WAL is retained for a replication window or point-in-time recovery, that retention is its own growing volume. Backup and archival storage across your retention policy is the item most often missed entirely: a 5x growth in live data usually means 5x-or-more growth in backups, especially under a multi-year retention requirement, since old backups do not shrink just because live data changed shape.
Automation and alerting. Alert on the trend, not just the level; a sudden change in growth rate is often the earliest real signal something changed, before any threshold is crossed. Tie WARN to a ticket and runbook, PAGE to on-call, and where the platform allows it, wire the resize itself to fire automatically once a threshold is crossed and a change window is open, instead of requiring a human to notice a graph.
Scaling triggers as a companion to alerting. A trigger is a pre-agreed threshold that fires a specific scaling ACTION, not just a notification: "at 1,000,000 queries per second (QPS) sustained for 15 minutes, add a read replica" is a trigger. "Storage at 85%, page a human who decides what to do" is an alert. A complete capacity plan has both.
Worked example
Pinned inputs, reproducible on re-run:
current_storage_gb = 800
current_iops = 6000
growth_multiple = 5.0
horizon_months = 12
alert_warn_pct = 0.70
alert_page_pct = 0.85
lead_time_months = 1.0
r = growth_multiple ** (1 / horizon_months) - 1
Real output from running it:
implied monthly compound growth rate: 14.3530%
month storage_gb iops
0 800 6000
6 1789 13416
12 4000 30000
provision AT LEAST 4706 GB / 35294 IOPS so month 12's actual usage
(4000 GB / 30000 IOPS) lands at the 85% page threshold, not above it
alert thresholds: WARN at 3294 GB (70%), PAGE at 4000 GB (85%)
at this growth rate, WARN fires roughly 1.4 months before PAGE would,
against a 1-month provisioning lead time (sufficient)
The 1.4-month runway against a 1-month lead time is the check that matters: if that number had come out below the lead time, the fix is to lower WARN, not hope the resize finishes in time.
A second, QPS-based variant shows the scaling-triggers framing on a different metric: 100,000 QPS growing to 1,000,000 QPS over 18 months instead of 12. Same method, different multiple and horizon: r=101/18−1≈13.65% a month, giving 100,000 at month 0, about 215,000 at month 6, about 464,000 at month 12, and 1,000,000 at month 18 (each value is 100000×(1+r)m, computed directly from the pinned rate above). The companion trigger here would be concrete and actionable, for example "add a read replica once sustained QPS crosses 500,000 for 15 minutes," not just a dashboard threshold.
The same method scales to a longer horizon too, for example a 10x-over-3-years warehouse case (multiple 10, horizon 36 months): the growth-rate formula is unchanged, but at that horizon the WAL retention and backup and archival line items above stop being a footnote and start dominating, since backup windows and cross-region replication bandwidth become material costs at multi-terabyte, multi-year scale in a way they are not at 12 months.
Trade-offs and pitfalls
Setting WARN too close to PAGE leaves no time to act, exactly what the runway check above exists to catch. Alerting on absolute usage only, and missing rate-of-change, means the earliest real signal gets ignored until a static threshold trips. Treating compute and storage as one number hides real divergence: a read-heavy service can 5x its IOPS while barely growing on disk, and a write-heavy audit log can do the reverse. The single most common gap is forgetting backups and WAL retention, teams size the live database carefully and then get surprised when the backup bill, or the backup window and recovery time objective, breaks first.
You need to bulk-load 100 million rows into PostgreSQL with minimal downtime and minimal WAL bloat. Walk through your plan: how would you validate the data first, choose between COPY and row-by-row INSERT, decide whether to disable and rebuild indexes, consider partitioning or UNLOGGED tables, and tune settings like maintenance_work_mem and parallelism? What risks would you flag, and how would you confirm the load succeeded?
Sample Answer
Direct answer
Validate the data against a disposable staging table before it ever touches the real one, load with COPY rather than row-by-row INSERT (a real, measured ~15x reduction in write-ahead log, or WAL, volume for the same rows in the test below), defer any non-primary-key indexes until after the load and only build the primary key up front if something else genuinely depends on uniqueness during the load itself, and size maintenance_work_mem and parallel workers for the index build rather than leaving them at their small interactive-session defaults. On the same test, building the primary key after the load instead of maintaining it during the load cut total WAL roughly in half. If the table can tolerate being reloaded from source on a crash, loading into an UNLOGGED table and converting it to logged only after the load is confirmed successful avoids writing that WAL at all during the risky part of the operation. WAL matters here in concrete terms: it is the log every change must be durably written to before Postgres calls a transaction committed, so a smaller WAL volume means less disk I/O and disk space consumed during the load, and less lag pushed onto any physical replica that has to replay every byte of it.
Structured elaboration
Validate first, against a disposable copy
Load the source data into a plain (or UNLOGGED) staging table first, or validate the source file directly with a script, before it ever reaches the destination table: check for null values in required columns, duplicate values in what will become the primary key, and that foreign-key targets actually exist. Finding a bad batch here means dropping and reloading a disposable staging table, not untangling a partially-loaded 100-million-row production table.
COPY versus row-by-row INSERT
COPY uses a dedicated bulk-loading protocol: rows stream to the server in one continuous operation instead of one parsed, planned, and separately logged statement per row. The measured section below shows this is not a marginal difference.
Index handling
For a one-time load of this size, drop or defer every index that is not required to be enforced during the load itself, run the COPY, then build indexes afterward. If the table must stay readable and writable by other sessions while the index builds, use CREATE INDEX CONCURRENTLY, which costs more and does not guarantee success on the first attempt (an interrupted concurrent build can leave an invalid index that has to be dropped and retried), in exchange for not blocking concurrent queries.
Partitioning and UNLOGGED tables
If this 100 million rows are a brand-new table rather than an append to an existing one, consider building it as a set of partitions and loading each partition with its own COPY, which allows parallel load streams and per-partition index builds. Sizing and routing a partitioning scheme is its own decision with its own trade-offs beyond this load; the relevant point here is only that partitioning is a lever for splitting one enormous load into several smaller, independently loadable and independently indexable pieces.
UNLOGGED tables skip the write-ahead log entirely, which is why the measurement below shows zero WAL bytes for the same load. The cost is that an UNLOGGED table's contents do not survive a crash or unclean restart and are not sent to physical replicas. The safe use of this for a one-time load: load into an UNLOGGED table, validate the result, then run ALTER TABLE ... SET LOGGED, which writes the table's contents to WAL exactly once as part of establishing durability. That is one bulk write instead of the incremental per-row WAL the original load would otherwise have generated, and it only pays that cost once the load is already confirmed good.
Tuning maintenance_work_mem and parallelism
maintenance_work_mem governs the memory available to operations like CREATE INDEX and VACUUM (distinct from work_mem, which governs ordinary query sorts and joins). Raise it for the duration of the load and index-build session rather than raising the cluster-wide default, since a generous value is safe specifically because you control how many maintenance operations are running concurrently during a dedicated load window; the same value left as a global default could let many ordinary sessions each claim that much memory at once. max_parallel_maintenance_workers lets a single CREATE INDEX use multiple parallel workers to build faster.
Risks to flag
WAL volume from a 100-million-row load, even via COPY, is real and can pressure disk space on the WAL volume or increase lag to any replicas, unless the table is loaded UNLOGGED. A single very large transaction can outrun the checkpointer (the background process that periodically flushes all recently modified in-memory pages to disk as a checkpoint, so older WAL can eventually be recycled), causing a burst of checkpoint I/O once it does catch up; checkpoint_timeout and max_wal_size (settings that control how often a checkpoint happens) can be raised temporarily for the load window. If this is an append to an existing live table rather than a new one, ordinary lock contention with concurrent readers and writers applies. Constraint violations discovered only at the very end of a long load are expensive; this is exactly what staging-table validation avoids.
Confirming the load succeeded
Compare the loaded row count against the source. Check for duplicate or null values in what should be unique or required columns. If any index was built CONCURRENTLY, check pg_index.indisvalid, since an interrupted concurrent build can silently leave an invalid index behind rather than failing loudly. Run ANALYZE on the table once loaded, since a table that just went from zero to 100 million rows with stale (or entirely absent) statistics can get badly wrong query plans until the planner has fresh numbers to work from.
Worked example
Everything below was executed against a real PostgreSQL 16 instance. pg_current_wal_lsn() and pg_wal_lsn_diff() give the exact number of WAL bytes a statement generated, a real, deterministic, storage-level measurement, not a timing claim.
First, COPY into a table where the primary key index is already built and maintained live during the load, versus COPY into an unindexed table with the primary key added afterward, both loading the same 2,000,000 generated rows:
CREATE TABLE bulk_indexed (id bigint PRIMARY KEY, val text);
CREATE TABLE bulk_noindex (id bigint, val text);
SELECT pg_current_wal_lsn() AS lsn_start \gset
\copy bulk_indexed FROM '/tmp/data.csv' WITH (FORMAT csv)
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), :'lsn_start');
SELECT pg_current_wal_lsn() AS lsn_start \gset
\copy bulk_noindex FROM '/tmp/data.csv' WITH (FORMAT csv)
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), :'lsn_start'); -- copy only
SELECT pg_current_wal_lsn() AS lsn_mid \gset
ALTER TABLE bulk_noindex ADD PRIMARY KEY (id);
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), :'lsn_mid'); -- index build only, isolated from a fresh LSN snapshot taken right after the copy
Output (executed, WAL bytes for 2,000,000 rows):
COPY into a table with the PK already built: 187,815,768 bytes
COPY into an unindexed table: 55,237,144 bytes
ADD PRIMARY KEY run once, after the load (isolated): 40,846,816 bytes
maintained_during_load = 187_815_768
deferred_total = 55_237_144 + 40_846_816
reduction = (1 - deferred_total / maintained_during_load) * 100
per_row_maintained = maintained_during_load / 2_000_000
per_row_deferred = deferred_total / 2_000_000
target_rows = 100_000_000
print(f"deferring the index cut total WAL by {reduction:.1f}%")
print(f"extrapolated to {target_rows:,} rows: "
f"{per_row_maintained*target_rows/1e9:.2f} GB (index maintained during load) vs "
f"{per_row_deferred*target_rows/1e9:.2f} GB (index built after)")
Output (executed):
deferring the index cut total WAL by 48.8%
extrapolated to 100,000,000 rows: 9.39 GB (index maintained during load) vs 4.80 GB (index built after)
Next, COPY versus row-by-row INSERT for the same 200,000 rows (the INSERT version is 200,000 separate autocommitted statements, the common shape of a naive "just loop and insert" script):
SELECT pg_current_wal_lsn() AS lsn_start \gset
\copy bulk_copy_200k FROM '/tmp/data_200k.csv' WITH (FORMAT csv)
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), :'lsn_start');
-- 200,000 individual statements, e.g. generated from application code, no explicit
-- transaction wrapping them, so each one commits on its own:
-- INSERT INTO bulk_rowbyrow (id, val) VALUES (1, 'v1');
-- INSERT INTO bulk_rowbyrow (id, val) VALUES (2, 'v2');
-- ... (200,000 lines)
SELECT pg_current_wal_lsn() AS lsn_start \gset
\i rowbyrow_inserts.sql
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), :'lsn_start');
Output (executed, WAL bytes for the same 200,000 rows):
COPY: 5,305,992 bytes
row-by-row INSERT: 80,843,112 bytes
copy_bytes, insert_bytes = 5_305_992, 80_843_112
print(f"row-by-row INSERT generated {insert_bytes/copy_bytes:.2f}x more WAL than COPY for the identical rows")
target_rows = 100_000_000
per_row_copy = copy_bytes / 200_000
per_row_insert = insert_bytes / 200_000
print(f"extrapolated to {target_rows:,} rows: "
f"{per_row_copy*target_rows/1e9:.2f} GB (COPY) vs {per_row_insert*target_rows/1e9:.2f} GB (row-by-row INSERT)")
Output (executed):
row-by-row INSERT generated 15.24x more WAL than COPY for the identical rows
extrapolated to 100,000,000 rows: 2.65 GB (COPY) vs 40.42 GB (row-by-row INSERT)
COPY into an UNLOGGED table, same 2,000,000 rows:
CREATE UNLOGGED TABLE bulk_unlogged (id bigint, val text);
SELECT pg_current_wal_lsn() AS lsn_start \gset
\copy bulk_unlogged FROM '/tmp/data.csv' WITH (FORMAT csv)
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), :'lsn_start');
Output (executed):
0
Finally, scoping maintenance_work_mem to just the index-build transaction with SET LOCAL, and confirming it actually reverts afterward:
BEGIN;
SET LOCAL maintenance_work_mem = '512MB';
SET LOCAL max_parallel_maintenance_workers = 4;
SHOW maintenance_work_mem;
CREATE INDEX bulk_indexed_val_idx ON bulk_indexed (val);
COMMIT;
SHOW maintenance_work_mem;
SELECT indisvalid FROM pg_index WHERE indexrelid = 'bulk_indexed_val_idx'::regclass;
Output (executed):
maintenance_work_mem
-----------------------
512MB -- inside the transaction, during the index build
maintenance_work_mem
-----------------------
64MB -- back to the session default immediately after COMMIT
indisvalid
------------
t
SET LOCAL only takes effect inside an explicit transaction block; run outside one, it is silently scoped to nothing and PostgreSQL warns rather than applies it, which is itself worth knowing before relying on it in a script that is not already wrapped in BEGIN/COMMIT.
Trade-offs & pitfalls
CREATE INDEX CONCURRENTLYis not free insurance. It costs more than a plain build and can fail partway through, leaving an invalid index that has to be dropped and rebuilt; use it only when the table genuinely must stay live during the index build, and always checkpg_index.indisvalidafterward.UNLOGGEDis not a default choice. It is the right lever specifically when the load can be redone from source on failure; for a load that cannot be cheaply repeated, the durability it gives up is not worth the WAL savings.- A single 100-million-row transaction is itself a risk, independent of WAL volume: it can outrun the checkpointer and, if it needs to be rolled back for any reason, undoing it is expensive. Loading in batches (still via
COPY, just multipleCOPYcalls) bounds the blast radius of a failure partway through. - Skipping the post-load
ANALYZEis a common, quiet mistake. A table with no statistics at all can get a catastrophically bad first query plan, which then looks like a mysterious performance problem entirely unrelated to the load that actually caused it. - The same instinct (minimize logging, avoid maintaining indexes row by row) applies on other engines, under different names. SQL Server bulk operations can run minimally logged under the
BULK_LOGGEDorSIMPLErecovery models (settings that control how much detail SQL Server's own transaction log keeps) combined with theTABLOCKhint (a table-level lock taken in exchange for permitting that faster, minimally-logged path), trading point-in-time recovery granularity (the ability to restore the database to an arbitrary moment using the log) for less log volume, similarly to whatUNLOGGEDtrades away here. Where PostgreSQL's lever for splitting a huge load is partitioned tables loaded independently, SQL Server's closest analog for a near-instant bulk swap isALTER TABLE ... SWITCH PARTITION, which moves an already-prepared partition into the live table as a metadata-only operation rather than a data-copying one. The underlying principle carries across engines even though the specific mechanism and syntax do not.
You need to add a column to a production table with hundreds of millions of rows, and you cannot take a long lock or cause a visible outage. Describe a safe approach: what technique would you use to make the change incrementally, how would a dual-write-and-backfill strategy work if you needed one, and how would you monitor and cap the impact on live traffic while it runs, including a way to back out if something goes wrong?
Sample Answer
Direct answer
Adding the column itself should be near-instant: a plain ADD COLUMN with a constant (or
NULL) default is a metadata-only change on modern PostgreSQL and doesn't rewrite the
table. The actual risk is in populating it: run that as an explicit incremental backfill
in small batches with pacing between them, have the application dual-write the new column
on every new or updated row going forward before the backfill starts, and monitor replication lag (how far behind the primary a replica's copy of the data has fallen) and lock wait as hard caps that pause the job automatically.
The plan
The ADD COLUMN step itself. On PostgreSQL 11 and later, ADD COLUMN with a
constant default, including NULL, is metadata-only and does not rewrite existing rows.
That's a genuine change from older versions, and a common misconception is that any ADD COLUMN with a default rewrites the whole table; that's only true for a volatile,
non-constant default, or certain constraint additions. This step takes a brief exclusive
lock, but for milliseconds, a fundamentally different risk than the population step.
If a full backfill is needed (computing a derived value per row, the harder case this
question is really about):
- Incremental technique: iterate in small batches by primary-key range, each batch its
own short transaction, with a brief pause between batches so replication and any read
replicas can keep up, and so row locks don't accumulate into one long-held transaction. - Dual-write: ship the application change that writes the new column on every new or
updated row first, before the batch backfill starts, so the backfill isn't chasing a
moving target. The batch job then only needs to cover rows written before that deploy. - Monitoring and capping impact: watch replication lag (don't let a replica fall far
enough behind that a failover would lose data, or that it starts serving badly stale
reads); watch lock-wait time and set alock_timeoutso a batch that's queued too long
aborts rather than backs up behind unrelated traffic; watch dead-tuple growth (a dead tuple is the old copy of a row that Postgres leaves behind after anUPDATE, since it writes a new row version instead of editing the old one in place); the backfill itself generates a dead row version for every update, a real bloat event, meaning the table fills up with dead tuples faster than they get cleaned out, at this row count that needs its own vacuum planning (VACUUMis the background process that reclaims the space those dead tuples hold so the table doesn't just keep growing); and cap the overall rate (target
rows/sec) rather than running batches back to back as fast as possible. - Backing out: since the column can be added nullable with nothing yet depending on it,
backing out mid-backfill just means stopping the job. The column can sit partially
populated with no correctness impact as long as nothing treats it as authoritative
until the backfill and validation are complete, and the dual-write path can be
feature-flagged off independently of the backfill's progress.
Worked example: backfill timing (pinned inputs)
total_rows = 400_000_000
batch_size_rows = 5_000
sleep_between_batches_ms = 50
exec_time_per_batch_ms = 20 # illustrative per-batch UPDATE execution time
num_batches = total_rows / batch_size_rows
time_per_batch_s = (sleep_between_batches_ms + exec_time_per_batch_ms) / 1000.0
total_time_s = num_batches * time_per_batch_s
print(f"num_batches = {num_batches:,.0f}")
print(f"time_per_batch = {time_per_batch_s*1000:.0f}ms")
print(f"total wall time = {total_time_s:,.0f}s = {total_time_s/3600:.1f} hours")
num_batches = 80,000
time_per_batch = 70ms
total wall time = 5,600s = 1.6 hours
Halving the batch size to 2,500 rows roughly doubles num_batches to 160,000 and, for a
similar per-batch overhead, roughly doubles the wall time to about 3.2 hours, a direct
trade between how gentle each batch is on the live system and how long the whole backfill
takes. That trade is worth sizing explicitly against how much time the migration is
actually allowed to take, not defaulted to "as small as possible."
Trade-offs and pitfalls
- Assuming
ADD COLUMNitself is the risky step; on modern PostgreSQL with a constant
default it usually isn't. The real risk in this scenario is almost always the
subsequent full-table update to populate a derived value; don't spend the caution
budget on the step that doesn't need it. - Smaller batches and longer pauses are safer for the shared system but make the backfill
take proportionally longer, size this trade-off deliberately, not by habit. - Shipping the backfill before the dual-write path is verified working means the backfill is chasing a target that's still drifting from concurrent, uninstrumented writes: rows the backfill already processed keep getting changed by callers the dual-write path never reached, so the column silently falls out of sync with its source again after the batch job has already moved past that row. This is the same kind of drift failure that hits any derived copy of data, a cache, a summary table, a replica, whenever its update logic misses a write instead of catching every one.
- Not planning for the backfill's own bloat: 400 million updates generate 400 million
dead tuples somewhere, a genuinely large vacuum workload competing with the same system
the whole plan is trying not to disrupt.
Your data warehouse runs a heavy nightly job at 2am that rebuilds summary tables, and by morning dashboards are still slow because the warehouse is doing background cleanup and vacuuming. How would you prioritize fixes to cut the morning latency: what operational changes, infrastructure changes, and query-level changes would you make, and how would you measure whether it worked?
Sample Answer
Direct answer
Prioritize fixes in the order of what is actually causing the morning slowness, not by category. If last night's job left the summary tables bloated and background cleanup is still working through it at 9 a.m., the highest-leverage fix is making that cleanup finish before morning traffic starts, an operational change, not rewriting morning queries that were never the actual problem.
How to think about it
Operational changes. Move the summary-rebuild job earlier, or make it finish with margin before business hours, measured from how long it actually takes, scheduled backward from the latest acceptable finish time rather than forward from "2 a.m. felt reasonable." Tune the maintenance work associated with it, cost-based delay settings for background cleanup, so it does not spill into the morning if the nightly window is tight. If the job and normal traffic must overlap at all, use resource governance, a lower-priority resource class or an I/O and CPU cap for the maintenance job, so it does not compete evenly with morning queries for the same budget.
Infrastructure changes. If the job is a heavy rewrite of the summary tables, building the new version in a separate table and swapping it in atomically avoids generating the dead-tuple bloat that then needs cleanup at all, often cheaper than fixing the cleanup after the fact. If the warehouse is a single shared cluster serving both the nightly rebuild and morning dashboards, separating them, a maintenance-only window or a dedicated workload-management queue class, stops the rebuild from directly competing with dashboard queries for cache and input and output.
Query-level changes. Confirm the summary tables the dashboards read from are actually indexed for the dashboard's query shape, rebuilding a table nightly is worthless if nobody keeps its indexes current after each rebuild. Check whether the dashboards are querying the summary tables at all, or accidentally falling back to the raw underlying tables during the rebuild window, if so, the query itself, not the schedule, is the real bug.
Measuring whether it worked. Track the same metric the incident showed, morning 95th and 99th percentile (p95 and p99) dashboard latency, plus the maintenance job's own duration and the affected tables' dead-tuple count over the following mornings. A fix that moves the job earlier but does not actually shrink how long cleanup takes just shifts when the problem happens, it does not remove it.
Worked example
A real, pinned demonstration of exactly this failure shape: a 50,003-row table churned with three full-table updates, simulating a night of heavy rewrite activity, growing from 3,672 kB to 13 MB with 150,009 dead tuples. While bloated, a plain SELECT count(*) cost 1,274 shared buffer touches and 3.883 ms.
Running Postgres's routine cleanup command, VACUUM (it reclaims space held by deleted or updated rows so the space can be reused, without shrinking the table file on disk), with verbose output:
VACUUM (VERBOSE) churn_demo;
tuples: 0 removed, 50003 remain, 0 are dead but not yet removable
index scan needed: 956 pages from table (75.04% of total) had
150009 dead item identifiers removed
Ordinary VACUUM reclaimed all 150,009 dead tuples for reuse but, confirmed here, did not shrink the file size, still 13 MB, because it marks space reusable rather than returning it to the operating system. VACUUM FULL, which takes an exclusive lock and physically rewrites the table, a real trade-off, it blocks readers and writers for its duration and is a maintenance-window operation, not a routine one, brought the table back to 3,664 kB, and the same count(*) afterward touched only 319 buffers at 1.593 ms, a real, measured return to baseline.
The lesson for this exact scenario: do not assume "we run VACUUM" is enough. Ordinary autovacuum keeps a table from growing without bound going forward, but it does not undo bloat that already happened; if last night's job caused a one-time bloat spike, the operational fix is preventing the bloat in the first place, build-new-then-swap instead of in-place rewrite, rather than relying on autovacuum to finish cleaning it up before morning traffic arrives.
Trade-offs and pitfalls
Scheduling VACUUM FULL as a quick fix without accounting for the exclusive lock it takes turns a slow-morning problem into a cannot-query-at-all-during-maintenance problem. Treating the symptom, morning slowness, as a query problem and adding indexes or caching that do not address a job still running or a table still bloated at 9 a.m. Not measuring the maintenance job's own duration means a fix that helps today can quietly regress again next quarter as the job's data volume grows and it starts running long again.
Denormalization can improve read performance, but it is not free. When would you propose denormalizing part of a schema, and what are the trade-offs: write complexity, data-consistency risk, storage overhead, and the ongoing burden of keeping the denormalized copy in sync?
Sample Answer
Direct answer
Denormalize when a specific, measured read path is too slow because of a join, and the write complexity and consistency cost of maintaining a duplicate copy is smaller than the cost of that slow read continuing. It is a targeted fix for one query pattern, not a general schema philosophy, propose it with the specific slow query and its measured cost in hand, not as a blanket "let's denormalize for performance."
How to think about it
Write complexity. Every write that touches the source of truth now potentially has to update the denormalized copy too; miss one write path and the two copies diverge silently.
Data-consistency risk. The denormalized copy is a cache living inside your own schema, so the sync strategy has to be chosen explicitly. Same-transaction updates give the strongest consistency (both writes commit together or neither does) but couple the two tables' write paths and can add lock contention. Change-data-capture or streaming propagation is looser and eventually consistent, and needs its own lag monitoring. An accepted-stale batch job is the simplest option, with freshness equal to whatever the batch interval is.
Storage overhead. Proportional to how much is duplicated. Denormalizing a few narrow columns, a name, a price, onto a high-cardinality table is usually a small percentage overhead; denormalizing whole nested structures is not.
Ongoing burden. A denormalized copy is a second thing that can be wrong. Whoever changes the schema later has to remember it exists and keep both write paths in sync, and that cost compounds over the schema's whole lifetime, not just at build time, which is exactly why being selective about where to apply it matters.
The textbook trigger is a product listing page needing product, price, inventory, category, and seller name in one read, a five-table join collapsed to one row per item. A useful middle ground, rather than choosing fully normalized or fully denormalized for a whole schema: keep the normalized tables as the system of record and add one denormalized read table kept in sync through change-data-capture or a streaming job. Writes still go through the clean normalized schema, and only the read path gets the flattened copy, capturing most of the latency win with a narrower blast radius than denormalizing the primary write tables themselves.
Worked example
A real, measured comparison: an order-line-item read that needs the customer's name and the product's name alongside the line item itself, first as a four-table join, then against a single flattened copy of the same data.
-- normalized: 4-way join for one order's line items
Hash Join
-> Nested Loop
-> Nested Loop
-> Index Scan on cust_orders (Index Cond: id = 555)
-> Index Scan on customers (Index Cond: id = o.customer_id)
-> Bitmap Heap Scan on order_items (Index Cond: order_id = 555)
-> Hash (Seq Scan on products)
Execution Time: 0.897 ms
-- denormalized: single-table read
Bitmap Heap Scan on order_items_denorm (Index Cond: order_id = 555)
Execution Time: 0.068 ms
The normalized plan is an 18-line, four-node plan touching all of order_items, cust_orders, customers, and products; the denormalized plan is a 7-line single bitmap index scan. The measured storage delta for this specific, narrow denormalization (two short text columns duplicated onto every line item, at 40,000 rows): 3,760 kB for the normalized line-item table versus 3,888 kB for the denormalized copy, about a 3.4% overhead. That number is specific to duplicating two short columns; denormalizing wider or more redundant data scales the storage cost up proportionally, it is not a universal figure.
Trade-offs and pitfalls
Denormalizing before a slow query has actually been measured pays the write-complexity and consistency cost for a problem that may not exist. Missing a write path when the sync logic gets added is the single most common way denormalized data silently goes stale. The most damaging failure mode: a later engineer reads from the denormalized copy to make a write decision, treating it as the source of truth by accident, which compounds any existing drift into a real data-correctness bug.
Unlock Full Question Bank
Get access to all 40 Database Performance Tuning and Scaling interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.