PostgreSQL, MySQL, SQL Server, Oracle and SQLite Compared
One reference table for the differences that actually break code: paging, auto increment, strings, dates, upserts and the features each product lacks.
- SQL Basics
- DDL
- DML
- SELECT
- WHERE
- Functions
- NULL and Logic
- Aggregate Functions
- GROUP BY
- JOIN
- Subqueries
- Set Operations
- CTEs
- Constraints
- Keys
- Relationships
- Database Design
- Normalisation
- Views
- Window Functions
- Advanced SQL
- Procedures and Functions
- Triggers
- Temporary Tables
- Transactions
- Isolation and Locking
- Indexes
- Query Performance
- SQL Security
- SQL Dialects
- Practical SQL
Concept
SQL is a standard that every product implements partially and extends privately. The core - SELECT, JOIN, GROUP BY, constraints, transactions - is genuinely portable. Everything around the edges is not.
The differences that break code
Limiting rows
| Product | Syntax |
|---|---|
| MySQL, MariaDB, PostgreSQL, SQLite | LIMIT 20 OFFSET 40 |
| Standard, PostgreSQL, SQL Server 2012+, Oracle 12c+ | OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY |
| SQL Server (simple top N) | SELECT TOP 20 ... |
Auto generated keys
| Product | Declaration | Last inserted id |
|---|---|---|
| MySQL / MariaDB | AUTO_INCREMENT | LAST_INSERT_ID() |
| PostgreSQL | GENERATED ALWAYS AS IDENTITY or SERIAL | RETURNING id |
| SQL Server | IDENTITY(1,1) | SCOPE_IDENTITY() or OUTPUT |
| Oracle | GENERATED AS IDENTITY | RETURNING ... INTO |
| SQLite | INTEGER PRIMARY KEY AUTOINCREMENT | last_insert_rowid() |
Strings
-- Concatenation
a || b -- standard, PostgreSQL, Oracle, SQLite
CONCAT(a, b) -- MySQL, MariaDB (|| means OR by default)
a + b -- SQL Server
-- Length
CHAR_LENGTH(s) -- standard, MySQL, PostgreSQL
LEN(s) -- SQL Server
LENGTH(s) -- Oracle, SQLite (bytes in MySQL!)
-- Case sensitivity of =
-- MySQL: collation driven, usually case INsensitive
-- PostgreSQL: case SENSITIVE (ILIKE exists for patterns)
-- SQL Server: collation driven, usually case insensitive
-- Oracle: case sensitiveDates
-- Now
NOW() -- MySQL, PostgreSQL GETDATE() -- SQL Server
CURRENT_DATE -- standard, most SYSDATE -- Oracle
-- Add 7 days
DATE_ADD(d, INTERVAL 7 DAY) -- MySQL
d + INTERVAL '7 days' -- PostgreSQL
DATEADD(DAY, 7, d) -- SQL Server
d + 7 -- Oracle
DATE(d, '+7 days') -- SQLite
-- Difference in days
DATEDIFF(a, b) -- MySQL: a minus b
DATEDIFF(DAY, b, a) -- SQL Server: note the reversed argument order
a - b -- PostgreSQL, OracleUpsert
INSERT ... ON DUPLICATE KEY UPDATE ... -- MySQL, MariaDB
INSERT ... ON CONFLICT (col) DO UPDATE -- PostgreSQL, SQLite
MERGE INTO ... WHEN MATCHED THEN ... -- standard, SQL Server, OracleNULL handling
COALESCE(a, b) -- standard, everywhere. Prefer this.
IFNULL(a, b) -- MySQL, SQLite
ISNULL(a, b) -- SQL Server
NVL(a, b) -- OracleFeature availability
| Feature | MySQL | MariaDB | PostgreSQL | SQL Server | Oracle | SQLite |
|---|---|---|---|---|---|---|
| CTEs | 8.0+ | 10.2+ | Yes | Yes | Yes | 3.8.3+ |
| Window functions | 8.0+ | 10.2+ | Yes | 2012+ | Yes | 3.25+ |
FULL OUTER JOIN | No | No | Yes | Yes | Yes | 3.39+ |
INTERSECT / EXCEPT | 8.0.31+ | 10.3+ | Yes | Yes | MINUS | Yes |
| Materialised views | No | No | Yes | Indexed views | Yes | No |
| Partial / filtered index | No | No | Yes | Yes | Function based | Yes |
LATERAL / APPLY | 8.0.14+ | No | Yes | APPLY | 12c+ | No |
| Stored procedures | Yes | Yes | 11+ | Yes | Yes | No |
| Row level security | No | No | Yes | Yes | Yes | No |
| Array / JSON types | JSON | JSON | Both, richly | JSON | JSON | JSON1 ext |
Behavioural differences, not just syntax
| Behaviour | Difference |
|---|---|
| Default isolation | MySQL REPEATABLE READ; PostgreSQL, SQL Server and Oracle READ COMMITTED |
| Transactional DDL | PostgreSQL and SQL Server yes; MySQL and Oracle auto commit |
NULL ordering | MySQL and SQLite first ascending; PostgreSQL and Oracle last |
| Empty string | Oracle treats '' as NULL; everyone else does not |
| Identifier case | PostgreSQL folds to lower; Oracle folds to upper; MySQL depends on the file system |
GROUP BY strictness | MySQL 8 enforces ONLY_FULL_GROUP_BY by default; MariaDB does not |
| Integer division | PostgreSQL, SQL Server and Oracle truncate; MySQL returns a decimal |
Important rules
- The core query language is portable. Paging, identity, dates, strings and upserts are not.
- Version matters as much as product: MySQL 5.7 and 8.0 differ more than some separate products do.
- Behavioural differences are more dangerous than syntax ones - syntax errors are loud, behaviour changes are silent.
- Oracle's empty string equals
NULLrule breaks assumptions carried from any other product.
Common mistakes
- Copying an answer from the internet without checking which product it targets.
- Porting an application between products and not re checking the isolation level.
- Using
LIMITin code destined for SQL Server or older Oracle. - Assuming
||concatenates on MySQL - it isOR. - Relying on MySQL's loose
GROUP BY, then moving to PostgreSQL.
Best practices
- Write down the target product and version in the project documentation.
- Prefer standard forms -
COALESCE,CAST,EXTRACT,CHAR_LENGTH- where one exists. - Isolate the non portable parts in one layer, so a port touches few files.
- Run tests against the same product and version as production.
Practice
- Write "the 20 most recent orders, skipping the first 40" for MySQL, PostgreSQL and SQL Server.
- Write an upsert into
customersfor MySQL and for PostgreSQL. - List three behavioural - not syntactic - differences that would silently change results in a port from PostgreSQL to MySQL.
Useful resources
Hand picked references for this topicComplete MySQL server reference documentation.
PostgreSQL DocumentationOfficial PostgreSQL manual, including the full SQL reference.
SQLite SQL ReferenceOfficial description of the SQL dialect SQLite understands.
SQL Server T-SQL ReferenceOfficial Transact-SQL language reference for SQL Server.
Oracle SQL Language ReferenceOfficial Oracle Database SQL language documentation.