· performance  · 21 min read

Postgres vs MySQL in 2026: Which to Pick for a SaaS (and When to Switch)

Both are excellent databases and for most SaaS workloads either one is fine. The honest answer: pick Postgres for greenfield, keep what you run well, and switch only for a reason you can name. Here are the few differences that actually decide it.

Both are excellent databases and for most SaaS workloads either one is fine. The honest answer: pick Postgres for greenfield, keep what you run well, and switch only for a reason you can name. Here are the few differences that actually decide it.

TL;DR - Postgres and MySQL are both excellent, and for the large majority of SaaS workloads either one will carry you to real scale. The question is rarely the bottleneck people think it is.

  • Greenfield, no strong reason either way: pick Postgres. Richer types, extensions, stricter correctness defaults, and it has become the default the ecosystem builds for.
  • Already running one well: keep it. A migration costs months and buys you little unless you can name the specific feature you are missing.
  • The differences that actually decide it are a short list: JSON and extensions (Postgres jsonb plus GIN indexes is the standout), the clustered-index storage model, SQL strictness, transactional DDL (Postgres rolls back a failed migration cleanly), and that Postgres can absorb your search index and job queue so you run fewer systems. Both are fully ACID. Most other differences will never touch your application.

The honest answer first

Most “Postgres vs MySQL” arguments are louder than the decision deserves. Both are mature, fast, battle-tested relational databases that run enormous systems in production. For a typical vertical SaaS, reads and writes of orders, users, jobs, invoices, the database engine is almost never what limits you first. Your schema, your indexes, and your query patterns decide your performance long before the choice of engine does. I have spent far more time fixing missing and wrong indexes than I have ever spent wishing a client had picked the other database.

So before the feature table, the meta-answer:

  • If you are starting fresh and have no strong reason, pick Postgres. It is the safe default in 2026, and “what most of the ecosystem assumes” is itself a real advantage.
  • If you already run MySQL well, with people who know it and backups that work, stay. Switching is a large project with little upside unless you have a concrete missing feature.

Everything below is for the cases where the choice does matter, and for understanding what you are actually trading.

The comparison at a glance

This table is also the map of the post: every dimension links to the section that explains it. The “Edge” column is my honest call, not a popularity score, and it lands Postgres ahead on the things that compound over a product’s life while giving MySQL the rows it genuinely wins.

Dimension PostgreSQL MySQL Edge
JSON jsonb with GIN, fully indexed JSON type, no direct index PostgreSQL
Types & extensions arrays, ranges, PostGIS, pgvector limited, few extensions PostgreSQL
Data integrity strict by default, no silent loss depends on sql_mode PostgreSQL
ACID & DDL ACID, transactional DDL ACID, DDL auto-commits PostgreSQL
Storage model heap + equal secondary indexes InnoDB clustered primary key Depends
Replication & upgrades WAL + logical, cross-version upgrades mature binlog, turnkey InnoDB Cluster HA PostgreSQL
Dead-row cleanup VACUUM, can bloat undo log + purge thread MySQL
Connections process each, usually needs PgBouncer light threads, no pooler MySQL
Default isolation READ COMMITTED REPEATABLE READ + gap locks Tie
Built-in queue SKIP LOCKED, LISTEN/NOTIFY, pgmq SKIP LOCKED (8.0+) PostgreSQL
Horizontal sharding Citus, more fragmented Vitess, battle-tested at scale MySQL
Ownership community, permissive license Oracle, dual-licensed PostgreSQL

Where they actually differ (the short list that matters)

Data types and extensions

This is the clearest Postgres advantage, and for a lot of SaaS apps it is the one that matters. Postgres ships rich types and an extension system MySQL has no real equivalent for:

  • Arrays, ranges, and composite types as first-class columns.
  • Extensions: PostGIS for geospatial (the standard for anything map or location based), pg_trgm for fuzzy and substring search, pgvector for embeddings, and many more. These install into the database and behave like native features.

