Same Query, More Rows: A PostgreSQL Lab

Same Query, More Rows: A PostgreSQL Lab

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.

What stayed the same?

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.

What did the timer include?

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.

What changed in the observations?

  • 10,000 rows: median 0.383 ms; empirical p95 0.609 ms.
  • 100,000 rows: median 0.634 ms; empirical p95 0.963 ms.
  • One million rows: median 2.464 ms; empirical p95 3.280 ms.

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.

Measured PostgreSQL lab results: median client elapsed times of 0.383, 0.634 and 2.464 milliseconds for 10k, 100k and one million rows, from 540 queries per size. Empirical p95 values are 0.609, 0.963 and 3.280 milliseconds. Separate plans recorded 22, 202 and 2,004 shared-buffer hits with no engine block reads.

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.

What did the plans explain?

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.

What does this result support?

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.

Update — October 11, 2026: A fresh run preserved the overall size ordering, while block-level results varied. The follow-up explains what that means for performance planning and the next comparison.

data-growth-lab-reproducibility.zip372.1 KiB

Browse writing · Browse case studies · Discuss a performance problem