In this local PostgreSQL lab, the same aggregate query had a median client elapsed time of 0.383 ms at 10,000 rows, 0.634 ms at 100,000 rows, and 2.464 ms at one million rows. Each size has 540 recorded queries.
The query returned one aggregate row each time. Inside the database, the matching rows grew from 20 to 200 to 2,000. That is the question this experiment isolates: how does growing data change the work behind the same query?
This is a measured lab using generated data, run on October 11, 2026. It is not a customer incident or a production capacity benchmark. The scripts, frozen protocol, environment, raw timings, result checksums and query plans are available in the reproducibility bundle.
Three separate tables used the same schema and index definitions: a primary key on id and an index on (tenant_id, status). Each row carried a 128-byte payload. The generator kept 100 tenants and a 20% open-order share within each tenant. The tables contained 10,000, 100,000 and one million rows.
The query counted open orders and summed their amounts for tenant 42:
SELECT count(*) AS open_count,
sum(amount_cents) AS open_amount_cents
FROM lab.orders_10000
WHERE tenant_id = 42 AND status = 'open';
The table name selected the data size; the query structure and predicate stayed unchanged. The one-row response shape stayed fixed, while its count and sum values changed with the generated data. Index definitions stayed fixed; their physical size grew with the tables.
The environment was PostgreSQL 18.4 on macOS 26.5.2, an Apple M4 with 10 logical CPUs and 16 GiB RAM. The separate lab cluster used 256 MiB shared buffers and 16 MiB work memory. Parallel query, JIT and autovacuum were disabled for this controlled read-only run. These are lab controls, not a production configuration recommendation.
One persistent local connection ran one query at a time, with a 10 ms pause between recorded queries. Arrival rate was not controlled. The client timer surrounded the synchronous libpq call until the complete aggregate result arrived. Connection startup, result decoding and checksum calculation were outside that interval.
Each table was vacuumed and analyzed once before the run, with the same statistics target. Statistics were not refreshed during measurement. Ten recorded warmups preceded each table block; their timings were excluded from the reported query summaries. This established a warm-query procedure, without claiming the entire database or operating-system cache was uniformly warm.
A short pilot checked runtime and result correctness. Then the protocol and source hashes were frozen. The formal run completed three cycles of all six table-order permutations, changing permutation order between cycles. Each table had 30 timed queries in each block: 540 per size, 1,620 total. The formal window lasted approximately 23.39 seconds.
The p95 is the nearest-rank sample quantile: the 513th ordered observation out of 540. It describes these recorded samples, not a guaranteed tail latency or an SLO result.
The ordering also held across cycle medians. For the three cycles, those medians ranged from 0.362–0.394 ms at 10,000 rows, 0.574–0.663 ms at 100,000 rows, and 2.406–2.544 ms at one million. Individual samples varied more widely; the complete values remain in the bundle. All 1,620 queries returned the independently checked count and sum. There were zero recorded errors, timeouts or result mismatches.
Observed warm serial-query results, October 11, 2026. Client timings and instrumented plan measurements are separate. Both chart axes start at zero; buffer hits are accesses, not unique pages or physical disk operations.
After each table block, a separate EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) captured the plan and counters. All 54 observations used the same plan shape: aggregate, bitmap heap scan, bitmap index scan. They processed 20, 200 and 2,000 matching rows for the three data sizes.
Their root shared-buffer hit totals were 22, 202 and 2,004 respectively, with zero shared block reads or temporary block I/O in those observations. The query was doing more matching-row and buffer-access work even under this warm procedure. These counters do not identify a CPU-only cause or measure physical disk activity. Root totals include child activity, so the analysis does not add the child counters again. PostgreSQL’s EXPLAIN documentation defines these counters and the timing boundary.
EXPLAIN ANALYZE executes and instruments the query. Turning node timing off retains the statement execution total; instrumentation still has overhead. Its execution times remain separate from the plain-query client measurements. PostgreSQL’s plan-reading guide describes that measurement caveat.
For this aggregate, preserving request structure and index definitions did not preserve the amount of work. Growing the generated data kept the matching proportion stable while increasing the absolute matching count. The larger-table median was higher in all three cycles.
The result does not establish that every query slows with data growth. A point lookup, different index, skewed tenant distribution, cold-cache run, concurrent writers or production workload could behave differently. This was a short run on a shared desktop host; background activity was not controlled, and CPU utilization was not measured.
The practical next step is to choose a real workflow, define its completion boundary and ask which data dimension changes the work. Data Growth Is Load supplies the proposed comparison worksheet. Write the Prediction helps state the expected mechanism and what would challenge it. This lab supplies one inspectable example; a repeat with no material difference or an inconclusive result belongs in the record too.
Browse writing · Browse case studies · Discuss a performance problem