If your product has a geospatial component, heavy semi-structured data, or search beyond exact match, Postgres saves you from bolting on a second system. That is often the whole decision.

JSON and JSONB

Both databases store JSON, but the experience is not equal, and this trips people up because the words look the same.

Postgres has two JSON types. json stores the exact text you put in and reparses it on every read, so it is the wrong default. jsonb stores a decomposed binary form: slightly more work to write, much faster to query, and the one you want almost always. The reason jsonb matters is indexing. A GIN index over a jsonb column makes containment and key-existence queries (does this document contain this key or value) fast without you pulling the whole document into the application. You can also index a single extracted path with an expression index when you only ever filter on one field.

CREATE INDEX ON events USING gin (payload jsonb_path_ops);
SELECT * FROM events WHERE payload @> '{"type": "signup"}';

MySQL has a JSON type too, stored in a binary form, and it is fine for holding and reading back a document. Where it falls behind is indexing: you cannot put an index directly on a JSON column. The workaround is to extract the value into a generated column and index that, or use a multi-valued index over a JSON array (8.0.17 and up). It works, but it is more setup and less flexible than jsonb plus a GIN index. If your app leans on semi-structured data and queries into it, this difference alone often decides the choice.

The honest caveat: if you are querying inside JSON constantly, that is frequently a sign the data wants real columns. JSON is for the genuinely variable shape, not an excuse to skip schema design on either engine.

The storage model: clustered index vs heap

MySQL’s InnoDB stores the table physically ordered by the primary key (a clustered index). Postgres stores rows in a heap and every index, including the primary key, points into it. This sounds academic and has real consequences:

  • On InnoDB, primary-key lookups and range scans on the primary key are very fast, because the row data lives in the index. A poorly chosen primary key (a random UUID) hurts more on MySQL, because it scatters the physical insert order. Sequential keys matter on both, but the penalty is sharper on InnoDB.
  • On Postgres, secondary-index lookups all cost roughly the same (index to heap), and there is no “the primary key is special for storage” effect. Covering indexes with INCLUDE are how you get the index-only wins.

Neither is better in the abstract. It changes which key designs and access patterns are cheap, and it is worth knowing which model you are on when you design your hottest tables.

Data integrity and SQL strictness

This is the difference I care about most, because the failure mode is silent and silent data corruption is the worst kind. The database is the last line of defense for your data. If it quietly accepts something wrong, you do not find out until the data is already bad and the backups already wrote it.

Postgres is strict by default and rejects lossy operations outright. MySQL in non-strict mode does not, and I have watched both of these happen in production:

  • Insert 12 characters into a VARCHAR(10). Non-strict MySQL stores the first 10 and silently drops the last 2, with a warning the application never reads. Someone’s name, token, or reference number is now wrong, and nothing failed.
  • Insert a value past a column’s integer maximum. Non-strict MySQL clamps it to the maximum the column can hold and stores that. The number you read back is not the number you wrote, and again nothing errored.

Postgres rejects both. value too long for type character varying(10) and integer out of range are errors, the insert fails, and your application finds out immediately, which is exactly when you want to find out. No configuration required.

Modern MySQL closes these gaps: strict mode (STRICT_TRANS_TABLES) has been in the default sql_mode since 5.7, and with it on, both cases raise errors like Postgres does. But the protection is one configuration line from being switched off, and plenty of inherited and legacy systems still run with it off, often without anyone realizing. Postgres never loads that foot-gun in the first place. For a team without a dedicated DBA, a database that protects your data without you having to configure it to is worth a great deal.

ACID and transactional DDL

First, a myth to kill: both are fully ACID. Postgres is ACID-compliant to its core, and so is MySQL when you use InnoDB, which has been the default storage engine since 5.5. The old “MySQL is not ACID” line comes from MyISAM, the non-transactional engine MySQL used to default to and that nobody should use for application data today. On modern MySQL with InnoDB, your transactions are atomic, consistent, isolated, and durable, same as Postgres.

