A field guide · for MySQL veterans

MySQL PostgreSQL

The differences that actually matter when you have spent a decade in MySQL and are learning your way around Postgres.

High-level · code-forward · 8 chapters · updated 2026
Reading from the perspective of
The short version

At a glance

Aspect MySQL PostgreSQL
{{ row.aspect }} {{ row.my }} {{ row.pg }}

Both are mature, free, ACID-compliant relational databases. The rest of this guide is the nuance.

MySQL and PostgreSQL query paths from client through parser, planner, executor, and storage
§ 01  /  Data types

Postgres treats the type system as a feature

MySQL gives you a solid, pragmatic set of scalar types plus JSON. Postgres covers the common scalar needs and then keeps going: native arrays, ranges, network and geometric types, UUIDs, domains, composite types, and the ability to define your own. For someone migrating, JSON and arrays open useful options—but neither replaces a relational model when the values have their own identity or relationships.

JSON, indexed both ways

Both store JSON. The difference is ergonomics: Postgres jsonb ships with containment operators and GIN indexing; in MySQL you index expressions, generated columns, or typed values from JSON arrays.

MySQLJSON type
-- validated text, binary internally
CREATE TABLE events (
  id      BIGINT AUTO_INCREMENT PRIMARY KEY,
  payload JSON NOT NULL
);

-- index a path via a generated column
ALTER TABLE events
  ADD kind VARCHAR(32)
    AS (payload->>'$.type') STORED,
  ADD INDEX (kind);

SELECT payload->>'$.user'
FROM   events
WHERE  kind = 'click';
PostgreSQLjsonb
-- parsed, binary, richly indexable
CREATE TABLE events (
  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  payload jsonb NOT NULL
);

-- one index covers every key
CREATE INDEX ON events
  USING gin (payload);

SELECT payload->>'user'
FROM   events
WHERE  payload @> '{"type":"click"}';

Arrays: native vs emulated

This is a genuine capability gap. Postgres has first-class array columns you can query with ANY. MySQL has no SQL array type: use a JSON array, including a multi-valued index when it fits, or normalize the values into a junction table. Arrays work best for small, bounded lists that belong to the row; use a related table when the elements need constraints, metadata, or independent updates.

MySQLno array type
CREATE TABLE posts (
  id   BIGINT AUTO_INCREMENT PRIMARY KEY,
  tags JSON
);

INSERT INTO posts (tags)
VALUES (JSON_ARRAY('sql','mysql'));

SELECT * FROM posts
WHERE  JSON_CONTAINS(tags, '"sql"');
PostgreSQLtext[]
CREATE TABLE posts (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tags text[]
);

INSERT INTO posts (tags)
VALUES (ARRAY['sql','postgres']);

SELECT * FROM posts
WHERE  'sql' = ANY(tags);
Visual example
Filtering by tag — same result, two roads
WHERE 'sql' = ANY(tags)  ·  JSON_CONTAINS(tags, '"sql"')
#{{ r.id }} {{ r.title }}
{{ r.chips }}
{{ r.badge }}
{{ dtCaption }}
§ 02  /  Transactions & MVCC

Ordinary row reads don't block row writes — on both

Here is reassuring news: both databases use MVCC (multi-version concurrency control), so ordinary row reads see a consistent snapshot rather than half-finished data. Locking reads, constraints, and schema changes can still wait. Postgres keeps old row versions in the table and cleans them up with VACUUM; MySQL/InnoDB keeps them in an undo log and purges them in the background.

Animated walkthrough  ·  repeatable-read snapshot  ·  {{ mvccStepLabel }}
{{ mvccTitle }}
Row versions · account #42
v1
{{ mvccV1Val }}
{{ mvccV1Tag }}
v2
{{ mvccV2Val }}
{{ mvccV2Tag }}
Clients
Client A · writer
{{ mvccA }}
Client B · reader
{{ mvccB }}

{{ mvccStepLabel }}. {{ mvccTitle }}. {{ mvccNarr }}

MySQL / InnoDB
{{ mvccMy }}
PostgreSQL
{{ mvccPg }}

