Benchmarking
Measure how KahunaCart performs on a store far larger than yours, on your own hardware and database engine. Use it to size a server, or to compare a change against the release before it. The results we publish are on the benchmarks page.
Before you begin
- Use a database you can throw away.
bench:seedwrites hundreds of thousands of rows and refuses to run on a store that already holds orders it did not write. - Migrate the schema first. Run
bin/plugin kahunacart migrate; both commands refuse to start otherwise. - Free about 2 GB of disk for the default size on SQLite.
Caution
Never run bench:seed against a live store. --purge reverses it, but a quarter of a million orders in your admin is not something to discover by accident.
The two commands
| Command | What it does |
|---|---|
bin/plugin kahunacart bench:seed |
Fills the store with a benchmark-sized catalog, customer list and order history. |
bin/plugin kahunacart bench:queries |
Times the queries behind the storefront and the admin against whatever the store now holds. |
Both are documented with their full option lists on the CLI reference.
Seed a benchmark store
- Point
database.typeat the engine you want to measure. See Configuration → Database. - Run
bin/plugin kahunacart migrate. - Run the seeder:
bin/plugin kahunacart bench:seed
The default size takes about 30 seconds on an M-series Mac with SQLite and produces a database of about 1.5 GB, roughly 4.5 million rows.
Use a smaller store while you are setting the process up:
bin/plugin kahunacart bench:seed --products=1000 --customers=5000 --orders=20000
Write a machine-readable summary instead of a report:
bin/plugin kahunacart bench:seed --json > seed.json
Take it all back out:
bin/plugin kahunacart bench:seed --purge
--purge takes about 12 seconds at the default size.
What the seeded store contains
At the default size:
| Part | What is written |
|---|---|
| Catalog | 5,000 products across a 51-node category tree three levels deep, 40 tags, 6 filterable attributes, and around 15,000 variants. 40% of products sell as one thing, 45% carry one option axis, 15% carry two. |
| Digital goods | 15% of products, each with a product file row and some with versioned releases. No bytes are written to disk. |
| Promotions | Six sales (live, expired, scheduled, and one with quantity tiers) covering roughly a tenth of the catalog, plus 50 coupons across the whole restriction matrix. |
| Tax and shipping | A tax zone for each of 8 countries with a standard and a reduced rate, and four shipping zones with a standard, an express and a free-over-threshold method each. |
| Customers | 25,000 customers with address books, 40% of them with a store login. |
| Orders | 250,000 orders spread over 24 months and weighted toward recent weeks, with items, adjustments, transactions, shipments, refunds, download grants, coupon redemptions and around 1.9 million history entries. |
The status mix is roughly 85% paid and fulfilled, 5% awaiting payment, 5% canceled and 5% refunded in part or in full.
Every row is filled the way the checkout and fulfillment code fills it: totals reconcile with lines and adjustments, fulfillment status agrees with the parcels, a refund's ledger row points at the purchase it reverses, and each status change carries its history entry.
The seeder does not decrement stock per line, queue an email or fire an order event.
Time the queries
bin/plugin kahunacart bench:queries
Raise the sample size for a figure you intend to quote:
bin/plugin kahunacart bench:queries --iterations=100 --warmup=10
Write a machine-readable document:
bin/plugin kahunacart bench:queries --json > queries.json
Each query calls the same repository and service methods the storefront and the admin call, not hand-written SQL. When a release changes what OrderRepository::completed() does, the number moves.
The command leaves the store as it found it: the cart it prices is removed and the stock it takes is put back.
What each query stands for
| Query | The page or action behind it |
|---|---|
catalog listing, page 1 |
The shop page with a category filter and a price sort, plus the count the pager needs. |
catalog listing, deep page |
The same listing at page 200, or the last page that holds rows on a smaller store. |
product page by slug |
A product page: the product, its option axes, its variants, its media and its sale price. |
catalog search |
{base}/search?q= for a word most of the catalog matches. |
admin order list, page 1 |
The admin order list at its default sort. |
admin order list, deep page |
The same list at page 500, or the last page that holds rows. |
order search by email |
The admin order search, given a customer's address. |
order search by number |
The same search, given an order number. |
order detail |
Opening one order: its lines, adjustments, transactions, history and shipments. |
customer by email |
The lookup every checkout does before it writes anything. |
report, revenue by month |
Revenue over the whole order history, bucketed by day and rolled up to months. |
report, top products |
The best sellers over the whole order history. |
cart pricing, 5 lines |
A five-line cart priced through the real stage pipeline: sales, coupons, shipping, tax. |
stock decrement and restore |
Taking a unit off the shelf and putting it back, in one transaction. |
Read the numbers
Four figures per query, in milliseconds.
| Figure | What it tells you |
|---|---|
min |
The best case, with everything cached. |
p50 |
The typical run. |
p95 |
The number to argue about. A query that is usually fast and occasionally slow is a broken screen, and its average hides that. |
max |
The worst run, which is the one that reaches a support ticket. |
The rows column is how many rows the query returned. A timing that improved because the query stopped matching anything shows up as a row count that fell.
Measured: the order search and the report rollup
The first standard-size run found two queries that scaled with the orders table. Both were fixed, in migrations 0031 and 0032. Before-and-after figures on the default seed, SQLite 3.53, PHP 8.4, an M-series Mac, 50 iterations after 5 warm-up runs, measured while the machine was also serving a load test:
| Query | Before | After | What changed |
|---|---|---|---|
order search by email |
312.01 ms p50 / 316.21 p95 | 5.09 ms p50 / 5.32 p95 | search_text plus an FTS5 index instead of a leading-wildcard LIKE. |
order search by number |
311.78 ms p50 / 317.71 p95 | 0.07 ms p50 / 0.08 p95 | The same column, matched as an indexed prefix. |
report, revenue by month |
994.10 ms p50 / 1327.96 p95 | 41.70 ms p50 / 42.59 p95 | The daily rollup, read for every closed day. |
report, top products |
999.81 ms p50 / 1346.59 p95 | 41.91 ms p50 / 42.64 p95 | kahunacart_product_stats under a covering index. |
Every other query moved by less than a millisecond. Seeding costs about 17 seconds more, for the search haystack and the rollup.
Three indexes do most of that work.
ix_kahunacart_orders_search_text, and the engine text index beside it. See Orders and the admin.ix_kahunacart_orders_status_completedon(status, completed_at). The live half of a report went from 762 ms to 0.1 ms.ix_kahunacart_product_stats_product, which covers the group-by behind top products: 23 ms instead of 200 ms.
Measured: comparison reporting
The Analytics screen asks two questions the benchmark's own targets do not: a range against another range, and a filtered range cut by a dimension. Both were measured on a 250,000-order store on each engine, 11 runs each, on the machine described under Compare database engines.
| What | SQLite | MariaDB | PostgreSQL |
|---|---|---|---|
| 90-day range compared against the same dates a year earlier, unfiltered | 44.3 ms | 70.5 ms | 24.0 ms |
| 90-day range with three filters, broken down by payment provider | 377.3 ms | 2,126.1 ms | 197.5 ms |
| the 90-day range on its own | 0.1 ms | 0.3 ms | 0.3 ms |
| the previous-year range on its own | 44.6 ms | 71.3 ms | 25.4 ms |
| the filtered scan on its own | 176.2 ms | 1,026.4 ms | 99.9 ms |
| the breakdown on its own | 191.7 ms | 1,026.9 ms | 100.3 ms |
| an unfiltered breakdown, which reads the rollup | 0.1 ms | 0.7 ms | 0.3 ms |
Three things to read out of that table.
An unfiltered range that is entirely rolled up is free, on every engine. It is a sum over day rows under a covering index.
The comparison range costs what it costs because of the timezone. Comparison periods use calendar dates in the store's timezone, so the previous-year range starts and ends part way through a UTC day, and those two partial days are read from the orders table. That is the whole 44 ms on SQLite, 71 ms on MariaDB and 25 ms on PostgreSQL. See the timezone rule.
A filter means reading the orders table, because the rollup has no order columns to filter on. The filtered numbers are four indexed statements (orders, lines, adjustments and refund transactions) plus the breakdown's own, all driven from ix_kahunacart_orders_status_completed. Before filter sets that do not join stopped being wrapped in a semi-join, the filtered scan was 1,608 ms and the segmented report 3,454 ms on SQLite.
Note
This is where the engines part company. A filtered report reads a quarter of a million order rows, and MariaDB takes about six times as long over them as the other two. A merchant on MariaDB who lives in the Analytics screen with filters on will feel that; their storefront and order list will not. The statements and the index are the same on all three engines.
Compare database engines
Point database.type at each engine in turn, migrate, seed from the same --seed, and run the queries:
bin/plugin kahunacart migrate
bin/plugin kahunacart bench:seed --json > seed-sqlite.json
bin/plugin kahunacart bench:queries --json > queries-sqlite.json
Then set database.type to mysql or pgsql in user/config/plugins/kahunacart.yaml, and repeat.
The same rows land on all three engines, so any difference in the timings is the engine.
Measured: all three engines, same store, same machine
Every figure below was measured on one laptop: an Apple M4 Max with 36 GB of memory running macOS 26.6 and PHP 8.4.25, with nothing else running.
| Version | How it was configured | |
|---|---|---|
| SQLite | 3.53.4 | Whatever pdo_sqlite was built with, opened the way the plugin opens it: WAL, synchronous=NORMAL, temp_store=MEMORY. |
| MariaDB | 12.3.3 | Homebrew's package defaults, untouched: innodb_buffer_pool_size 128 MB, innodb_flush_log_at_trx_commit 1, strict mode on. |
| PostgreSQL | 16.15 | Homebrew's package defaults, untouched: shared_buffers 128 MB, work_mem 4 MB. |
The store is the default seed on each engine: bench:seed with no flags, so seed 20260830 and --now 2026-08-30T00:00:00Z, giving 5,000 products, 25,000 customers, 250,000 orders and 511,731 order lines on all three.
| Engine | Seeding | On disk |
|---|---|---|
| SQLite | 104 s | 1.8 GB |
| MariaDB | 104 s | 2.4 GB |
| PostgreSQL | 85 s | 2.3 GB |
bench:queries at its defaults, 50 timed runs after 5 warm-up runs, three times per engine. Each cell is the median of the three runs' p50s, in milliseconds. Every cell above 5 ms sat inside a 10% spread across its runs, most inside 3%.
| Query | SQLite | MariaDB | PostgreSQL |
|---|---|---|---|
catalog listing, page 1 |
9.84 | 7.31 | 1.32 |
catalog listing, deep page |
11.39 | 7.71 | 5.45 |
product page by slug |
1.38 | 3.04 | 2.01 |
catalog search |
4.20 | 12.34 | 5.67 |
catalog listing, three facets |
8.97 | 2.13 | 2.02 |
catalog listing, on sale |
3.20 | 12.11 | 4.84 |
catalog facet counts |
5.57 | 4.08 | 2.84 |
catalog search, two words |
6.75 | 15.33 | 5.26 |
admin order list, page 1 |
0.07 | 0.23 | 0.31 |
admin order list, deep page |
14.96 | 110.00 | 10.84 |
order search by email |
5.35 | 70.74 | 4.66 |
order search by number |
0.08 | 51.61 | 0.30 |
order detail |
0.08 | 0.65 | 0.75 |
customer by email |
0.00 | 0.06 | 0.08 |
report, revenue by month |
53.27 | 178.55 | 44.21 |
report, top products |
53.26 | 180.26 | 41.10 |
cart pricing, 5 lines |
0.36 | 2.10 | 2.68 |
stock decrement and restore |
0.05 | 0.53 | 0.70 |
There is no headline. Fourteen of the eighteen queries land inside a few milliseconds of each other on all three engines, and on a real storefront request the page, the theme and the network are what a shopper waits for. Pick the engine your hosting and your backups want. The four rows where the engines differ each have a cause.
MariaDB's order search is a fixture artefact. Every seeded order has an address at bench.kahunacart.test and a number starting BENCH-, so the words bench, kahunacart and test appear in all 250,000 search haystacks. InnoDB's FULLTEXT in boolean mode intersects a posting list per word, and a word in every row is a posting list the length of the table. On the seeded store +bench* +126001* takes 76.8 ms and +126001* alone takes 0.20 ms, for the same single order. A real store's emails and order numbers are not all the same three words, so a real store does not pay this.
*MariaDB's deep order page is `SELECT ` over an offset.** 110 ms at page 500, against 15 ms and 11 ms. The admin list reads whole order rows and skips 12,475 of them to reach 25, and an order row on MariaDB carries the search haystack and several JSON snapshots. Page 1 is 0.23 ms on the same engine. Nobody browses to page 500 of an order list; they search.
MariaDB's reports are three to four times the other two, 178 ms against 53 ms and 44 ms. Both are one GROUP BY product_id over the 469,000 rows of kahunacart_product_stats, and EXPLAIN confirms MariaDB uses the covering index like the other two. It reads that index more slowly per row, and there is no setting to change. The comparison-reporting table shows the same gap larger, because a filtered report reads orders instead of the rollup.
PostgreSQL needs its table statistics, and after a bulk seed it does not have them yet. Straight after bench:seed, catalog listing, three facets was 56.29 ms; after a VACUUM ANALYZE (2.6 seconds by hand) it was 2.02 ms, and everything else came down 10 to 20% too. Autovacuum had not caught up with a quarter of a million rows written in eighty seconds. The PostgreSQL column above is measured after it. ANALYZE TABLE on MariaDB moved nothing, and SQLite has no statistics unless you ask for them, which the plugin never does.
So on PostgreSQL, run VACUUM ANALYZE between bench:seed and bench:queries, or wait for autovacuum. On the other two, seed and measure.
The determinism contract
One integer and one instant decide everything the seeder writes: --seed and --now. Nothing in the seeder calls a random or a clock function, so:
- The same options produce the same store. Same product slugs, same order numbers, same totals, same hashes, on SQLite, MySQL and PostgreSQL alike.
- The engines are comparable. Seed all three from the same seed and any difference in the timings is the engine, not the data.
- Releases are comparable. Re-seed after a release with the same
--seedand--now, and the numbers either side measure the same store.
Each phase draws from its own stream derived from the seed, so raising --orders leaves the catalog byte-identical.
Both --json documents carry the plugin version, the git commit, the engine and its version, the PHP version, the seed and the instant.
What the benchmark marks as its own
There is no migration and no marker column. Every row the seeder writes carries a recognizable name:
| Rows | Marked by |
|---|---|
| Products, categories, tags, attributes, sales, zones | A bench- slug or name. |
| Customers | An address at bench.kahunacart.test. |
| Orders and invoices | A BENCH- number. |
| Coupons | A BENCH code. |
--purge removes exactly those, children first, and leaves anything else untouched.
bench:seed refuses to run when it finds orders that are not its own, unless you pass --force.
Turn a benchmark into a decision
The answer is not the same for every store.
- SQLite needs no server, is the quickest on the small reads a storefront makes most of, and is the right answer for a store whose traffic one machine can hold.
- PostgreSQL is the most even of the three. It leads on the catalog listing and on everything that reads the whole order history. Pick it if your merchants live in the Analytics screen with filters on, and give it a
VACUUM ANALYZEafter a bulk import. - MySQL and MariaDB are the pragmatic answer where a MySQL server already exists and is backed up. They keep up on fourteen of the eighteen queries; where they do not is reporting and a deep order page.
Take your own numbers before you decide. A store with 2,000 orders and a store with 250,000 do not have the same answer, and neither does a box with a slow disk. Size the database server to the data first: see Performance.
Note
bench:queries measures the database only. A page a visitor waits for is that figure plus rendering and the network. See the published benchmarks for whole-page figures under load.
Related
- CLI: every
bin/plugin kahunacartcommand with its flags. - Configuration: the database options, including the three engines.
- Orders and the admin: the three-axis order status the seeded orders carry, and how the order search is indexed.
- Reports and the rollup: the daily rollup the report timings above are measured against.
- Analytics: the comparison, filter and breakdown paths the second table measures.
- Performance: the settings that decide how fast the storefront answers, and what to tune before you rerun.
- Troubleshooting: what to check when a query is slow on a real store.
- Published benchmarks: the numbers we take from these commands, rerun after every development wave.