The real difference is transactional DDL, and it matters most when you run migrations. In Postgres, schema changes (CREATE, ALTER, DROP) are transactional: you can wrap several of them in one transaction and, if anything fails, the whole thing rolls back and the schema is exactly as it was. Your migration either fully applies or fully does not.

MySQL 8 added atomic DDL, which is a smaller guarantee. Each individual DDL statement either completes or cleans itself up after a crash, so you do not get half-created tables. But DDL still implicitly commits the current transaction, and you cannot group several schema changes and roll them back together. A migration that runs five ALTERs and fails on the fourth leaves the first three applied. On Postgres that same migration rolls back to a clean state.

For a team shipping schema changes often, that is a real operational comfort: a failed Postgres migration is a non-event, while a failed MySQL migration can leave you half-migrated and patching by hand. It is one of the most underrated reasons Postgres is pleasant to operate.

Replication: Postgres has quietly pulled ahead

Both replicate well and both scale reads with replicas, so for a long time this was scored as a draw, or even a MySQL win on maturity. In 2026, for what a SaaS actually needs, Postgres has the better story.

Start with the foundation. Postgres replication is built on the write-ahead log, the same mechanism that guarantees durability. One well-understood log does both jobs, which is why physical streaming replication is simple, fast, and famously reliable, and why point-in-time recovery from archived WAL is excellent. You are not bolting replication onto the side of the engine; it falls out of how the engine already works.

Then the part that compounds: logical replication, solid since version 10 and materially better every release since. Because it replicates row changes rather than physical blocks, it can do things physical replication and MySQL’s traditional binlog replication cannot do cleanly:

  • Selective replication of specific tables to specific subscribers.
  • Cross-version replication, which is how you run a near-zero-downtime major version upgrade. Postgres 17 added pg_createsubscriber, which turns an existing physical standby into a logical replica without re-copying the whole dataset, and failover slots, which keep logical replication alive when the primary fails over instead of breaking it. Those two features close the last gaps that used to make people reach for MySQL here.

This matters because over the life of a product, the replication event that actually hurts is not steady-state read scaling, which both do fine. It is the major-version upgrade you keep postponing because it means downtime. Postgres has turned that into a routine, low-drama operation, and that is worth more than a maturity badge.

The honest counterpoint, so this is a verdict and not a cheer: MySQL’s InnoDB Cluster bundles Group Replication, MySQL Router, and MySQL Shell into integrated, consensus-based high availability with automatic failover out of the box. In vanilla Postgres you assemble equivalent automatic failover from mature but separate tools (Patroni, repmgr, and friends). If “turnkey clustered HA with one vendor’s tooling” is your priority, MySQL still hands you more in the box.

Net: Postgres wins on the axis that compounds over a product’s life, upgrades and replication flexibility, and MySQL keeps the edge on out-of-the-box HA packaging. For most SaaS teams, the upgrade story is the one they feel every year, so Postgres takes this one on merit.

Reclaiming dead rows: vacuum vs the undo log

The two clean up old row versions differently, and this is the Postgres operational gotcha to know going in. Postgres keeps multiple row versions in the table itself (MVCC) and marks updated or deleted rows as dead, then relies on VACUUM to reclaim them. Reads are fast because no version needs reconstructing, but on a write-heavy or update-heavy table autovacuum has to keep up or tables bloat and slow down. MySQL’s InnoDB keeps the current row in place and older versions in a separate undo log, cleaned by a background purge thread, so bloat is less of a surprise, at the cost of reconstructing old versions for long-running reads. The Postgres bloat-and-vacuum story is the single most common operational surprise I see on Postgres at scale, and it is manageable once you know to tune autovacuum for your write rate rather than leaving it on defaults.

The connection model, and why Postgres usually needs PgBouncer

Here MySQL has a real edge. MySQL handles each connection with a thread inside one process, which is light, so it scales to thousands of connections without help. Postgres gives every connection its own operating-system process, each costing several megabytes of memory and some scheduling overhead. That model is robust, but it means Postgres has a much lower practical ceiling on raw connections, and a healthy back-end pool is roughly three to five times your CPU core count, not the thousands an app server fleet can open.

