MySQL and PostgreSQL are the two most popular open-source relational databases in the world. MySQL is famous for its speed and ease of use — it powers a huge share of the web (including WordPress and early Facebook). PostgreSQL is known for its strict standards compliance, advanced feature set, and reliability under complex workloads. This guide covers every major dimension so you can pick the right database for your project.
At a glance
| MySQL | PostgreSQL | |
|---|---|---|
| First released | 1995 (MySQL AB) | 1996 (UC Berkeley) |
| License | GPL v2 / commercial | PostgreSQL License (MIT-like) |
| SQL compliance | Partial | Near-full (most compliant OSS DB) |
| Primary use case | Web apps, CMS, read-heavy | Complex queries, analytics, data integrity |
| ACID | Yes (InnoDB) | Yes (always) |
| JSON support | JSON type (limited) | JSONB (indexed, powerful) |
| Full-text search | Basic | Advanced (tsvector/tsquery) |
| Replication | Async + semi-sync | Streaming + logical |
| Extensions | Limited | PostGIS, pgvector, TimescaleDB, etc. |
| Managed cloud | RDS, Cloud SQL, PlanetScale | RDS, Cloud SQL, Supabase, Neon |
History and philosophy
MySQL was created in 1995 by MySQL AB (later acquired by Sun, then Oracle). Its design goal was speed and simplicity for web applications — it prioritised fast reads over strict SQL compliance. MySQL popularised the LAMP stack (Linux, Apache, MySQL, PHP) and became the default database for millions of WordPress and Drupal sites.
PostgreSQL (originally called POSTGRES) was developed at UC Berkeley starting in 1986 and released as open-source in 1996. Its design goal was correctness and extensibility — it aimed to be the most SQL-compliant open-source database. PostgreSQL describes itself as "the world's most advanced open-source relational database."
The philosophical difference matters: MySQL trades some correctness for speed by default; PostgreSQL refuses to sacrifice correctness for performance.
SQL compliance
| Feature | MySQL | PostgreSQL |
|---|---|---|
| Standard SQL | Partial | Near-full |
| Window functions | Yes (8.0+) | Yes (since 2009) |
| CTEs (WITH clause) | Yes (8.0+) | Yes |
| Recursive CTEs | Yes (8.0+) | Yes |
| CHECK constraints enforced | Yes (8.0.16+) | Yes (always) |
| Foreign keys enforced | Yes (InnoDB) | Yes |
| RETURNING clause | No | Yes |
| DISTINCT ON | No | Yes |
| Lateral joins | Yes (8.0+) | Yes |
| Range types | No | Yes |
| Array types | No | Yes |
| Custom types/domains | Limited | Yes |
MySQL historically allowed inserting invalid data (e.g., inserting a string into an integer column silently). Modern MySQL with sql_mode=STRICT_TRANS_TABLES (the default since 5.7) is much better, but PostgreSQL has always been strict by default.
Performance
Neither database is universally faster — it depends on the workload.
| Workload | Winner | Why |
|---|---|---|
| Simple read queries (SELECT by PK) | MySQL | Lightweight parser, low overhead |
| Write-heavy, high concurrency | PostgreSQL | MVCC implementation avoids read-write locks |
| Complex JOIN queries | PostgreSQL | Better query planner |
| Analytical / reporting queries | PostgreSQL | Parallel query, better aggregation |
| Full-text search | PostgreSQL | tsvector outperforms MySQL FULLTEXT |
| JSON queries | PostgreSQL | JSONB with GIN indexes |
| Connection count (10k+ connections) | PostgreSQL + pgBouncer | MySQL handles many connections natively better |
MVCC implementation differences:
Both databases use Multi-Version Concurrency Control (MVCC) to allow readers and writers to not block each other. However:
- MySQL (InnoDB) stores the old row version in a separate undo log. This keeps the main tablespace clean but can make
ROLLBACKslow for large transactions. - PostgreSQL stores old row versions in the table itself (heap). This means
ROLLBACKis fast (just mark the transaction as aborted), but old versions accumulate and requireVACUUMto reclaim space.
For most web applications (< 10k QPS, < 1TB data), the difference is negligible. Benchmark your specific workload.
Data types
| Type | MySQL | PostgreSQL |
|---|---|---|
| Integer types | TINYINT, SMALLINT, MEDIUMINT, INT, BIGINT | SMALLINT, INTEGER, BIGINT |
| Auto-increment | AUTO_INCREMENT | SERIAL, IDENTITY, sequences |
| String | CHAR, VARCHAR, TEXT | CHAR, VARCHAR, TEXT |
| Binary | BINARY, VARBINARY, BLOB | BYTEA |
| Date/time | DATE, TIME, DATETIME, TIMESTAMP, YEAR | DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL |
| JSON | JSON (stored as text) | JSON, JSONB (binary, indexed) |
| UUID | VARCHAR or BINARY(16) | UUID (native type) |
| Arrays | No | Yes (any type) |
| Range types | No | int4range, tsrange, daterange, etc. |
| Network types | No | INET, CIDR, MACADDR |
| Geometric types | No | POINT, LINE, POLYGON, etc. |
| Full-text | FULLTEXT index | tsvector + tsquery + GIN |
| Enum | ENUM (table-level lock to alter) | CREATE TYPE ... AS ENUM (safer) |
| Custom types | No | Yes (CREATE TYPE, CREATE DOMAIN) |
PostgreSQL's type system is far richer. If you need to store IP addresses, geographic coordinates, time ranges, or arrays natively, PostgreSQL wins.
JSON support
JSON support is one of the biggest differentiators.
MySQL JSON:
-- Store JSON
CREATE TABLE events (data JSON);
INSERT INTO events VALUES ('{"name": "click", "count": 5}');
-- Query (slow without generated columns)
SELECT data->>'$.name' FROM events WHERE data->>'$.count' > 3;
-- Must create generated column for index
ALTER TABLE events
ADD COLUMN count_val INT GENERATED ALWAYS AS (data->>'$.count') STORED,
ADD INDEX (count_val);
PostgreSQL JSONB:
-- Store JSONB (binary, faster to query)
CREATE TABLE events (data JSONB);
INSERT INTO events VALUES ('{"name": "click", "count": 5}');
-- Query with operators
SELECT data->>'name' FROM events WHERE (data->>'count')::int > 3;
-- Direct GIN index on the whole JSONB column
CREATE INDEX ON events USING GIN (data);
-- jsonpath queries (PostgreSQL 12+)
SELECT * FROM events WHERE data @@ '$.count > 3';
PostgreSQL's JSONB:
- Stores data in binary format (faster to read, slightly slower to write than JSON)
- Supports GIN indexes directly on the column — no generated columns needed
- Operators:
->(get key as JSON),->>(get key as text),@>(contains),?(key exists),@@(jsonpath) - Can index individual keys with expression indexes
- Supports
jsonb_set,jsonb_insert,jsonb_delete_path
For document-heavy workloads (product catalogs, event logs, user preferences), PostgreSQL JSONB is significantly more capable.
Full-text search
| Feature | MySQL FULLTEXT | PostgreSQL tsvector |
|---|---|---|
| Index type | FULLTEXT | GIN or GiST |
| Ranking | Relevance score | ts_rank, ts_rank_cd |
| Language support | Basic | 25+ languages |
| Stemming | Limited | Per-language dictionaries |
| Stop words | Yes | Configurable |
| Phrase search | Limited | Yes (phraseto_tsquery) |
| Prefix search | No | Yes (websearch_to_tsquery) |
| Highlighting | No | ts_headline |
| Index updates | Automatic | Automatic (with trigger or generated column) |
-- PostgreSQL full-text search example
ALTER TABLE articles ADD COLUMN search_vector TSVECTOR
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
) STORED;
CREATE INDEX ON articles USING GIN (search_vector);
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, websearch_to_tsquery('english', 'database performance') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;
For production full-text search at scale, dedicated tools (Elasticsearch, Typesense, Meilisearch) are better than either database. But for moderate workloads, PostgreSQL's built-in full-text search is genuinely useful.
Replication
| Feature | MySQL | PostgreSQL |
|---|---|---|
| Primary/replica replication | Yes | Yes |
| Async replication | Yes (default) | Yes (default) |
| Sync replication | Semi-sync (plugin) | Yes (synchronous_standby_names) |
| Logical replication | Yes (8.0+) | Yes (10+) |
| Multi-source replication | Yes (Group Replication) | Third-party (BDR, Patroni) |
| Built-in HA/failover | Group Replication | Patroni, repmgr (third-party) |
| Read replicas | Yes | Yes |
| Streaming replication | Yes (binary log) | Yes (WAL streaming) |
MySQL has built-in Group Replication for multi-primary setups. Tools like ProxySQL and Orchestrator make managing MySQL replication clusters well-understood.
PostgreSQL streaming replication is simple and reliable. High-availability requires third-party tools like Patroni (industry standard), repmgr, or managed services (Supabase, Neon, AWS Aurora PostgreSQL).
Extensions and ecosystem
This is PostgreSQL's biggest advantage:
| Extension | What it does |
|---|---|
| PostGIS | Geographic objects, spatial queries (GIS industry standard) |
| pgvector | Vector embeddings for AI/ML similarity search |
| TimescaleDB | Time-series data at scale (used by Grafana Cloud) |
| pg_partman | Automated partition management |
| pg_cron | Cron jobs inside PostgreSQL |
| pgcrypto | Cryptographic functions |
| pg_stat_statements | Query performance tracking |
| HypoPG | Test hypothetical indexes without creating them |
| Citus | Distributed PostgreSQL (horizontal sharding) |
| pgrouting | Geospatial routing |
| age | Graph database queries (Apache AGE) |
MySQL has fewer extensions. Its ecosystem is more about tooling (Percona Toolkit, Vitess for sharding) than in-database extensions.
Indexing
Both databases support B-tree indexes by default. PostgreSQL offers more:
| Index type | MySQL | PostgreSQL | Use case |
|---|---|---|---|
| B-tree | Yes | Yes | Equality, range queries |
| Hash | Yes (NDB only) | Yes | Equality only |
| Full-text / GIN | FULLTEXT | GIN | Text search, JSONB, arrays |
| GiST | No | Yes | Geometric data, ranges |
| BRIN | No | Yes | Time-series, sequential data |
| SP-GiST | No | Yes | Non-balanced tree structures |
| Partial index | No | Yes | Index subset of rows |
| Expression index | Yes (generated) | Yes | Index on function result |
| Covering index (INCLUDE) | Yes (8.0+) | Yes (11+) | Avoid table lookup |
| Invisible index | Yes | No (use partial index) | Test index removal |
-- PostgreSQL partial index: only index active users
CREATE INDEX ON users (email) WHERE is_active = true;
-- PostgreSQL expression index: case-insensitive email lookup
CREATE INDEX ON users (lower(email));
-- PostgreSQL GIN index on JSONB
CREATE INDEX ON products USING GIN (attributes);
Security and access control
| Feature | MySQL | PostgreSQL |
|---|---|---|
| Row-Level Security | No (application-level) | Yes (RLS policies) |
| Column-level permissions | Yes (GRANT on columns) | Yes |
| Role-based access | Users + roles | Roles only (users are roles) |
| SSL/TLS | Yes | Yes |
| Password auth | Yes | Yes + SCRAM-SHA-256 |
| LDAP auth | Yes | Yes |
| Kerberos | Enterprise only | Yes |
| pg_hba.conf (host-based auth) | No | Yes (fine-grained) |
| Audit logging | Plugin (audit_log) | pgaudit extension |
Row-Level Security (RLS) is a major PostgreSQL feature used heavily by Supabase:
-- Enable RLS on a table
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their own documents
CREATE POLICY user_documents ON documents
FOR ALL USING (owner_id = current_user_id());
-- Now every query is automatically filtered
SELECT * FROM documents; -- returns only current user's documents
This enables building multi-tenant applications where the database enforces isolation, not just the application code.
Stored procedures and functions
-- PostgreSQL supports multiple procedural languages
CREATE OR REPLACE FUNCTION get_user_stats(user_id INT)
RETURNS TABLE (total_orders INT, total_spent DECIMAL)
LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY
SELECT COUNT(*)::INT, SUM(amount)
FROM orders
WHERE orders.user_id = get_user_stats.user_id;
END;
$$;
-- PostgreSQL also supports SQL functions, PL/Python, PL/V8 (JavaScript), PL/R
-- MySQL stored procedure
DELIMITER $$
CREATE PROCEDURE GetUserStats(IN p_user_id INT)
BEGIN
SELECT COUNT(*) AS total_orders, SUM(amount) AS total_spent
FROM orders
WHERE user_id = p_user_id;
END$$
DELIMITER ;
PostgreSQL supports PL/pgSQL, PL/Python, PL/Perl, PL/Tcl, PL/V8 (JavaScript), and more. MySQL supports only its own procedural language.
Transactions and isolation
Both databases are ACID-compliant with InnoDB (MySQL) and always (PostgreSQL).
| Isolation level | MySQL | PostgreSQL |
|---|---|---|
| READ UNCOMMITTED | Yes | Mapped to READ COMMITTED |
| READ COMMITTED | Yes | Yes |
| REPEATABLE READ | Yes (default) | Yes |
| SERIALIZABLE | Yes | Yes (uses SSI — Serializable Snapshot Isolation) |
PostgreSQL's SERIALIZABLE level uses Serializable Snapshot Isolation (SSI) — a more modern algorithm that avoids most false positives (unnecessary aborts) compared to traditional locking-based serializable. This makes it practical to use serializable isolation in production.
MySQL's default isolation level is REPEATABLE READ. PostgreSQL's default is READ COMMITTED.
Migrations and schema changes
| Operation | MySQL | PostgreSQL |
|---|---|---|
| Online DDL (no lock) | Yes (most operations) | Yes (most operations since 11+) |
| Transactional DDL | No | Yes — DDL inside transactions! |
| Adding column with default | Fast (8.0+: instant) | Fast (11+: instant for constant defaults) |
| Adding NOT NULL column | Requires default or rewrite | Requires default or rewrite |
| Altering column type | Table rewrite | Table rewrite (or use new column) |
| Renaming column | Yes (8.0+) | Yes |
Transactional DDL is a PostgreSQL superpower:
-- In PostgreSQL, DDL runs inside transactions
BEGIN;
ALTER TABLE users ADD COLUMN preferences JSONB;
CREATE INDEX ON users (email);
-- If anything goes wrong, ROLLBACK undoes all DDL changes
COMMIT;
In MySQL, DDL statements cause an implicit COMMIT — you cannot roll back a schema change.
Licensing
| MySQL | PostgreSQL | |
|---|---|---|
| License | GPL v2 | PostgreSQL License (MIT-like) |
| Commercial use | Free under GPL; commercial license from Oracle | Completely free for any use |
| Forks | MariaDB, Percona Server | Not needed (open enough) |
| Oracle ownership | Yes (since 2010) | Community-governed |
PostgreSQL's license is more permissive — you can use it in commercial software without restrictions. MySQL's GPL means if you distribute software that includes MySQL, you must open-source it (or buy a commercial license from Oracle).
This is why many companies prefer PostgreSQL for embedded or redistributed products. It's also why MariaDB was created as a GPL MySQL fork after Oracle acquired MySQL.
Managed cloud options
| Provider | MySQL | PostgreSQL |
|---|---|---|
| AWS | RDS MySQL, Aurora MySQL | RDS PostgreSQL, Aurora PostgreSQL |
| Google Cloud | Cloud SQL MySQL | Cloud SQL PostgreSQL, AlloyDB |
| Azure | Azure Database for MySQL | Azure Database for PostgreSQL |
| PlanetScale | Yes (serverless, Vitess-based) | No |
| Supabase | No | Yes (with RLS, real-time, auth) |
| Neon | No | Yes (serverless, branching) |
| Railway | Yes | Yes |
| Render | Yes | Yes |
| CockroachDB | No | PostgreSQL-compatible |
Supabase (PostgreSQL + RLS + Auth + real-time) has become the go-to managed database for startups. Neon offers PostgreSQL with serverless scaling and database branching (create a copy of your DB for every PR). PlanetScale (MySQL-based) offers serverless MySQL with schema branching.
When to choose MySQL
- WordPress / CMS: WordPress, Drupal, Joomla are optimised for MySQL. Use MySQL.
- Simple read-heavy web apps: High-traffic blogs, forums, content sites where you mostly do SELECT by primary key.
- Existing team expertise: If your team knows MySQL well and the workload is standard, switching costs outweigh benefits.
- PlanetScale: If you want serverless MySQL with schema branching.
- phpMyAdmin workflows: MySQL's tooling and hosting ecosystem is larger for PHP/WordPress stacks.
- MariaDB: If you want a fully open MySQL-compatible database free from Oracle's influence.
When to choose PostgreSQL
- Complex queries: Multiple JOINs, window functions, CTEs, recursive queries.
- Data integrity is critical: Financial systems, healthcare, anything where "close enough" isn't good enough.
- JSON as a first-class feature: Product catalogs with variable attributes, event sourcing, user preferences.
- AI/ML workloads: pgvector for similarity search, time-series with TimescaleDB.
- Geospatial: PostGIS is the industry standard for geographic data.
- Row-Level Security: Multi-tenant SaaS, applications that need the database to enforce data isolation.
- Supabase / Neon: If you want the modern managed PostgreSQL ecosystem.
- Analytical queries: PostgreSQL's query planner handles complex analytics better.
- Full text search on moderate scale: tsvector/tsquery is genuinely useful for < 10M documents.
Side-by-side syntax comparison
-- Auto-increment primary key
-- MySQL:
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- PostgreSQL:
CREATE TABLE users (
id SERIAL PRIMARY KEY, -- or: id INTEGER GENERATED ALWAYS AS IDENTITY
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Upsert
-- MySQL:
INSERT INTO users (id, email) VALUES (1, 'a@b.com')
ON DUPLICATE KEY UPDATE email = VALUES(email);
-- PostgreSQL:
INSERT INTO users (id, email) VALUES (1, 'a@b.com')
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email;
-- Get inserted row back
-- MySQL: no RETURNING — must use LAST_INSERT_ID()
INSERT INTO users (email) VALUES ('a@b.com');
SELECT LAST_INSERT_ID();
-- PostgreSQL: RETURNING clause
INSERT INTO users (email) VALUES ('a@b.com')
RETURNING id, created_at;
-- Pagination (keyset, both support it)
-- MySQL:
SELECT * FROM posts WHERE id > ? ORDER BY id LIMIT 20;
-- PostgreSQL:
SELECT * FROM posts WHERE id > $1 ORDER BY id LIMIT 20;
Full comparison table
| Dimension | MySQL | PostgreSQL |
|---|---|---|
| SQL compliance | Partial | Near-full |
| Typing strictness | Lenient (strict mode opt-in) | Always strict |
| Default isolation | REPEATABLE READ | READ COMMITTED |
| Transactional DDL | No | Yes |
| JSON support | JSON (text storage) | JSONB (binary, indexed) |
| Array types | No | Yes |
| Range types | No | Yes |
| Full-text search | FULLTEXT (basic) | tsvector (advanced) |
| Row-Level Security | No | Yes |
| Extensions | Few | Many (PostGIS, pgvector, etc.) |
| Procedural languages | MySQL SQL only | PL/pgSQL, PL/Python, PL/V8, etc. |
| Performance (simple reads) | Slightly faster | Comparable |
| Performance (complex queries) | Good | Better |
| Replication | Built-in HA (Group Replication) | Streaming + logical (HA via Patroni) |
| License | GPL v2 / commercial | PostgreSQL License (free for all) |
| Oracle control | Yes | No (community-governed) |
| Best managed option | PlanetScale, AWS RDS | Supabase, Neon, AWS RDS |
| Ecosystem fit | WordPress, LAMP | Modern SaaS, analytics, AI, GIS |
Common mistakes
| Mistake | Why it's a problem |
|---|---|
| Using MySQL for analytics without partitioning | Complex aggregations are slow without planning |
| Using PostgreSQL without VACUUM | Dead tuples accumulate and slow queries |
| Ignoring connection pooling for PostgreSQL | PostgreSQL spawns a process per connection — pgBouncer is essential at scale |
| Using MySQL ENUM and trying to add values | Alters the column definition and requires a table lock (use a lookup table instead) |
| Forgetting TIMESTAMPTZ in PostgreSQL | TIMESTAMP without timezone stores local time, breaking with timezone changes |
| Using SELECT * in production | Fetches unnecessary columns, breaks when schema changes |
| Not using EXPLAIN ANALYZE | Writing queries without checking the query plan |
| Switching databases without benchmarking | Benchmark your actual workload, not synthetic tests |
FAQ
Is PostgreSQL always better than MySQL?
Not for every use case. MySQL is fast, well-understood, and perfect for WordPress and simple read-heavy applications. PostgreSQL is better for complex queries, data integrity, JSON workloads, and modern SaaS applications. Choose based on your actual requirements.
Can I migrate from MySQL to PostgreSQL?
Yes, but it requires work. Tools like pgloader can automate much of the schema and data migration. The bigger challenge is rewriting MySQL-specific syntax (AUTO_INCREMENT → SERIAL, backtick identifiers, DATETIME → TIMESTAMPTZ). Budget a few days for a medium-sized application.
What about MariaDB?
MariaDB is a MySQL fork created by MySQL's original developers after Oracle acquired MySQL. It's API-compatible with MySQL, has some PostgreSQL-inspired features (e.g., window functions earlier than MySQL), and is the default "MySQL" in many Linux distributions. For most purposes, choose MariaDB or MySQL based on your hosting environment — they're interchangeable for typical web apps.
Which is faster for WordPress?
MySQL (and MariaDB) are faster for WordPress. WordPress is designed for MySQL, its queries are tuned for MySQL's optimizer, and the entire WordPress hosting ecosystem optimizes for MySQL. Using PostgreSQL with WordPress requires third-party plugins and is not recommended.
What is the best managed PostgreSQL for a startup?
Supabase for projects that want auth, RLS, real-time, and storage included. Neon for pure serverless PostgreSQL with database branching (great for CI/CD). AWS RDS Aurora PostgreSQL for enterprise workloads that need Oracle-level support.
Does PostgreSQL support sharding?
Not natively. Options: Citus (distributed PostgreSQL, now open-source), manual application-level sharding, or AWS Aurora PostgreSQL's distributed version. For most applications, read replicas + vertical scaling handle growth before sharding is needed.
Ready to work with databases? Use our JSON Formatter to validate JSON data before inserting into your database, or our SQL Formatter to clean up your queries.