Database and pipeline benchmarks

Pushword’s database benchmark compares SQLite, MariaDB and PostgreSQL at three levels: isolated database operations, representative application reads and real end-to-end commands or HTTP requests. The fixtures are deterministic and synthetic; no production content or configuration is read.

This page records a reference run from 17 August 2026. It is useful for spotting scaling trends and large regressions, but it is not a production capacity guarantee. Absolute timings depend on the machine, database configuration, filesystem cache and background activity.

Run the benchmark

With MariaDB and PostgreSQL available on the configured test URLs, run the default volume ladder from the repository root:

composer bench-databases

The default run measures database and application workloads at 100, 1,000 and 10,000 synthetic pages. The longer end-to-end pipelines default to 100 and 1,000 pages. Run their 10,000-page variants explicitly when needed:

PUSHWORD_BENCH_PIPELINE_VOLUMES=100,1000,10000 composer bench-databases

To reproduce only the 10,000-page static generation measurement:

PUSHWORD_BENCH_PIPELINE_VOLUMES=10000 \
  ./.scripts/test --benchmark StaticGeneratorBenchmarkTest

The runner prints Markdown tables and removes its temporary databases, generated files and result files after the run. DSN and volume overrides are documented in Contribute > Database volume benchmark.

What is measured

LayerWorkloadRepetitions
Database operationsBatched inserts, count, 200 indexed slug lookups, tag filtering, numeric JSON filtering and a sorted 50-row listMedian of 5 reads
Application workloadMultisite editorial navigation, internal-link resolution, catalogue filters, facets and a content-export passMedian of 3 reads
pw:page-scanThe real command scans a deterministic internal-link graph with external checks disabled1 command run
pw:staticThe real command renders HTML and writes it to an isolated directory with one worker1 command run
EasyAdminAuthenticated list, pagination, search, host filter and sort requestsSum of the median of 3 runs per request

The pipeline volume is the number of synthetic pages added to the normal test fixtures. Consequently, operation and SQL-query totals also include the small set of pages already present in the development application. For pw:static, the query counter covers the command’s coordinating entity manager, not the separate render kernel connection. Instrumenting both connections is useful for profiling but adds enough overhead to invalidate cross-engine duration comparisons. Xdebug is disabled, database engines run locally, pipelines are sequential, and timings use a monotonic clock.

Reference environment

ComponentValue
Operating systemLinux 7.1.6, x86-64
ProcessorAMD Ryzen AI 9 HX 370, 12 cores / 24 threads
Memory125 GiB
PHP8.5.9 NTS
SQLite3.51.2, local file
MariaDB11.8.8, local TCP connection
PostgreSQL17.11, disposable local TCP container

All three engines used the same application code and test environment. The server databases were reached over loopback, so these results do not include production network latency.

Database operation results

Times are milliseconds; lower is better. Write covers fixture insertion. Lookups is the complete batch of 200 indexed slug queries.

PagesEngineWriteCountLookupsTag filterJSON numberList 50
100SQLite29.30.052.380.050.060.07
100MariaDB62.20.104.980.090.110.11
100PostgreSQL65.60.1913.070.120.150.12
1,000SQLite321.30.102.360.140.240.14
1,000MariaDB416.50.536.670.490.610.64
1,000PostgreSQL564.30.1822.590.310.550.30
10,000SQLite3,123.80.762.581.092.150.93
10,000MariaDB4,306.84.595.413.245.175.50
10,000PostgreSQL6,168.11.0212.171.623.421.31

SQLite has the lowest write and point-lookup cost on this local, single-process workload. PostgreSQL’s larger fixed cost is visible at low volume, while its count, tag, JSON and list timings remain closer to SQLite than MariaDB at 10,000 pages.

Application workload results

Times are milliseconds; lower is better. Rich seed creates the related editorial and catalogue corpus and is not included in Read total.

PagesEngineRich seedEditorialCatalogueFacetsExportRead total
100SQLite75.97.471.370.540.499.87
100MariaDB104.313.083.111.201.0718.46
100PostgreSQL103.822.595.701.391.7531.43
1,000SQLite707.422.355.866.894.2139.31
1,000MariaDB968.125.836.725.645.9944.19
1,000PostgreSQL1,088.125.484.743.1710.7244.11
10,000SQLite6,970.1135.3346.36115.3547.59344.63
10,000MariaDB9,028.9105.1943.9860.51314.37524.05
10,000PostgreSQL10,523.966.0519.4122.13125.05232.64