The fix is a connection pooler, almost always PgBouncer, sitting in front of Postgres and multiplexing many client connections onto a small set of real ones. It works well and is standard practice, but it is one more component to run, and it is the thing teams forget until Postgres starts refusing connections under load. If your deployment opens a lot of short-lived connections, serverless functions are the classic case, MySQL’s model is genuinely less fuss, and on Postgres you should plan for PgBouncer from the start.

Default isolation level

A subtle one that surprises people moving between the two. Postgres defaults to READ COMMITTED; MySQL’s InnoDB defaults to REPEATABLE READ. They behave differently under concurrency, and MySQL’s REPEATABLE READ uses gap and next-key locks that lock ranges of the index to prevent phantom inserts. Those gap locks can produce deadlocks and blocking that a Postgres user has never seen, and the reverse move can expose code that silently relied on the stronger default. It rarely decides the choice, but if you port an application from one to the other, test your concurrent paths rather than assuming the transaction semantics carry over.

Where it does not matter (so do not agonize)

For the bulk of what a SaaS does, the two are interchangeable:

  • Basic CRUD, joins, transactions, foreign keys: both do these correctly and fast.
  • ORMs (Hibernate, ActiveRecord, Django ORM, Prisma) support both first-class, and your application code barely changes.
  • Raw single-query speed on a well-indexed table is close enough that your schema and indexing dwarf the difference.

If your debate is about which one is “faster,” you are almost certainly optimizing the wrong layer. Fix the slow queries first and the engine choice stops mattering.

A queue you do not have to run separately

One of the best reasons to like Postgres for a small-to-mid SaaS is that it absorbs jobs you might otherwise stand up a whole system for. The clearest example is a background job queue. Plenty of teams reach for Kafka, RabbitMQ, or SQS on day one and now operate a second piece of infrastructure for what a single table can do.

The mechanism is SELECT ... FOR UPDATE SKIP LOCKED. A worker grabs the next available job, locks that row, and SKIP LOCKED makes other workers step over the locked row instead of blocking on it. So many workers drain one jobs table concurrently with no contention:

SELECT id, payload FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1;

You mark the row done (or failed, with a retry count) in the same transaction. That is a durable, transactional queue with no extra moving part, and because the job lives in the same database as your data, enqueuing a job and the write that triggered it commit together or not at all. That exactly-once-with-your-data property is hard to get when the queue is a separate system.

Two honest caveats. First, this is not Postgres-exclusive anymore: MySQL 8.0 added SKIP LOCKED too, so the same pattern works there. Postgres just has the richer surrounding ecosystem, including LISTEN/NOTIFY to wake idle workers (use it only to wake them, not as the queue itself, since notifications are lost if a listener is disconnected) and the pgmq extension if you want an SQS-like API. Second, a database-backed queue is the right call at moderate volume, thousands to low millions of jobs a day, not at the scale where a purpose-built broker earns its operational cost. Most SaaS never reaches that scale, and starts simpler than it thinks it needs to.

The broader point: Postgres with its extensions often replaces the search index, the geospatial store, and the job queue you were about to add. Fewer systems is fewer things to operate, and for a small team that is worth real money.

When MySQL is the right call

  • You already run it, your team knows it, and your backups and replication work. Inertia is a legitimate engineering reason here.
  • Your workload is simple, read-heavy, key-based access with a clean sequential primary key. InnoDB’s clustered index is genuinely good at this.
  • You open many connections, especially short-lived ones from serverless functions, and want to avoid running a pooler.
  • You expect to need real horizontal sharding. MySQL has the more battle-tested story here: Vitess (a CNCF graduated project, the technology that scaled YouTube, also run by Slack and GitHub) and the branching workflow PlanetScale builds on it. Postgres answers with Citus, but the tooling is more fragmented. Most SaaS never reaches the scale where this matters, and Vitess takes real expertise to run, so treat it as a tiebreaker only if sharding is genuinely on your horizon.
  • Your hosting or platform constraints favor it, or a managed product you depend on speaks MySQL natively.