The gotcha that will bite you: transactional DDL

This is the single biggest day-to-day surprise. In Postgres, most schema changes can be wrapped in a transaction and rolled back. MySQL DDL generally causes an implicit commit. PostgreSQL still has exceptions: for example, CREATE INDEX CONCURRENTLY cannot run inside a transaction block.

MySQLno rollback
BEGIN;
  CREATE TABLE staging LIKE orders;  -- commits!
  ALTER TABLE orders
    ADD shipped_at TIMESTAMP;         -- commits!
ROLLBACK;  -- too late, schema changed
PostgreSQLmost DDL rolls back
BEGIN;
  CREATE TABLE staging
    (LIKE orders INCLUDING ALL);
  ALTER TABLE orders
    ADD COLUMN shipped_at timestamptz;
ROLLBACK;  -- schema is untouched

One more default worth knowing: MySQL/InnoDB isolates transactions at REPEATABLE READ out of the box, while Postgres defaults to READ COMMITTED. Your queries can behave subtly differently under concurrency until you align them.

§ 03  /  Indexing

Same B-tree, very different toolbox

Everyday B-tree indexes work similarly. Postgres adds a wider set of index types (GIN, GiST, BRIN, Hash, SP-GiST) plus partial and expression indexes. CREATE INDEX CONCURRENTLY avoids blocking writes. MySQL 8.4 has functional, invisible, and JSON multi-valued indexes, but no partial indexes.

MySQL / InnoDB · clustered
With a PK, table = clustered index
PK 1 → row PK 2 → row PK 3 → row

With a primary key, rows live inside that clustered index; secondary indexes store the PK and may require a second hop. Without one, InnoDB chooses a suitable unique index or creates a hidden clustered index.

PostgreSQL · heap
Index → pointer → heap
index heap (unordered)

Rows live in an unordered heap; every index (including the PK) points into it. This enables several index methods, but every added index still costs storage and write maintenance.

MySQL
-- functional index (8.0.13+)
CREATE INDEX idx_email_lc
  ON users ((lower(email)));

-- no partial index: emulate it
ALTER TABLE users
  ADD active_email VARCHAR(255)
    AS (IF(status='active',email,NULL)) STORED,
  ADD INDEX (active_email);

-- keep an index but hide it from planner
ALTER TABLE orders
  ALTER INDEX idx_cust INVISIBLE;
PostgreSQL
-- partial index: only the rows you query
CREATE INDEX idx_active_email
  ON users (email)
  WHERE status = 'active';

-- expression index
CREATE INDEX ON users (lower(email));

-- covering, built without locking writes
CREATE INDEX CONCURRENTLY idx_cust
  ON orders (customer_id) INCLUDE (total);
§ 04  /  Writing queries

Small syntax differences, real workflow impact

The one you will reach for constantly: RETURNING. Postgres can return chosen columns and expressions directly from the statement. MySQL clients receive the generated ID in the insert response, but returning arbitrary inserted values usually needs another statement.

Visual example
Insert a row, get its id back
MySQL{{ myTrips }}
INSERT INTO orders (item, qty)
VALUES ('book', 2);

-- client reads insert_id from the OK packet
{{ myLog }}
PostgreSQL{{ pgTrips }}
INSERT INTO orders (item, qty)
VALUES ('book', 2)
RETURNING id, created_at;
{{ pgLog }}
{{ qCaption }}

Upsert: two spellings of the same idea

MySQL
INSERT INTO stock (sku, qty)
VALUES ('A1', 5) AS incoming
ON DUPLICATE KEY UPDATE
  qty = stock.qty + incoming.qty;
PostgreSQL
INSERT INTO stock (sku, qty)
VALUES ('A1', 5)
ON CONFLICT (sku)
DO UPDATE SET
  qty = stock.qty + EXCLUDED.qty;
§ 05  /  Extensibility

Where PostgreSQL diverges most

Postgres was designed to be extended. Once an extension's supporting files are installed—or your managed provider offers it—CREATE EXTENSION adds capabilities such as geospatial queries (PostGIS), vector search (pgvector), time-series tooling (TimescaleDB), and query statistics. MySQL extends the server mainly through pluggable storage engines and plugins: a different, more infrastructure-oriented model.

