I ran one small Go service against PostgreSQL 18 and then against MariaDB 12.3, on the same database machine, and pushed it with k6 until the database CPU was full. This post is the reference: machine sizes, both config files, the load script, the full per-level tables, and the places where the numbers are too close or too few to mean much.
The app, the k6 script, the setup and run scripts are in huseyinbabal/db-wars.
Result first
Equal settings, a pool of 100 connections, 5 million orders unless noted. Not one request failed in any run.
| Test | Runs | PostgreSQL | MariaDB | Difference |
|---|---|---|---|---|
| Reads (95% reads), up to 800 users | 3 | 7,805 / 7,910 / 7,912 req/s | 7,789 / 7,902 / 7,906 req/s | about the same |
| Reads, ceiling | 3 | 12,527 / 13,324 / 13,435 req/s | 10,344 / 10,772 / 11,016 req/s | Postgres +21% to +24% |
| Reads, p95 at 1,600 users | 3 | 55 to 85 ms | 117 to 147 ms | |
| Writes (50% writes), ceiling | 3 | 4,122 / 4,385 / 4,277 req/s | 3,991 / 4,085 / 4,154 req/s | Postgres +3% to +7% |
| 20 million orders, ceiling | 1 | 9,827 req/s | 10,346 req/s | MariaDB +5% |
| Spike 100 → 3,000 → 100 users | 4 | 6,630 / 6,837 / 6,693 / 6,805 req/s | 7,042 / 6,565 / 6,662 / 6,626 req/s | a few percent, both directions |
The short version: below the point where the database CPU runs out, the two serve the same traffic at the same latency. The read ceiling is where Postgres pulls ahead. Writes, a dataset bigger than memory and a sudden spike all end within a few percent.
Machines
Three DigitalOcean droplets in one VPC, region lon1, Ubuntu 24.04, CPU-optimized sizes so the vCPUs are dedicated.
| Machine | Size | Runs |
|---|---|---|
| load | c-8 (8 vCPU, 16 GB) | k6, Prometheus, Grafana |
| app | c-4 (4 vCPU, 8 GB) | the Go service |
| db | c-2 (2 vCPU, 4 GB) | PostgreSQL or MariaDB, never both at once |
k6 talks to the app and the app talks to the database only over private addresses. The database machine is the small one on purpose, so it is the first thing to run out.
The sizes matter for one reason worth knowing before you copy this. A first trial on a shared-CPU droplet showed CPU steal climbing from about 6% to about 50% during the session. Throughput fell with it, so two runs an hour apart were not comparable. On a shared host, watch the st column in top or vmstat before you trust any comparison.
Settings, written out for both
Nothing is left on a default that could differ between the two. PostgreSQL gets a file in conf.d:
max_connections = 200
shared_buffers = 1GB
effective_cache_size = 3GB
max_wal_size = 2GB
wal_level = minimal
max_wal_senders = 0
fsync = on
synchronous_commit = on
default_transaction_isolation = 'read committed'
ssl = off/etc/postgresql/18/main/conf.d/bench.conf
MariaDB gets the matching options:
[mariadbd]
max_connections = 200
innodb_buffer_pool_size = 2560M
innodb_log_file_size = 2G
innodb_flush_log_at_trx_commit = 1
skip-log-bin
transaction-isolation = READ-COMMITTED
character-set-server = utf8mb4/etc/mysql/mariadb.conf.d/99-bench.cnf
Read them as pairs: both flush every commit to disk, both use read committed, neither keeps a replication log, neither uses TLS. Memory is the exception, and it is deliberate. PostgreSQL reads through the operating system page cache, so 1 GB of shared_buffers plus the page cache use the machine’s memory. InnoDB opens its files with O_DIRECT and has nothing behind its buffer pool, so it gets the bigger share.
The app
A small order service written with database/sql and hand-written SQL: pgx for PostgreSQL, go-sql-driver/mysql for MariaDB. Placeholders are written as ? and rewritten to $1, $2 for PostgreSQL; otherwise the SQL is the same.
| Endpoint | What it does |
|---|---|
GET /products/{id} | one row by primary key |
GET /products?category=&after= | one page of products from an index |
GET /orders/{id} | an order with its items |
POST /orders | one transaction with 7 statements: 2 stock updates, 2 price reads, 1 order insert, 2 item inserts |
The pool is SetMaxOpenConns(100) with the same number of idle connections. Data is loaded with shop seed -products 100000 -orders 5000000.
Prepare once, on both drivers
This is the part that can make a comparison like this wrong by a factor of two. When you pass arguments to db.QueryContext without a prepared statement, go-sql-driver/mysql (with its default interpolateParams=false) prepares a statement on the server, executes it and closes it: three round trips for one query. pgx caches prepared statements per connection on its own. Left alone, MariaDB pays two extra round trips per query that PostgreSQL does not, and the result measures the driver instead of the database.
The service prepares each query once and reuses the statement on both databases:
var stmts sync.Map
func stmt(ctx context.Context, query string) (*sql.Stmt, error) {
if s, ok := stmts.Load(query); ok {
return s.(*sql.Stmt), nil
}
s, err := db.PrepareContext(ctx, q(query))
if err != nil {
return nil, err
}
if old, loaded := stmts.LoadOrStore(query, s); loaded {
s.Close()
return old.(*sql.Stmt), nil
}
return s, nil
}
Inside the order transaction the prepared statements are bound with tx.StmtContext, and items are sorted by product id first so every transaction takes the row locks in the same order.
The load
Users arrive in levels. Each one sends a request, sleeps 50 to 150 ms and sends the next, so the request rate grows with the number of users until something saturates.
const stages = [];
for (const u of USERS) {
stages.push({ target: u, duration: '10s' }); // new users arrive
stages.push({ target: u, duration: `${HOLD}s` }); // hold
}
WRITES is the share of requests that create an order; the reads split into 50% product by id, 30% product list and 20% order by id.
k6 run -o experimental-prometheus-rw -e BASE_URL=http://10.0.0.2:8000 \
-e WRITES=0.05 -e USERS=100,200,400,800,1600,3200 -e HOLD=80 users.js
The first read round and the first write round held each level for 170 seconds and stopped at 1,600 users. The later rounds use the command above. Before every run both databases are stopped, the page cache is dropped, one database is started and its data reloaded, and the run starts after 30 seconds of quiet. During the run vmstat, iostat, top, pidstat and a load-average loop write one line per second on the database and app machines, and k6 sends its metrics to Prometheus.
Reads, level by level
Third read round. Request rate and latency come from k6; the CPU columns are the database machine.
users req/s p50 p95 p99 fail% | db cpu: usr sys wait idle load
100 986 0.7 2.3 3.8 0.00 | 8 6 2 82 8.3
200 1978 0.7 2.3 3.4 0.00 | 12 9 3 74 2.9
400 3951 0.6 2.5 3.7 0.00 | 19 15 5 59 1.4
800 7912 0.6 2.8 4.9 0.00 | 33 25 5 35 1.5
1600 13351 12.8 54.5 82.1 0.00 | 57 42 0 1 51.6
3200 13435 98.2 388.6 587.0 0.00 | 57 41 0 1 84.6output:
users req/s p50 p95 p99 fail% | db cpu: usr sys wait idle load
100 984 0.8 2.3 3.3 0.00 | 10 4 1 82 7.7
200 1972 0.7 2.2 3.4 0.00 | 17 8 3 71 2.3
400 3951 0.7 2.5 3.8 0.00 | 29 14 4 51 1.1
800 7906 0.7 3.1 5.6 0.00 | 48 24 3 23 2.5
1600 11016 33.6 117.3 175.5 0.00 | 63 31 0 0 46.8
3200 10936 136.1 551.4 842.6 0.00 | 66 33 0 1 84.0output:
Look at the 800-user row. Both serve about 7,910 requests per second (7,912 and 7,906), so they do the same work, and both answer 95% of requests in about 3 ms. PostgreSQL does it with 35% of the CPU idle and MariaDB with 23%. Most of the gap is user time: 33% against 48%, with system time at 25% and 24%. That spare CPU is the head start PostgreSQL has when the next level arrives. At 1,600 users both machines are at 0 to 1% idle, and the request rate stops where each database ran out.
I measured how much CPU each one used for the same requests. I did not profile where MariaDB spends the extra user time, so I am not going to guess at it here.
Past the ceiling, the extra users only add waiting. The request rate stays flat from 1,600 to 3,200 users while p95 grows about seven times on PostgreSQL and five times on MariaDB, and the load average reaches 84 on a two-core machine.
At the limit the two show up differently in top. PostgreSQL forks one backend process per connection, so with a pool of 100 the run queue fills with postgres processes. MariaDB is a single mariadbd process with one thread per connection, so top shows one line at about 190% CPU, both cores.
Writes
With WRITES=0.5, every second request is a new order. The ceiling comes much earlier, between 400 and 800 users, because every order is a seven-statement transaction with a flush to disk at commit.
users req/s p50 p95 p99 fail%
100 971 1.7 4.3 25.3 0.00
200 1951 2.1 4.5 7.4 0.00
400 3800 3.0 16.4 35.2 0.00
800 4250 71.3 220.4 326.6 0.00
1600 4218 202.2 788.6 1201.2 0.00
3200 4277 457.6 1894.1 2921.4 0.00output:
Across three rounds PostgreSQL was 3% to 7% ahead at the ceiling. MariaDB’s curve has the same shape.
The useful lesson here is what saturation looks like. Nothing crashed and nothing returned an error, even with 3,200 users on a database that tops out around 4,200 requests per second. The pool of 100 limits how many connections reach the database, and everyone else waits for a connection. The cost shows up only in latency: p95 went from 16 ms at 400 users to almost 1.9 seconds at 3,200. A dashboard that only counts errors would show this as healthy.
20 million orders
Same read mix, four times the orders, so the working set no longer fits in memory on the database machine.
| Ceiling at 5M orders (best run) | Ceiling at 20M orders | |
|---|---|---|
| PostgreSQL | 13,435 req/s | 9,827 req/s |
| MariaDB | 11,016 req/s | 10,346 req/s |
PostgreSQL lost more and MariaDB ended 5% ahead. This test ran once, and the spike below shows why one run is not enough to settle a 5% gap.
The spike
100 users, then 3,000 at once, then 100 again, with 20% writes.
| Run | PostgreSQL | MariaDB |
|---|---|---|
| 1 | 6,630 req/s | 7,042 req/s |
| 2 | 6,837 req/s | 6,565 req/s |
| 3 | 6,693 req/s | 6,662 req/s |
| 4 | 6,805 req/s | 6,626 req/s |
After the first run MariaDB was 6% ahead. After the next three, PostgreSQL was ahead by up to 4%. Repeated runs on the same machines moved by a few percent, so any gap of that size from one run is not a result. When the spike ended, both returned to the low-millisecond latency of the 100-user level.
What this does not show
- Other sizes. One database machine with 2 vCPUs and 4 GB. A machine with more cores, or with a dataset far bigger than memory, can change the order.
- Other pool sizes. Everything here uses 100 connections. I also ran a round without a pool, but one result in it I could not explain, so it is not in these tables.
- Why MariaDB used more CPU for the same reads. Measured, not profiled.
- Tuning beyond equal settings. Both run with the same durability and isolation and a sensible memory split, not with settings tuned for this workload.
- The 20-million-order result. One run, and the spike shows that a single run can point the wrong way at a 5% distance.
Sources
- db-wars: the app, the k6 script and the setup scripts
- PostgreSQL 18: resource consumption settings (shared_buffers)
- PostgreSQL 18: write-ahead log settings (wal_level, synchronous_commit)
- MariaDB: InnoDB system variables (innodb_buffer_pool_size, innodb_flush_log_at_trx_commit)
- go-sql-driver/mysql: interpolateParams
- pgx: statement cache
- k6: ramping VUs executor
- k6: Prometheus remote write output