When Postgres is the right call

  • Greenfield with no strong reason to do otherwise.
  • You need jsonb, arrays, geospatial, full-text or fuzzy search, or vectors. The extension ecosystem is the killer feature.
  • You value strict-by-default correctness and want fewer configuration foot-guns.
  • You expect complex queries: window functions, CTEs, partial and expression indexes. Postgres has long been ahead on analytical SQL, and the gap, while narrower than it was, still favors it for query richness.

Ownership and licensing: who you are betting on

A point founders care about more than engineers do, and they are right to. Postgres has no single owner. It is developed by a distributed global group under a permissive BSD-style license, with major contributions from AWS, Microsoft, Google, Crunchy Data, EDB, and many individuals. The community phrase is “no single throat to choke,” and the flip side is that no single company can take it away from you.

The contributor base is the concrete version of that argument. PostgreSQL 17 had 463 people contribute as patch authors, committers, reviewers, testers, or reporters, and the previous release drew 416 people from 109 companies. Behind them sit a core team of 7, around 30 committers, and roughly 50 major contributors, spread across AWS, Google, Microsoft, Fujitsu, NTT, EDB, and Crunchy Data, with no single employer dominating the list. That is a low bus factor: any one company walking away does not stall the project.

Here is the trap to avoid, because it is the most common bad comparison and it gets the answer backwards. Do not read contributor strength off the GitHub contributor graph. Postgres does not develop on GitHub at all. Work happens on the public pgsql-hackers mailing list and the commitfest review cycle, and the GitHub repository is only a mirror. A Postgres committer applies a patch and credits the real author inside the commit message itself, in the Author: and Reviewed-by: lines, and only about thirty people hold commit rights. So GitHub, which counts the git author, sees only the committer. The numbers make the point: GitHub lists roughly 42 contributors for postgres/postgres, while the actual PostgreSQL 17 release credited 463 people, and the prior one 416 from 109 companies. To be fair, the same proxy fails the other way too. mysql/mysql-server shows around 114 GitHub authors, which looks like more than Postgres, yet MySQL is built largely inside Oracle and its outside contributors are credited in the release notes rather than as git authors. So the git-author count is a poor proxy for either project. It measures who holds commit access, not who builds the database. The signal that holds up is the one each project publishes: Postgres names hundreds of contributors across more than a hundred companies under distributed governance, while MySQL’s roadmap and the bulk of its code live inside one vendor.

MySQL is owned by Oracle, dual-licensed GPL for open-source use and commercial for proprietary use. The day-to-day experience is fine and the Community Edition is real open source. The concern is strategic, not immediate: Oracle has put some features in the paid Enterprise edition that are not in Community, and Oracle’s commercial priorities and MySQL’s open roadmap can drift apart over time. The community response to exactly this worry was MariaDB, the fork Monty Widenius started in 2009 to keep an openly governed version alive.

If long-term vendor risk and governance matter to you, and for anything you are building a business on they should, Postgres is the lower-risk bet. It is the same reason I tell teams to watch where else they are quietly handing a single vendor control of something load-bearing.

Postgres and MySQL: pros and cons at a glance

PostgreSQL

  • Pros: richest types and jsonb with GIN indexing; huge extension ecosystem (PostGIS, pg_trgm, pgvector); strict by default, so no silent data loss; transactional DDL for clean migration rollbacks; far more index types (partial, expression, covering, GIN, GiST, BRIN); strong complex-query support; WAL-based replication plus logical replication for near-zero-downtime cross-version upgrades; permissive license, community-governed.
  • Cons: process-per-connection, so it usually needs PgBouncer; VACUUM and bloat need attention on write-heavy tables; horizontal sharding tooling is more fragmented than MySQL’s.

