For a greenfield project with no existing constraints, pick Postgres. It has the richer type system, a genuinely useful built-in full-text search, and an extension ecosystem MySQL has no answer to. For an established MySQL deployment that works, stay: the migration costs more than the difference is worth.
That is the short version, and for most teams it is the whole answer. What follows is where the line actually falls, and the operational differences that decide the cases where it does not.
Where Postgres Clearly Wins
JSON/JSONB
Postgres JSONB is indexed, queryable, and has a rich operator set:
SELECT * FROM users WHERE preferences @> '{"theme": "dark"}';
CREATE INDEX idx_user_prefs ON users USING gin(preferences);
The catch with storing documents in a column is that they come back out as one unbroken line. Paste a row into the JSON Parser when you need to see the shape of what you actually stored.
Full-Text Search
Postgres ships a capable built-in FTS engine, enough to skip Elasticsearch for most apps:
SELECT * FROM articles
WHERE to_tsvector('english', title || ' ' || body)
@@ plainto_tsquery('english', 'server components');
Advanced Data Types
Arrays, ranges, and composite types mean fewer application-layer workarounds:
ALTER TABLE articles ADD COLUMN tags text[];
SELECT * FROM articles WHERE 'typescript' = ANY(tags);
Extension Ecosystem
pgvector for AI similarity search, PostGIS for geospatial, timescaledb for time-series. MySQL has no equivalent ecosystem.
Where MySQL Holds Its Ground
Replication maturity: 20 years of battle-tested primary-replica tooling (Percona, ProxySQL).
Managed cost: AWS Aurora MySQL and Cloud SQL MySQL are cheaper than Postgres equivalents at scale.
Read routing. ProxySQL and Vitess for sophisticated read/write splitting are more mature on MySQL.
Team expertise: if your team knows MySQL deeply, that knowledge is operationally valuable.
Where MySQL is genuinely faster
Worth stating plainly, because the Postgres side of this debate tends to skip it. MySQL's InnoDB clusters rows on the primary key, so a lookup by primary key reads the row data straight from the index, with no second read. Postgres always follows a heap pointer after the index scan.
For a workload dominated by primary-key point lookups, that difference is real. Postgres narrows it with index-only scans, but those require the visibility map to be current, which means the table has to be well vacuumed.
The operational difference nobody mentions until it bites
Postgres needs vacuuming, and MySQL does not. This is the single largest operational difference between them, and the one that surprises teams arriving from MySQL.
Postgres implements MVCC by writing a new row version on every update and leaving the old one behind as a dead tuple. autovacuum reclaims that space in the background. When it cannot keep up, usually on a very write-heavy table or one held open by long-running transactions, dead tuples accumulate, tables bloat, and queries slow down for no reason visible in the query plan.
-- Dead tuples and when autovacuum last got to each table
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
MySQL's InnoDB uses undo logs and purges them differently, so there is no equivalent chore. If you are moving to Postgres, put the query above on a dashboard before you need it.
Connection handling is the other one. Postgres allocates a process per connection, which is expensive, so a few hundred connections is enough to hurt. Anything with a serverless or per-request connection model needs a pooler such as PgBouncer in front of it. MySQL uses threads and tolerates high connection counts far better. Most "Postgres is slow at scale" stories are a missing pooler.
The Decision Guide
| Choose Postgres if... | Choose MySQL if... |
|---|---|
| Greenfield project with no constraints | Deep team MySQL expertise |
| JSON documents, geospatial, arrays in data model | Cost-sensitive AWS deployments |
| Want built-in FTS | Existing organisation MySQL standard |
| Need pgvector for AI features | Need Vitess for extreme horizontal sharding |
| On Supabase, Neon, or Railway | Joining an existing MySQL setup |
Either works fine for: standard CRUD APIs, relational data with foreign keys, most startups under 10M rows per table.
The differences that break application code
Syntax differences you notice in five minutes. These you notice in production.
| Behaviour | Postgres | MySQL |
|---|---|---|
| Identifier case | folds unquoted names to lowercase | case sensitivity depends on the filesystem |
| String comparison | case sensitive by default | case insensitive under the default collation |
'' and NULL | different values | different values, but many apps rely on loose coercion around them |
| Invalid dates | rejected | historically coerced, still surprising under some SQL modes |
Comparing '5' to 5 | error | silently coerced |
| Transactional DDL | yes, roll back a migration | no, DDL commits implicitly |
The last row is the one worth reading twice. In Postgres you can wrap a migration in BEGIN, have step four fail, and roll the whole thing back. In MySQL, every ALTER TABLE commits as it runs, so a migration that fails halfway leaves the schema in a state your migration tool did not plan for. Anyone who has had to hand-repair a half-applied MySQL migration at night will weigh this heavily.
The case-sensitivity row is the most common source of bugs after a migration. A MySQL app doing WHERE email = 'User@Example.com' matches a stored user@example.com. The same query in Postgres returns nothing, silently, and the login form just stops working for some users. Use citext or normalise on write.
My Default
For new projects with no specific constraints: Postgres. More expressive, unmatched extension ecosystem, and Supabase/Neon make it as easy to operate as any database has ever been.
But I'd never tell a team to migrate away from a well-functioning MySQL setup. The grass isn't that much greener.
If You're Actually Migrating
Moving an existing MySQL database to Postgres is a bigger project than the syntax differences suggest. The two biggest sources of pain aren't the schema, they're the parts of your application that quietly depend on database-specific behavior.
Auto-increment columns need to become SERIAL or GENERATED ALWAYS AS IDENTITY, and MySQL's implicit type coercion (comparing a string column to an integer without erroring) has no equivalent in Postgres, which will throw instead. If your codebase has queries that rely on that leniency, they'll fail loudly after migration, which is arguably a feature, but budget time to find them. Tools like pgloader handle the bulk data transfer and most type mapping automatically, but always run it against a staging copy first and diff row counts and checksums before pointing production traffic at the new database.
Tools in this post
Related Tool
JSON Parser & Formatter
Validate, format, and minify JSON data with error highlighting.
Try it freeWritten by
Jamith NimanthaSoftware developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.
Related Articles
All articles →One Week in the AI Money Machine: $25B in Bonds, a Record IPO, and a Moratorium
Amazon raised $25 billion in bonds, SK Hynix pulled off the largest foreign US listing ever, Meta committed to doubling compute, and New York hit pause. All in the same two weeks.
GPT-5.6: What Sol, Terra, and Luna Actually Mean for Developers
OpenAI shipped GPT-5.6 as a three-tier family: Sol, Terra, and Luna. Here's the pricing, the new caching rules, and which tier your workload actually needs.
Autonomous Coding Agents Redefining Software Development
Autonomous coding tools like Claude Code and specialized environments are shifting the developer role from writing code to orchestrating logic.