Skip to content

Powered by Grav CMS + Helios Theme

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:seed writes 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

  1. Point database.type at the engine you want to measure. See Configuration → Database.
  2. Run bin/plugin kahunacart migrate.
  3. Run the seeder:
BASH
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:

BASH
bin/plugin kahunacart bench:seed --products=1000 --customers=5000 --orders=20000

Write a machine-readable summary instead of a report:

BASH
bin/plugin kahunacart bench:seed --json > seed.json

Take it all back out:

BASH
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

BASH
bin/plugin kahunacart bench:queries

Raise the sample size for a figure you intend to quote:

BASH
bin/plugin kahunacart bench:queries --iterations=100 --warmup=10

Write a machine-readable document:

BASH
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_completed on (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.

See Reports and the rollup.

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:

BASH
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:

  1. The same options produce the same store. Same product slugs, same order numbers, same totals, same hashes, on SQLite, MySQL and PostgreSQL alike.
  2. The engines are comparable. Seed all three from the same seed and any difference in the timings is the engine, not the data.
  3. Releases are comparable. Re-seed after a release with the same --seed and --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 ANALYZE after 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.

  • CLI: every bin/plugin kahunacart command 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.