The short version
- Same ideas, different words. Both are mature ACID engines with MVCC, transactions, views and JSON.
- Schema means two things. In MySQL it is a database. In PostgreSQL it is a namespace inside one.
- Defaults bite hardest. Collation, identifier case, NULL order and implicit conversion differ silently.
- PostgreSQL can roll back DDL. On MySQL every CREATE and ALTER commits at once.
- VACUUM is the new chore. Autovacuum needs watching; InnoDB purges old row versions by itself.
- Pool your connections. PostgreSQL forks a process per client, so shops put PgBouncer in front.
Two good engines, two dialects
Both engines are excellent, free, ACID and MVCC based, so readers do not block writers. Both give you foreign keys, triggers, stored procedures, views, CTEs, window functions, JSON and replication, so almost everything from chapter 1 to chapter 13 applies to both. This is not a "which is better" chapter: companies run both happily for decades. The differences live in four places, and this chapter is one table for each: vocabulary, defaults, capabilities and operations.
MySQL, Sept 2026
8.4.11 LTS · 9.7.2 LTS · 26.7 Innovation
PostgreSQL, Sept 2026
18.6 current · 17.11 · 19 in beta
Port and Windows service
3306, MySQL84 / MySQL97 · 5432, postgresql-x64-18
Support window
Eight years per MySQL LTS · five years per PostgreSQL major
License
GPL Community plus paid Oracle Enterprise · permissive PostgreSQL License
Origin
1995, Sweden, now Oracle · 1986, Berkeley, community run
Remember
The engines agree on the big ideas and disagree on words, defaults and add-ons. When something "does not work" on the other engine, it is almost always a translation problem.
The translation table
This is the most useful thing in the chapter. Read the left column as what you say at work, and the middle as what a PostgreSQL colleague means. Bookmark it, and when you write a request for the other team, use their words.
| MySQL says | PostgreSQL says | What it means for you |
|---|---|---|
database (same thing as schema) | database, which contains schemas | In MySQL the two words are synonyms and USE shop switches. In PostgreSQL a database holds schemas (public by default), a connection is bound to one database, and crossing databases needs an extension such as postgres_fdw. |
user as 'app'@'10.0.0.%' | role with the LOGIN attribute | MySQL accounts include a host part. PostgreSQL roles do not; who may connect from where lives in pg_hba.conf. See chapter 14. |
AUTO_INCREMENT | GENERATED ALWAYS AS IDENTITY (older scripts: SERIAL) | Same job, backed by a sequence object you can inspect and reset. |
| storage engine (InnoDB, MyISAM, MEMORY) | none | MySQL chooses an engine per table. PostgreSQL has one way to store a table. |
SHOW TABLES, SHOW CREATE TABLE, DESCRIBE | \dt, \d orders, \l, \du in psql | Both have information_schema; PostgreSQL adds pg_catalog underneath. Backslash commands work only in psql. |
`backticks` | "double quotes" | Both quote identifiers. MySQL reads double quotes as string quotes unless sql_mode includes ANSI_QUOTES. |
LIMIT 10 OFFSET 20 | the same, or FETCH FIRST 10 ROWS ONLY | PostgreSQL accepts both spellings. MySQL takes only LIMIT. |
mysqldump, util.dumpInstance() | pg_dump, pg_dumpall, pg_restore | Logical backups. See chapter 15. |
my.ini plus mysqld-auto.cnf (SET PERSIST) | postgresql.conf plus postgresql.auto.conf (ALTER SYSTEM), and pg_hba.conf | PostgreSQL keeps "who may connect" in its own file. |
| binary log (binlog) | write-ahead log (WAL) | Both feed replication and point-in-time recovery. The binlog carries row changes, the WAL carries physical page changes. |
| source and replica | primary and standby | Same idea. Old articles say master and slave; MySQL 8.4 removed that syntax. |
KILL QUERY id, KILL CONNECTION id | pg_cancel_backend(pid), pg_terminate_backend(pid) | Two levels in both: stop the statement, or drop the session. |
SHOW PROCESSLIST, Performance Schema, sys schema | pg_stat_activity, the pg_stat_* views, pg_stat_statements | Monitoring. See chapter 16. |
innodb_buffer_pool_size | shared_buffers | The main memory cache. Both default to 128 MB, far too small for a real server. |
OPTIMIZE TABLE | VACUUM, VACUUM FULL | Reclaim space. In PostgreSQL plain VACUUM is a routine chore. |
| Event Scheduler (built in) | pg_cron extension | Scheduled jobs inside the database. See chapter 12. |
| plugins and components | extensions (CREATE EXTENSION) | PostgreSQL's add-on ecosystem: PostGIS, pgvector, pg_stat_statements, pg_cron and hundreds more. |
For you as a DBA
Expect tickets written in the other dialect. "Create a schema and a role for the analytics app" means a database and a user on your server, so say that in your reply and nobody is surprised later.
What each engine can do
This table condenses chapters 7 to 13. "No" rarely means impossible; it usually means you emulate the feature, and the AI can write that for you. Read it to see which requests are one line on one engine and a small project on the other. PostgreSQL leads on what the database can do to itself, MySQL on what ships in the box.
| Capability | MySQL 8.4 / 9.7 / 26.x | PostgreSQL 17 / 18 |
|---|---|---|
| FULL OUTER JOIN | No (LEFT UNION RIGHT) | Yes |
| Window functions | Yes | Yes |
| CTEs, including recursive | Yes | Yes |
| INTERSECT and EXCEPT | Yes (8.0.31+) | Yes |
| Materialized views | No (table plus event) | Yes |
| Statement-level triggers | No (row-level only) | Yes |
| INSTEAD OF triggers on views | No | Yes |
| Procedural languages | Partial SQL/PSM only | Yes PL/pgSQL, Python, Perl, more |
| Transactional DDL | No (atomic, implicit commit) | Yes |
| CHECK constraints enforced | Yes (8.0.16+) | Yes |
| Deferrable constraints | No | Yes |
| Partial indexes | No | Yes |
| Expression indexes | Yes functional key parts | Yes |
| JSON indexing | Partial generated or multi-valued | Yes GIN on jsonb |
| Array columns | No (JSON arrays) | Yes |
| Full-text search | Yes FULLTEXT index | Yes tsvector and GIN |
| GIS / spatial | Yes built in | Yes PostGIS |
| Partitioning | Yes | Yes |
| Vectors | Partial type only, distance in HeatWave | Partial pgvector |
| Extension ecosystem | Partial plugins, components | Yes |
| Built-in scheduler | Yes Event Scheduler | No (pg_cron) |
| Roles | Yes | Yes |
| Row-level security | No (views plus grants) | Yes CREATE POLICY |
| Storage engines per table | Yes | No |
| Clustered primary key | Yes InnoDB always | No heap tables |
The last row shapes design work. InnoDB stores every table sorted by its primary key, so a random UUID key scatters inserts across the whole table. PostgreSQL keeps rows in a heap and does not care.
Defaults that surprise you
Most porting bugs come from defaults: things one engine does silently and the other does differently. They matter more than missing features because they produce wrong numbers instead of error messages. MySQL's old reputation for accepting bad data is out of date, and strict mode has been the default since 5.7. Plain lower_snake_case table names avoid the identifier trap in both engines, and they keep a dump from a Linux server loading cleanly on your Windows one.
| Behavior | MySQL 8.4 / 9.7 / 26.x | PostgreSQL 17 / 18 |
|---|---|---|
| String comparison | Default collation utf8mb4_0900_ai_ci ignores case and accents, so 'amira' finds Amira and a UNIQUE index rejects both spellings | Exact by default. Relax it with ILIKE, lower(), the citext extension or a nondeterministic ICU collation |
| Identifier case | lower_case_table_names is 1 on Windows, so Customers and customers are one table. On Linux the default is 0. Fixed when the data directory is initialized | Unquoted names fold to lower case on every platform. Only "double quoted" names keep their case, and then every query must quote them |
| Default isolation | REPEATABLE READ: one snapshot for the whole transaction. See chapter 9 | READ COMMITTED: fresh committed data at each statement |
| DDL in a transaction | Atomic, but every CREATE, ALTER and DROP commits immediately and cannot be undone | Transactional. ROLLBACK after creating tables leaves nothing behind |
GROUP BY strictness | Strict. ONLY_FULL_GROUP_BY is on by default | Strict. Both allow columns that depend on the grouped primary key |
| NULL sort order | NULL first ascending. No NULLS FIRST clause; emulate with ORDER BY col IS NULL, col | NULL counts as larger, so it sorts last ascending. NULLS FIRST and NULLS LAST are supported |
| Booleans | BOOLEAN is an alias for TINYINT(1) and prints 1 and 0 | A real boolean type that prints t and f |
| Implicit conversion | Converts freely. id = '13abc' matches row 13 with a warning | Raises an error on invalid input for the column type |
SELECT id, name FROM customers WHERE name = 'amira';
-- MySQL: 1 row (Amira), because the collation ignores case
-- PostgreSQL: 0 rows
-- PostgreSQL ways to get the MySQL behavior:
SELECT id, name FROM customers WHERE name ILIKE 'amira';
SELECT id, name FROM customers WHERE lower(name) = 'amira';
Ask the AI
When behavior differs, name both engines and versions and ask for the reason as well as the fix: "This returns Amira on MySQL 8.4 and no rows on PostgreSQL 18: SELECT name FROM customers WHERE name = 'amira'. Explain why and give me a PostgreSQL version that behaves the same."
Running them: the operational differences
The SQL is nearly the same. The weekly chores are where you feel the gap.
| Chore | MySQL | PostgreSQL |
|---|---|---|
| Replication and failover | Ships the binlog; async by default, semisynchronous on request. Group Replication plus MySQL Shell and Router make InnoDB Cluster, which elects a new primary for you | Streams the WAL to standbys, async by default, or synchronous per standby. Logical replication copies selected tables across versions. No failover manager, so teams add Patroni or repmgr |
| Backups | mysqldump or parallel util.dumpInstance(). On Windows the free physical option is the Clone plugin or a cold copy; Enterprise Backup is paid and XtraBackup is Linux only | pg_dump and pg_dumpall, plus free pg_basebackup with incremental backups since 17 and WAL archiving for point-in-time recovery. pgBackRest has no Windows build |
| Upgrades | In place: stop the service, install the newer MSI, start it, and the data dictionary upgrades itself. Step through each LTS, and no downgrade, so check with util.checkForServerUpgrade() first | Minor is a binary swap and restart. Major changes the on-disk format, so install alongside and run pg_upgrade (--link avoids copying). Since 18 it keeps optimizer statistics |
| Reclaiming space | InnoDB keeps old row versions in undo logs and purges them automatically. You never think about it | Dead tuples stay in the table until autovacuum clears them. Watch bloat on heavily updated tables, long transactions that block cleanup, and transaction ID wraparound |
| Connections and pooling | One mysqld process with a thread per client. max_connections defaults to 151 and apps usually connect directly. A thread pool is in Community Edition since 26.7 | One backend process per client, so memory and startup cost more. max_connections defaults to 100, which is why shops run PgBouncer in front |
| Monitoring | Performance Schema, the sys views and the slow query log (off by default). Enterprise Monitor reached end of life in January 2025 | pg_stat_* views, pg_stat_statements (needs shared_preload_libraries and a restart), auto_explain, and log_min_duration_statement as the slow log |
Grafana and Percona Monitoring and Management cover both engines, which helps when you inherit a mixed estate. Every major cloud sells both, and a managed service takes backups, patching and failover off your plate. Everything else here stays yours. Linux advice also needs translating on Windows: there is no postgres OS user, so sudo -u postgres psql becomes psql -U postgres, and both engines run their scheduled jobs from Task Scheduler. See chapter 4 and chapter 5.
Porting SQL from one to the other
The same report written for both engines usually differs in three or four small places. Hand the AI the SQL, name both engines with versions, and ask it to list every assumption it made. Then run the result on both servers and compare row counts.
-- MySQL
SELECT c.name, GROUP_CONCAT(o.id ORDER BY o.id) AS order_ids
FROM customers AS c LEFT JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name ORDER BY c.name;
-- PostgreSQL: same rows, including Dev with no orders
SELECT c.name, STRING_AGG(o.id::text, ',' ORDER BY o.id) AS order_ids
FROM customers AS c LEFT JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name ORDER BY c.name;
| MySQL | PostgreSQL | Note |
|---|---|---|
GROUP_CONCAT() | STRING_AGG() | Needs a separator and a text cast. |
IFNULL() | COALESCE() | Works in both. Prefer it. |
DATE_FORMAT() | TO_CHAR() | Different format codes. |
ON DUPLICATE KEY UPDATE | ON CONFLICT DO UPDATE | Name the conflicting columns. |
INSERT IGNORE, REPLACE INTO | ON CONFLICT DO NOTHING, MERGE | No direct equivalent. |
TINYINT(1), DATETIME, INT UNSIGNED | boolean, timestamp, integer plus CHECK | No unsigned integers. |
ENUM('paid','pending') | CREATE TYPE ... AS ENUM or CHECK | Enums are separate objects. |
|| means OR | || concatenates | Silent logic change. CONCAT() works in both. |
UPDATE ... LIMIT 1 | not allowed | Rewrite with a subquery on the key. |
USE shop | \c shop in psql | SQL cannot switch databases. |
Where to read more
- MySQL releases: Innovation and LTS
How the 8.4, 9.7 and 26.x lines relate.
- PostgreSQL versioning policy
Which majors are supported, and until when.
- MySQL Server SQL modes
Strict mode, ANSI_QUOTES and PIPES_AS_CONCAT.
- PostgreSQL routine vacuuming
Dead tuples, autovacuum and wraparound.