MySQL

  • Pros: lighter thread-per-connection model that scales connections without a pooler; InnoDB’s clustered index is excellent for key-based read-heavy access; the most battle-tested horizontal sharding story (Vitess, PlanetScale); integrated out-of-the-box HA (InnoDB Cluster); huge hosting reach and operational familiarity; background purge means less bloat surprise.
  • Cons: weaker JSON indexing (needs generated columns); historically looser defaults, so strictness depends on sql_mode being right; DDL is not transactional, so a failed migration can leave you half-applied; smaller built-in type and extension story; Oracle ownership and a community split across MySQL, MariaDB, and Percona.

Both are fully ACID, both are fast, both run large systems. The cons above are real but rarely fatal, and for most teams the decision still comes down to the first two checklist items below.

The switching question: usually do not

The most common version of this question is not “which do I pick” but “should we move from one to the other.” For almost everyone the answer is no.

A database migration is a multi-month project with real risk: schema and type differences, SQL dialect differences, replication cutover, a dual-write or downtime window, and a long tail of application bugs from subtle behavior changes. The payoff has to be a feature you cannot live without or a cost you cannot otherwise control. “Postgres is nicer” is not that payoff when MySQL is serving your traffic fine.

The cases where switching is justified are specific: you need PostGIS or pgvector and bolting on a second datastore is worse than migrating; or you are consolidating several services onto one engine and the math works. If you cannot write the reason in one sentence, do not start.

What this looks like in practice

A team I worked with ran a perfectly healthy MySQL setup and was convinced their scaling problem meant they had “chosen the wrong database.” They were pricing a migration to Postgres as the fix. The actual problem was a handful of unindexed foreign keys and one report query doing a full scan on every dashboard load. We read the plans, added the indexes, and the dashboards went from seconds to milliseconds. The database engine was never the issue. They are still on MySQL, and they are glad they did not spend a quarter migrating away from a problem that was three indexes deep.

That is the pattern. The engine choice gets blamed for problems that live in the schema and the queries. Picking well at the start saves you a little; operating either one well saves you far more.

Decision checklist

  1. Already running one well? Keep it. Stop reading.
  2. Greenfield, no strong reason? Postgres.
  3. Need geospatial, vectors, rich JSON, or fuzzy search? Postgres, for the extensions.
  4. Simple, read-heavy, key-based, and your team knows MySQL? MySQL is fine.
  5. Thinking about switching? Write the one-sentence reason first. No sentence, no migration.

Summary

Postgres and MySQL are both excellent and both fully ACID, and for most SaaS workloads the choice is far less load-bearing than the internet makes it sound. Pick Postgres for greenfield, mostly for jsonb and the extension ecosystem, its strict-by-default correctness, transactional DDL that makes migrations safe to fail, and its knack for absorbing the queue and search systems you would otherwise run alongside it. Keep MySQL if you run it well, especially for simple read-heavy access where InnoDB’s clustered index shines. The real differences come down to JSON and extensions, the storage model, SQL strictness, transactional DDL, and the operational texture of replication and reclaiming dead rows. And whichever you run, your schema and your indexes will decide your performance long before the engine does.


Slow database and not sure if it is the engine or the schema? It is almost always the schema. I find out which as part of Performance Engineering. Book a free 30-minute call and we will read the query plans together.

Back to Blog

Related Posts

View All Posts »

Spring Boot on the JVM vs GraalVM Native: What Actually Wins on AWS

A head-to-head benchmark of the same Spring Boot app built for the JVM and as a GraalVM native binary - on real AWS hardware with a real database, run multiple times. Native wins startup, memory, and predictability; the warm JVM wins the median, peak throughput, and often the tail too - but the JVM swings run-to-run while native stays flat.

How to Reduce AWS RDS Costs Without Hurting Performance

RDS is often the second or third biggest line on an AWS bill, sometimes the first, and most of it is avoidable. The levers that move it: fix the queries before you upsize, match the instance to your load shape, and stop provisioning storage for data that has not arrived yet.