---
title: "Postgres vs MySQL in 2026: Performance, Syntax and the Real Differences"
description: "Where Postgres and MySQL genuinely differ — storage engines, MVCC, connection models, SQL syntax and operations — plus where SQLite and MariaDB fit, and how to actually choose."
author: Aleksei Aleinikov
date: 2026-09-10
lang: en
tags: [postgres-vs-mysql, postgresql, mysql, database-comparison, sql-performance, mariadb]
canonical: https://www.alekseialeinikov.com/en/blog/topics/data/postgres-vs-mysql-2026
source: alekseialeinikov.com
---

# Postgres vs MySQL in 2026: Performance, Syntax and the Real Differences

Most Postgres versus MySQL comparisons are a feature checklist from 2015 with the dates changed. Both databases have added most of what the other used to have, so the checklist tells you almost nothing.

What still differs is deeper: how they store rows, how they handle concurrent versions of those rows, how they treat a connection, and what happens when a migration fails halfway. Those differences do not show up in a feature table, and they are the ones you live with.

![Postgres vs MySQL in 2026: storage, concurrency, connections and syntax compared.](https://www.alekseialeinikov.com/blog/postgres-vs-mysql-2026.webp)

## The 30-second answer

- **Complex queries, analytics, JSON, geospatial, extensions?** Postgres.
- **Huge numbers of simple connections, key-based lookups, read replicas?** MySQL.
- **Team already fluent in one of them?** That one. Seriously.
- **Starting fresh with no constraint?** Postgres, because the extension ecosystem gives you more escape hatches later.
- **Single process, no network, no ops?** Neither — that is SQLite.

Everything below is why.

## What actually differs under the hood

### Storage: clustered vs heap

This single choice explains most performance differences.

**MySQL (InnoDB) clusters the table on the primary key.** The row data physically lives inside the primary key index. Look up by primary key and you traverse one B-tree and you are holding the row.

The cost: **secondary indexes store the primary key, not a row location**. So a lookup via a secondary index traverses that index to find a primary key, then traverses the primary key index to find the row. Two traversals. It also means a wide primary key inflates every secondary index on the table.

**Postgres stores rows in a heap** — an unordered pile of pages — and every index, including the primary key, points into it. Every index lookup costs an extra fetch to reach the row.

The benefit: all indexes are equal, so a wide primary key does not tax the others, and Postgres can use **index-only scans** when its visibility map says a page is fully visible.

**What this means in practice:** MySQL is excellent at "give me the row with this ID". Postgres is more even-handed when your access patterns are varied.

### MVCC: where old row versions live

Both give you readers that never block writers. They pay for it differently.

**Postgres writes a new version of the row into the table itself** and leaves the old one behind until `VACUUM` reclaims it. Heavy-update tables therefore **bloat**, and autovacuum keeping up is a real operational concern rather than a theoretical one.

**MySQL writes old versions into undo logs**, so the table stays compact. The cost moves elsewhere: long-running transactions force the undo log to grow, and a reader on an old snapshot has to walk back through those versions.

Neither approach is free. Postgres makes you think about vacuum; MySQL makes you think about long transactions and undo growth.

### Connections: process vs thread

**Postgres forks a process per connection.** A process is not cheap. A few hundred mostly-idle connections consume real memory, and you will want **PgBouncer** or an equivalent pooler in front of any serious deployment. Serverless platforms that open a connection per request make this acute.

**MySQL uses a thread per connection**, which is dramatically cheaper. Thousands of connections is an ordinary configuration.

If your architecture opens many short-lived connections and you cannot pool, that fact alone may pick your database.

## Postgres vs MySQL performance

Nobody can honestly tell you which is faster, because they are fast at different shapes of work. Here is what each actually favours.

![Where Postgres and MySQL win: clustered vs heap storage and their different MVCC costs.](https://www.alekseialeinikov.com/blog/postgres-mysql-architecture-2026.webp)

**MySQL tends to win at:**

- Primary-key point lookups — one traversal, row in hand
- Very high connection counts
- Simple, high-volume OLTP where the query plan is obvious
- Read-scaling via replicas, with a long-mature binlog replication story

**Postgres tends to win at:**

- Complex joins, subqueries and aggregations — a stronger planner
- Analytical queries alongside transactional ones
- Anything with partial, expression or covering indexes
- Write patterns that benefit from `HOT` updates avoiding index churn
- JSON, arrays, geospatial and vector work, because of extensions

**A word on benchmarks.** Any number you read — including any I could publish — is one workload, one schema, one hardware profile and one configuration. Tuning moves results more than the engine choice does. If performance is genuinely your deciding factor, benchmark *your* queries on *your* data. Everything else is someone else's workload.

If you are optimising rather than choosing, the techniques in [SQL query optimisation](https://www.alekseialeinikov.com/en/blog/topics/data/sql-query-optimization-2026-faster-database-performance) apply to both engines.

## Postgres vs MySQL syntax differences

This is the section that matters when you are porting something. These are the ones that actually break.

| What | Postgres | MySQL |
| --- | --- | --- |
| String concatenation | `'a' \|\| 'b'` | `CONCAT('a','b')` |
| Quote an identifier | `"my table"` | `` `my table` `` |
| Auto-incrementing key | `GENERATED ALWAYS AS IDENTITY` | `AUTO_INCREMENT` |
| Upsert | `INSERT ... ON CONFLICT DO UPDATE` | `INSERT ... ON DUPLICATE KEY UPDATE` |
| Return the inserted row | `INSERT ... RETURNING *` | not supported |
| Boolean | real `BOOLEAN` type | alias for `TINYINT(1)` |
| String comparison | case-sensitive by default | case-insensitive in common collations |
| Limit with offset | `LIMIT 10 OFFSET 20` | `LIMIT 10 OFFSET 20` or `LIMIT 20, 10` |
| Current timestamp | `NOW()`, `CURRENT_TIMESTAMP` | `NOW()`, `CURRENT_TIMESTAMP` |
| Arrays | native array types | not supported |
| Regex match | `~`, `~*` | `REGEXP`, `RLIKE` |

Three of these cause most of the pain:

**`RETURNING`.** Postgres hands you back the row you just wrote in the same statement. MySQL does not support it, so you insert and then select, which is a second round trip and a race unless you are careful. MariaDB does support it.

```sql
-- Postgres: one statement
INSERT INTO users (email) VALUES ('a@b.com')
RETURNING id, created_at;
```

**Case sensitivity.** In Postgres, `WHERE email = 'Bob@x.com'` will not match a stored `bob@x.com`. In MySQL with a typical collation, it will. Ports in either direction silently change behaviour, and the bug surfaces in production as "login sometimes doesn't work".

**Unquoted identifier folding.** Postgres lowercases unquoted identifiers, so `CREATE TABLE MyTable` produces `mytable`. Once you quote something as `"MyTable"`, you must quote it forever. The usual advice — stick to `lower_snake_case` and never quote — is worth following in both.

## Operations: the difference nobody mentions

**Postgres has transactional DDL.** You can wrap schema changes in a transaction and roll them back:

```sql
BEGIN;
ALTER TABLE orders ADD COLUMN status text;
-- something fails here
ROLLBACK;   -- the column never existed
```

**MySQL commits implicitly on DDL.** A migration that fails on step four of six leaves you with three applied changes and no way back except a hand-written down migration.

If you deploy schema changes often, this is a bigger day-to-day quality-of-life difference than any query benchmark.

Two more operational notes:

- **Extensions.** Postgres lets you add PostGIS for geospatial, pgvector for embeddings, TimescaleDB for time series — inside the same database, with the same backups and the same transactions. MySQL has no comparable mechanism. If you might need [a vector database](https://www.alekseialeinikov.com/en/blog/topics/data/do-you-need-a-vector-database-postgres-vs-dedicated-2026), this matters.
- **Replication.** MySQL's binlog replication is old, well understood and has excellent tooling. Postgres has streaming physical replication and logical replication; both work well, and logical replication has improved substantially in recent releases.

## Where SQLite fits

SQLite is frequently put in this comparison, and the framing is usually wrong.

**SQLite is not a small server database. It is a library that runs inside your process.** There is no server, no port, no user accounts and no network round trip — a function call reads a file.

That makes it excellent for:

- Application-local state, caches and config
- Test suites, where a database per test costs microseconds
- Edge and embedded deployments
- Read-heavy workloads served from a single machine

And unsuitable the moment you need **several application servers writing concurrently**. SQLite serialises writers: one at a time.

The honest rule: if exactly one process ever touches the data, SQLite is likely the right answer and the simplest one. If two might, you want a server.

## MariaDB vs MySQL

MariaDB began as a MySQL fork after Oracle's acquisition. Years later, "drop-in replacement" is no longer accurate.

They have **diverged**: separate optimiser development, different storage engines, and features on each side the other lacks. MariaDB has `RETURNING`; MySQL does not. Version numbers no longer correspond at all.

Drivers and basic dumps are largely interchangeable. **Replication between them and any recent syntax are not.**

Choose **MariaDB** for community governance and its specific feature set. Choose **MySQL** if you depend on Oracle's ecosystem, a managed offering built on it, or its exact replication semantics.

## Which versions to run

As of September 2026:

| Database | Current | Notes |
| --- | --- | --- |
| PostgreSQL | **18** (18.6) | 19 in beta; **14 reaches EOL Nov 2026** |
| MySQL | **9.7 LTS** (9.7.3) | 8.4 LTS supported to 2029; **8.0 hit EOL Apr 2026** |
| MariaDB | **12.3 LTS** (12.3.3) | 11.8 and 11.4 LTS still supported |

Two things to act on: if you are on **MySQL 8.0**, it is out of support — plan the move to 8.4 or 9.7. If you are on **Postgres 14**, you have until November.

Postgres ships a major version yearly with five years of support. MySQL splits into quarterly Innovation releases and roughly biennial LTS releases; unless you need a specific new feature, run LTS.

## The decision table

| Your situation | Pick |
| --- | --- |
| Complex queries, reporting alongside OLTP | **Postgres** |
| Geospatial, vectors, time series in one database | **Postgres** — extensions |
| Frequent schema migrations | **Postgres** — transactional DDL |
| Thousands of connections, no pooler possible | **MySQL** |
| Key-value style access by primary key | **MySQL** — clustered index |
| Heavy read scaling via replicas | **MySQL** — mature binlog tooling |
| One process owns the data, no network | **SQLite** |
| Want community governance over Oracle | **MariaDB** or Postgres |
| Team already deeply fluent in one | **That one** |

## The bottom line

The feature gap that made this an interesting argument a decade ago has mostly closed. What is left is structural: **clustered versus heap storage**, **old row versions in the table versus in undo logs**, **a process versus a thread per connection**, and **whether a failed migration can roll back**.

Those four differences will shape your operational life far more than any feature checklist. Pick on those, and on which one your team can debug at 3am.

And if you find yourself deciding on a benchmark you did not run, you are choosing based on someone else's workload.
