Every few months a benchmark makes the rounds showing MySQL crushing PostgreSQL, or PostgreSQL crushing MySQL, and both camps share the version that flatters them. Having run both engines in production, my rule is simple: when two published benchmarks of the same two databases disagree by multiples, the benchmarks are measuring the benchmark setup, not the databases.
This is not database nihilism. Performance differences between MySQL 8.x and PostgreSQL are real, workload-dependent, and measurable. The problem is that most comparisons are decided before the first query runs, by configuration defaults, durability settings, tool choice, and test duration. This article catalogs the common ways these comparisons go wrong, then lays out how to run one that actually predicts what your production workload will do. I will not give you my numbers, because my numbers describe my workload. The method is the deliverable.
Default configuration bias decides most benchmarks
The most common methodology failure is benchmarking out-of-the-box configurations and presenting the result as an engine comparison. PostgreSQL ships with famously conservative defaults: shared_buffers of 128MB, a small work_mem, and a max_wal_size that forces frequent checkpoints under write load. These defaults exist so PostgreSQL starts everywhere, including tiny containers; they are not a performance posture. MySQL's defaults are also not production-tuned, with a modest innodb_buffer_pool_size, but the two sets of defaults are conservative in different places and to different degrees.
So an untuned face-off compares two arbitrary configurations. A fair test tunes both sides with the same seriousness: buffer pool and shared_buffers sized to the same fraction of memory with each engine's conventions respected, log and WAL sizing that does not force artificial checkpoint or flush storms, and parallelism settings that reflect the hardware. If you do not have a MySQL expert and a PostgreSQL expert tuning their respective sides, or at minimum each engine's documented best practices applied with equal care, you are not comparing engines, you are comparing your familiarity with them.
sysbench and pgbench are not the same yardstick
Tool choice quietly biases outcomes. sysbench grew up in the MySQL ecosystem, and its canonical OLTP scripts encode MySQL-shaped assumptions; it can drive PostgreSQL, but the historical center of gravity shows. pgbench is PostgreSQL's native tool and speaks only to PostgreSQL, implementing a TPC-B-like transaction that is its own narrow workload. Results from sysbench on one engine and pgbench on the other are not comparable at all, and even sysbench-on-both deserves skepticism about whether the schema, data types, and transaction shapes favor one side.
The deeper problem is that neither synthetic workload is your workload. A uniform-random point-update test says little about an application dominated by skewed reads, batch inserts, and a nightly analytics pass. If a synthetic harness is unavoidable, at least make it identical on both sides and shaped like your reality. Better: capture your actual query mix and replay it. On PostgreSQL, pg_stat_statements tells you what your workload really is:
SELECT left(query, 100) AS query_family,
calls,
round(total_exec_time::numeric / 1000, 1) AS total_s,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
The MySQL equivalent lives in performance_schema's statement digest table. Between the two, you can build a replay mix weighted by real call frequency, which is worth more than any canned scenario.
Durability parity: the silent thumb on the scale
This is the single most common way a benchmark lies, and it is usually unintentional. Both engines let you trade durability for speed, with different knobs. On MySQL, full durability means innodb_flush_log_at_trx_commit set to 1 and sync_binlog set to 1; relaxing either dramatically raises write throughput at the cost of potentially losing recent transactions on a crash. On PostgreSQL, the equivalents are synchronous_commit, which at the value off can lose a window of recent commits but never corrupts, plus the never-touch-it fsync and the wal_sync_method details underneath.
A benchmark that runs MySQL with relaxed flushing against PostgreSQL with synchronous_commit on, or the reverse, is comparing two different durability contracts, not two engines. Before any run, print the settings on both sides and put them in the report:
SELECT name, setting
FROM pg_settings
WHERE name IN ('synchronous_commit', 'fsync',
'wal_sync_method', 'full_page_writes',
'wal_compression', 'max_wal_size')
ORDER BY name;
On the MySQL side, record innodb_flush_log_at_trx_commit, sync_binlog, innodb_doublewrite, and whether the binlog is enabled at all, since a binlog-disabled MySQL is doing less work per commit than a PostgreSQL generating WAL for replication. Match the contracts: fully durable versus fully durable, or explicitly relaxed versus explicitly relaxed, and say which in the writeup.
Connection handling skews concurrency tests
MySQL uses a thread per connection; PostgreSQL forks a process per connection. Run a benchmark at 2,000 direct connections and you are largely measuring that architectural difference, and punishing PostgreSQL for being deployed the way nobody deploys it. Production PostgreSQL at high client counts sits behind a pooler such as PgBouncer, which is a standard part of the platform in practice. A fair concurrency test either includes the pooler on the PostgreSQL side or keeps client counts in the range both engines are designed to handle directly. The same fairness cuts the other way: if your application genuinely cannot add a pooling layer, then the direct-connection behavior is legitimately part of your comparison, and PostgreSQL should be measured with that constraint honestly on the table. Our comparison pages go deeper on where these architectural differences matter beyond benchmarks.
Run long enough to meet the background machinery
Short benchmarks flatter everyone. A ten-minute write test on PostgreSQL may complete before autovacuum engages with the garbage the test created and before a checkpoint cycle lands inside the measurement window; the steady-state behavior includes both. MySQL has its own deferred costs: purge lag building behind long transactions, change buffer merges, adaptive flushing finding its level. A fair test runs for hours at realistic load, on a dataset meaningfully larger than memory if that matches production, and reports the whole time series, not the best window. Watch p95 and p99 latency over time, not just mean throughput: a flat average with periodic latency spikes is exactly the signature that separates a well-tuned checkpoint and vacuum configuration from a bad one, and it is invisible in a single summary number.
A fair-test checklist for your own workload
Condensed into a protocol: use identical hardware or instance classes, identical filesystems, and production-scale data. Tune both engines with equal expertise and publish both configurations. Match durability contracts explicitly. Use the same load generator on both sides, shaped from your real query mix, with your real concurrency delivered the way production would deliver it, pooler included. Warm caches, then run for hours. Record latency percentiles over time, plus each engine's internal evidence: performance_schema digests on MySQL, pg_stat_statements and wait events on PostgreSQL. Then run the ugly cases that decide real outcomes: your biggest schema migration, a failover under load, a bulk delete followed by continued traffic. Finally, size the winner properly rather than assuming benchmark hardware translates; the PostgreSQL sizing guide covers that step for the PostgreSQL side. If a difference survives all of that, it is real and it is yours. Most published differences do not survive step two.
After the benchmark: MonPG on the PostgreSQL side
A fair benchmark and good production monitoring are the same discipline: evidence over vibes. If your testing lands you on PostgreSQL, the instrumentation you used during the bake-off, query statistics, wait events, vacuum behavior, latency percentiles over time, is exactly what you need permanently, and that is what MonPG's PostgreSQL monitoring provides: pg_stat_statements history, lock and wait evidence, autovacuum and bloat signals, and replication lag kept in one workflow, so the baseline you measured in the benchmark keeps getting measured in production, where the workload will drift away from it.
MonPG monitors PostgreSQL only, so it has no opinion on your MySQL numbers and cannot watch a MySQL fleet; if your fair test says stay on MySQL, that is a legitimate result and you should trust it. Either way, keep the methodology. The teams that benchmark honestly are the same teams that debug production quickly, because both habits come down to refusing to accept a number without knowing what produced it.