Postgres vs MySQL in 2026: The Real Score
Most teams pick Postgres because "it's better," but I've seen that decision cost a Series B startup an extra $15,000/month in compute just to run a simple user profile service. The irony is, MySQL could have handled that exact workload for a third of the price. The "default" choice isn't always the right one.
Why this matters in 2026
Cloud costs are under the microscope more than ever. Every dollar spent on compute, storage, and I/O counts. Data volumes are exploding, and specialized workloads, from vector embeddings to time-series analytics, are now common. The old argument of "Postgres for ACID, MySQL for web apps" is long dead. We're past simple CRUD; our databases are expected to do more, faster, and cheaper. Picking the right tool isn't about dogma; it's about minimizing your bill and keeping your engineers sane.
Three things I learned shipping this in production
MySQL's simplicity and replication just works for high-volume reads
I was part of a team building an e-commerce platform where product catalog data was our lifeblood. We had millions of products, and the read patterns were incredibly high, often hitting 10,000 reads per second during peak sales events. We started with Postgres, mostly out of habit, but quickly ran into issues with scaling reads efficiently without over-provisioning. Our initial RDS Postgres 14.7 instance, an db.r6g.xlarge, was struggling with spikes, costing us around $700/month, and read replicas were adding complexity to our application logic.
We made a pivot. We moved the product catalog to AWS RDS MySQL 8.0.28, specifically leveraging Aurora with multiple read replicas. Aurora's storage layer separates compute and storage, making read replica scaling incredibly fast and cost-effective. We configured three db.r6g.large Aurora MySQL instances as read replicas, distributing the load. Each replica was about $350/month. The primary instance, also a db.r6g.large, handled writes. Total cost, including storage, was around $1,500/month.
The key was the simplicity of MySQL's statement-based or row-based replication. For our read-heavy, eventually consistent product catalog, it was rock solid. We achieved consistent sub-5ms latency for product detail pages and search results. Trying to achieve the same with logical replication in Postgres (e.g., using wal2json or pglogical) for cross-region reads felt like a much heavier lift, requiring more operational babysitting and higher compute for the publisher. When we tried to run complex geospatial queries on this MySQL setup, though, performance tanked. MySQL's spatial index support is there, but it's not in the same league as PostGIS.
Here's an example of the simple query that benefited immensely from MySQL's replication architecture:
-- Product lookup on MySQL Aurora Read Replica
SELECT
product_id,
name,
description,
price,
inventory_count
FROM
products
WHERE
product_id = 'PROD-XYZ-789'
AND
is_active = TRUE;
This query, when hitting a read replica, would return in 2-3ms, even under heavy load. The simplicity of distributing these reads was MySQL's undeniable win in this scenario.
Postgres for true data versatility and complex operations
On a separate project, a SaaS platform for IoT sensor data analytics, Postgres was the undisputed champion. We were ingesting millions of sensor readings daily, each a small JSON payload, alongside device metadata and geospatial coordinates. We needed to perform real-time aggregations, run complex spatial joins, and allow users to query their data with flexible filters, often against the semi-structured JSON.
We chose Postgres 15.3, running on an AWS RDS db.r6g.2xlarge instance (about $1,400/month). The combination of jsonb for storing raw sensor data, PostGIS 3.3 for location-based queries, and the TimescaleDB 2.11 extension for time-series optimization was a game-changer. Postgres allowed us to keep all this diverse data in one place, avoiding the operational nightmare of sharding data across separate document stores, geospatial databases, and time-series databases.
For instance, we could store a sensor's readings, its location, and its manufacturer's specifications (as JSON) in a single table. Querying for all sensors within a 10km radius of a specific point that reported a temperature above 30 degrees Celsius in the last hour, and were manufactured by 'Acme Corp' (a field within the jsonb payload), was a single, performant SQL query.
MySQL's JSON support, while improved in recent versions, still feels clunky. Functions like JSON_EXTRACT or JSON_UNQUOTE are verbose compared to Postgres's native operators like -> and ->>. Its geospatial capabilities are rudimentary, and there's no equivalent to TimescaleDB for native time-series optimization. We saved months of engineering time and avoided significant infrastructure complexity by not having to move data between systems.
Here's an example of a complex query that shows Postgres's strength:
-- Postgres query for IoT sensor data
SELECT
s.sensor_id,
s.location,
s.readings ->> 'temperature' AS current_temp,
(s.metadata ->> 'manufacturer')::text AS manufacturer
FROM
sensors s
WHERE
ST_DWithin(s.location, ST_SetSRID(ST_MakePoint(-74.0060, 40.7128), 4326), 10000) -- within 10km of NYC
AND
(s.readings ->> 'temperature')::numeric > 30
AND
s.timestamp > NOW() - INTERVAL '1 hour'
AND
s.metadata ->> 'manufacturer' = 'Acme Corp';
This query, with proper GIST indexes on location and jsonb paths, would execute in hundreds of milliseconds, even on tables with tens of millions of rows. Without Postgres's advanced features, this would have been a multi-service, multi-database problem.
The hidden costs of MVCC and transaction overhead
One of the most painful lessons came from a real-time bidding platform. We started with Postgres 14.7 on AWS RDS db.r6g.4xlarge (around $2,800/month), believing its ACID guarantees were paramount. We had tables with extremely high update rates for bid changes, auction statuses, and user balances. Postgres's Multi-Version Concurrency Control (MVCC) model, while fantastic for read consistency without locking, creates old row versions that need to be cleaned up by VACUUM.
We quickly ran into performance bottlenecks caused by VACUUM operations and transaction ID wraparound issues. Our transaction throughput for high-frequency updates was suffering. Even with aggressive autovacuum tuning (e.g., setting autovacuum_vacuum_scale_factor = 0.05 and autovacuum_vacuum_cost_delay = 10ms), we saw spikes in disk I/O and CPU utilization every few hours, causing transaction latency to jump from 10ms to hundreds of milliseconds. A single long-running transaction could block VACUUM, leading to severe table bloat and dramatically slower queries for everyone. We spent weeks debugging and tuning.
We eventually migrated the hottest, most frequently updated tables (like bid_transactions and user_balances) to MySQL 8.0.30, using InnoDB's row-level locking. We used a smaller db.r6g.2xlarge instance, costing about $1,400/month. We saw an immediate 30% increase in write throughput for point updates and significantly lower, more consistent latency. MySQL's InnoDB engine handles row-level locking and undo logs differently, often leading to better performance for workloads dominated by frequent, small updates that don't contend heavily. The lack of VACUUM overhead meant more predictable performance.
Our failure was underestimating the operational burden of MVCC bloat in a high-write, high-churn environment. It's not that Postgres is bad, but its architecture has specific performance characteristics that need to be understood.
Here's a simplified UPDATE query that, when executed thousands of times per second, caused MVCC bloat issues in Postgres:
-- High-frequency update
UPDATE
user_balances
SET
balance = balance - 10.00,
last_updated = NOW()
WHERE
user_id = 'USER-ABC-123'
AND
balance >= 10.00;
In Postgres, each UPDATE creates a new row version. Over time, these old versions pile up and need VACUUM to clean them. In MySQL's InnoDB, an UPDATE generally modifies the row in place, marking the old version in the undo log, which is typically more efficient for these types of hot-row updates.
What I would do differently if I started today
I would default to MySQL for any greenfield project that primarily involves simple CRUD operations and high read/write throughput on structured data, especially if I anticipate heavy replication needs or straightforward sharding. Postgres is great, but its operational overhead for these basic workloads is often underestimated, particularly regarding MVCC and auto-vacuuming. I would only reach for Postgres when I know I need its advanced features, like jsonb for semi-structured data, PostGIS for complex geospatial queries, custom types, specific extensions, or truly complex transactional guarantees that push the limits of SQL. Starting with MySQL for simple workloads reduces initial infrastructure complexity and often leads to a lower operational burden and cloud bill
John from California
just requested a quote
2 minutes ago