The small-volume result is dominated by fixed round-trip cost. At 10,000 pages, PostgreSQL completes this particular read mix fastest, especially the catalogue and facet queries. MariaDB’s export pass is the largest outlier in this snapshot. These are workload-specific results, not a ranking of the databases in general.

Initial end-to-end pipeline results

Throughput includes the development fixtures as well as the requested synthetic volume. Peak memory is the PHP process peak, not total database or operating-system memory.

ScenarioPagesEngineDuration msThroughputSQL queriesPeak memory MiB
pw:page-scan100SQLite245473.5 pages/s244135.0
pw:page-scan100MariaDB258449.6 pages/s244176.3
pw:page-scan100PostgreSQL285407.0 pages/s244166.3
pw:page-scan1,000SQLite1,589639.4 pages/s2,044166.0
pw:page-scan1,000MariaDB1,880540.4 pages/s2,044201.3
pw:page-scan1,000PostgreSQL1,976514.2 pages/s2,044199.3
pw:static100SQLite1,049111.5 pages/s249190.9
pw:static100MariaDB1,28091.4 pages/s249208.3
pw:static100PostgreSQL1,39583.9 pages/s249206.3
pw:static1,000SQLite8,896114.3 pages/s2,049203.8
pw:static1,000MariaDB11,41089.1 pages/s2,049216.8
pw:static1,000PostgreSQL11,12291.4 pages/s2,049212.8
EasyAdmin100SQLite255.719.6 requests/s25110.0
EasyAdmin100MariaDB281.017.8 requests/s25147.3
EasyAdmin100PostgreSQL249.820.0 requests/s23141.3
EasyAdmin1,000SQLite242.120.6 requests/s25116.0
EasyAdmin1,000MariaDB254.219.7 requests/s25151.3
EasyAdmin1,000PostgreSQL272.518.4 requests/s23145.3

EasyAdmin stays effectively flat from 100 to 1,000 pages, and its constant query count is the more durable signal than the small timing differences. Page scan issues approximately two observed queries per processed page. The initial static-generation result showed the same shape on its coordinating connection, but profiling the render kernel exposed additional corpus-wide work hidden behind that count.

Static generation at 10,000 pages

The 10,000-page static scenario was run separately because it takes several minutes per engine. Each run successfully rendered and validated exactly 10,000 synthetic HTML files; the operation count also includes 17 development-fixture pages.

EngineInitialOptimizedChangeOptimized throughputCoordinator queriesPeak memory MiB
SQLite203.8 s76.7 s-62.4%130.60 pages/s10,035540.9
MariaDB242.5 s107.2 s-55.8%93.47 pages/s10,035561.8
PostgreSQL254.9 s113.1 s-55.6%88.54 pages/s10,035561.8

The initial run rebuilt two complete page maps for every rendered page: redirect lookup data in Core and internal-link source data in Link Improver. Both caches were cleared by the render kernel’s service reset after every request, turning otherwise linear work into two quadratic paths. Static generation now pins those read-only maps for one build. A render epoch still invalidates Link Improver data when the page corpus changes in a long-lived process. Feed generation also preloads the set of parent pages once instead of issuing one child-count query per page.

At 5,000 pages, the render-kernel phase fell from 39.5 s to 11.0 s and the complete run from 64.4 s to 36.2 s. The optimized 1,000-page runs took 6.9 s on SQLite, 8.2 s on MariaDB and 8.1 s on PostgreSQL. From 1,000 to 10,000 pages, duration now grows by 11.1x, 13.0x and 13.9x respectively, instead of 21–23x in the initial snapshot.

The benchmark also records generated HTML size and the kernel.handle, compression, file-write and page-generation phases in its JSON result. The 10,000-page corpus produced about 86.9 MiB of HTML on every engine. These diagnostics are deliberately reported rather than enforced as CI thresholds; shared-runner timing noise would make such thresholds unreliable.

Interpreting and updating the results

  • Compare query counts and scaling curves before comparing sub-millisecond timings.
  • Repeat pipeline runs when investigating a small difference; their published values are single snapshots, unlike the median database and application reads.
  • Keep the same machine, engine versions and volume ladder when comparing a change.
  • Do not infer concurrent request capacity: this benchmark is deliberately sequential and uses local databases.
  • Update the date, environment and complete affected table after a representative rerun. Label code revisions explicitly when a table is intended as a before/after comparison.

The structural query-count regression tests under packages/core/tests/Perf/ and packages/admin/tests/Perf/ run in normal CI. Timing benchmarks remain opt-in because shared CI runners are too noisy for stable latency thresholds.