MySQL · storage engines
-- choose an engine per table
CREATE TABLE session_cache (
  k VARCHAR(64) PRIMARY KEY, v BLOB
) ENGINE = MEMORY;

CREATE TABLE orders (
  ...
) ENGINE = InnoDB;
PostgreSQL · extensions
CREATE EXTENSION IF NOT EXISTS postgis;   -- geo
CREATE EXTENSION IF NOT EXISTS vector;    -- ai
CREATE EXTENSION pg_stat_statements;

-- define your own type
CREATE TYPE money_range AS
  RANGE (subtype = numeric);
PostGIS pgvector TimescaleDB hstore pg_trgm InnoDB Memory Archive
Examples of PostgreSQL extensions and MySQL storage engines
§ 06  /  Performance

Neither is "faster" — they optimize for different shapes

On modern versions the gap for ordinary workloads is small. The differences show up at the edges: connection handling, complex query planning, and the maintenance each MVCC design demands.

MySQL considerations
  • ·InnoDB's clustered primary key rewards compact keys and access patterns built around them.
  • ·Secondary lookups may make a second hop through the primary key.
  • ·Thread-per-connection by default; reuse connections to avoid churn at scale.
  • ·Replication and high availability are mature, but topology and failover choices remain workload-specific.
PostgreSQL considerations
  • ·Several index methods, richer SQL semantics, and parallel plans give the optimizer more options.
  • ·Heap access and table statistics make plan quality sensitive to vacuum and analyze health.
  • ·Process-per-connection: pool with PgBouncer for many clients.
  • ·Watch for table bloat; tune autovacuum on write-heavy tables.

The practical shift is to benchmark your own query mix and learn each engine's maintenance signals. PostgreSQL gives you more ways to express and index demanding queries; whether they run faster depends on the schema, data distribution, statistics, configuration, and hardware.

§ 07  /  Migration traps

The syntax is the easy part

Most migration surprises come from defaults and data semantics, not from rewriting AUTO_INCREMENT. Audit these before moving production data.

Numbers
Unsigned integers

PostgreSQL has no unsigned integer types. Widen the column or add a range constraint, then verify foreign keys use matching types.

Text
Collation and case

Do not assume equality, sorting, or unique constraints behave identically. Test the actual collations, Unicode data, and case-insensitive lookups you rely on.

Time
Timestamps and zones

Map each temporal column deliberately. In PostgreSQL, timestamptz represents an instant; it does not retain the original zone label.

Data quality
SQL modes and invalid values

Inventory zero dates, truncated strings, coercions, and permissive-mode artifacts before export. PostgreSQL may reject rows that MySQL previously accepted or normalized.

Names
Identifiers and quoting

Unquoted PostgreSQL identifiers fold to lowercase. Mixed-case or reserved names require double quotes everywhere, so normalize names when you can.

Operations
Sequences, pools, and vacuum

Reset sequence values after bulk loads, size the connection pool for PostgreSQL, and monitor autovacuum rather than treating it as optional cleanup.

§ 08  /  When to use which

Choose for the constraints you actually have

Both can serve a wide range of applications well. The wrong choice is the one that ignores required features, team expertise, provider support, migration risk, or the workload you measured.

Reach for MySQL when
  • ·Your existing schema, tooling, and operating model already fit it well.
  • ·Your team and tooling already know it well.
  • ·Your provider and application stack offer a well-understood MySQL path.
Reach for PostgreSQL when
  • ·Your data model wants JSON, arrays, ranges, or custom types.
  • ·You write complex analytical queries alongside transactions.
  • ·You need extensions such as PostGIS or pgvector, or richer constraint and index semantics.
A note for the migrator

A decade of MySQL experience transfers well, but the migration is more than syntax. Most DDL can now roll back, AUTO_INCREMENT becomes GENERATED … AS IDENTITY, and VACUUM becomes part of normal operations. The richer type, index, and extension systems are useful when they solve a concrete problem—and each brings choices worth testing.