Part · Chapter

MySQL vs PostgreSQL

Your job says MySQL, but PostgreSQL keeps showing up in other teams' stacks, in job posts and in AI answers that assume the wrong engine, so here is the translation table.

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 saysPostgreSQL saysWhat it means for you
database (same thing as schema)database, which contains schemasIn 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 attributeMySQL accounts include a host part. PostgreSQL roles do not; who may connect from where lives in pg_hba.conf. See chapter 14.
AUTO_INCREMENTGENERATED ALWAYS AS IDENTITY (older scripts: SERIAL)Same job, backed by a sequence object you can inspect and reset.
storage engine (InnoDB, MyISAM, MEMORY)noneMySQL 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 psqlBoth 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 20the same, or FETCH FIRST 10 ROWS ONLYPostgreSQL accepts both spellings. MySQL takes only LIMIT.
mysqldump, util.dumpInstance()pg_dump, pg_dumpall, pg_restoreLogical backups. See chapter 15.
my.ini plus mysqld-auto.cnf (SET PERSIST)postgresql.conf plus postgresql.auto.conf (ALTER SYSTEM), and pg_hba.confPostgreSQL 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 replicaprimary and standbySame idea. Old articles say master and slave; MySQL 8.4 removed that syntax.
KILL QUERY id, KILL CONNECTION idpg_cancel_backend(pid), pg_terminate_backend(pid)Two levels in both: stop the statement, or drop the session.
SHOW PROCESSLIST, Performance Schema, sys schemapg_stat_activity, the pg_stat_* views, pg_stat_statementsMonitoring. See chapter 16.
innodb_buffer_pool_sizeshared_buffersThe main memory cache. Both default to 128 MB, far too small for a real server.
OPTIMIZE TABLEVACUUM, VACUUM FULLReclaim space. In PostgreSQL plain VACUUM is a routine chore.
Event Scheduler (built in)pg_cron extensionScheduled jobs inside the database. See chapter 12.
plugins and componentsextensions (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.

CapabilityMySQL 8.4 / 9.7 / 26.xPostgreSQL 17 / 18
FULL OUTER JOINNo (LEFT UNION RIGHT)Yes
Window functionsYesYes
CTEs, including recursiveYesYes
INTERSECT and EXCEPTYes (8.0.31+)Yes
Materialized viewsNo (table plus event)Yes
Statement-level triggersNo (row-level only)Yes
INSTEAD OF triggers on viewsNoYes
Procedural languagesPartial SQL/PSM onlyYes PL/pgSQL, Python, Perl, more
Transactional DDLNo (atomic, implicit commit)Yes
CHECK constraints enforcedYes (8.0.16+)Yes
Deferrable constraintsNoYes
Partial indexesNoYes
Expression indexesYes functional key partsYes
JSON indexingPartial generated or multi-valuedYes GIN on jsonb
Array columnsNo (JSON arrays)Yes
Full-text searchYes FULLTEXT indexYes tsvector and GIN
GIS / spatialYes built inYes PostGIS
PartitioningYesYes
VectorsPartial type only, distance in HeatWavePartial pgvector
Extension ecosystemPartial plugins, componentsYes
Built-in schedulerYes Event SchedulerNo (pg_cron)
RolesYesYes
Row-level securityNo (views plus grants)Yes CREATE POLICY
Storage engines per tableYesNo
Clustered primary keyYes InnoDB alwaysNo 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.

BehaviorMySQL 8.4 / 9.7 / 26.xPostgreSQL 17 / 18
String comparisonDefault collation utf8mb4_0900_ai_ci ignores case and accents, so 'amira' finds Amira and a UNIQUE index rejects both spellingsExact by default. Relax it with ILIKE, lower(), the citext extension or a nondeterministic ICU collation
Identifier caselower_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 initializedUnquoted names fold to lower case on every platform. Only "double quoted" names keep their case, and then every query must quote them
Default isolationREPEATABLE READ: one snapshot for the whole transaction. See chapter 9READ COMMITTED: fresh committed data at each statement
DDL in a transactionAtomic, but every CREATE, ALTER and DROP commits immediately and cannot be undoneTransactional. ROLLBACK after creating tables leaves nothing behind
GROUP BY strictnessStrict. ONLY_FULL_GROUP_BY is on by defaultStrict. Both allow columns that depend on the grouped primary key
NULL sort orderNULL first ascending. No NULLS FIRST clause; emulate with ORDER BY col IS NULL, colNULL counts as larger, so it sorts last ascending. NULLS FIRST and NULLS LAST are supported
BooleansBOOLEAN is an alias for TINYINT(1) and prints 1 and 0A real boolean type that prints t and f
Implicit conversionConverts freely. id = '13abc' matches row 13 with a warningRaises an error on invalid input for the column type
Illustration · Both
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.

ChoreMySQLPostgreSQL
Replication and failoverShips the binlog; async by default, semisynchronous on request. Group Replication plus MySQL Shell and Router make InnoDB Cluster, which elects a new primary for youStreams 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
Backupsmysqldump 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 onlypg_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
UpgradesIn 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() firstMinor 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 spaceInnoDB keeps old row versions in undo logs and purges them automatically. You never think about itDead 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 poolingOne 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.7One backend process per client, so memory and startup cost more. max_connections defaults to 100, which is why shops run PgBouncer in front
MonitoringPerformance Schema, the sys views and the slow query log (off by default). Enterprise Monitor reached end of life in January 2025pg_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.

Illustration · Both
-- 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;
MySQLPostgreSQLNote
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 UPDATEON CONFLICT DO UPDATEName the conflicting columns.
INSERT IGNORE, REPLACE INTOON CONFLICT DO NOTHING, MERGENo direct equivalent.
TINYINT(1), DATETIME, INT UNSIGNEDboolean, timestamp, integer plus CHECKNo unsigned integers.
ENUM('paid','pending')CREATE TYPE ... AS ENUM or CHECKEnums are separate objects.
|| means OR|| concatenatesSilent logic change. CONCAT() works in both.
UPDATE ... LIMIT 1not allowedRewrite with a subquery on the key.
USE shop\c shop in psqlSQL cannot switch databases.

Where to read more

Quick check

1. A PostgreSQL colleague asks you to "create a reporting schema". On PostgreSQL, a schema is…

In PostgreSQL a database contains schemas (public by default) and a connection is bound to one database. In MySQL, schema and database are synonyms. Reread "The translation table".

2. Which routine chore exists only on the PostgreSQL side?

InnoDB purges old row versions from its undo logs automatically. PostgreSQL leaves dead tuples in the table and relies on VACUUM, usually run by autovacuum. ANALYZE exists in both; the binlog is MySQL-only. Reread "Running them: the operational differences".

3. You run SELECT id, customer_id FROM orders ORDER BY customer_id on both engines. Where does order 13 (customer_id NULL) appear?

MySQL presents NULL first in ascending order. PostgreSQL treats NULL as larger than any value, so it comes last unless you write NULLS FIRST. Reread "Defaults that surprise you".

4. Inside an explicit transaction you run CREATE TABLE, then ROLLBACK. What happens?

PostgreSQL has transactional DDL. MySQL's DDL is atomic but causes an implicit commit, so the table remains. Reread "Defaults that surprise you".

5. WHERE name = 'amira' finds Amira on MySQL but returns nothing on PostgreSQL. Why?

MySQL's utf8mb4_0900_ai_ci ignores case and accents; PostgreSQL's default comparison is exact, and you use ILIKE, lower() or citext to relax it. Reread "Defaults that surprise you".