SQL dialect comparison
The same operation, four ways. Organized by engine instead of by topic — for when you know what you want to do and just need the syntax for a specific database.
Limit rows
Full page- PostgreSQL
LIMIT 20 OFFSET 40- MySQL
LIMIT 40, 20- SQL Server
SELECT TOP 20 * FROM t ORDER BY ... OFFSET 40 ROWS- SQLite
LIMIT 20 OFFSET 40
SQL Server's TOP goes right after SELECT, not at the end.
String concatenation
Full page- PostgreSQL
a || b- MySQL
CONCAT(a, b)- SQL Server
a + b -- or CONCAT(a, b)- SQLite
a || b
|| is standard SQL; MySQL reads it as logical OR by default, so use CONCAT there.
Current date / time
Full page- PostgreSQL
now() -- or CURRENT_TIMESTAMP- MySQL
NOW()- SQL Server
GETDATE()- SQLite
datetime('now')
CURRENT_DATE / CURRENT_TIMESTAMP are the standard forms most engines also accept.
Bucket a date (truncate to month)
Full page- PostgreSQL
date_trunc('month', ts)- MySQL
DATE_FORMAT(ts, '%Y-%m-01')- SQL Server
DATETRUNC(month, ts) -- 2022+- SQLite
strftime('%Y-%m', ts)
This is the least portable corner of SQL — expect a different function name everywhere.
Add an interval to a date
Full page- PostgreSQL
ts + INTERVAL '7 days'- MySQL
DATE_ADD(ts, INTERVAL 7 DAY)- SQL Server
DATEADD(day, 7, ts)- SQLite
date(ts, '+7 days')
Store timestamps in UTC and do this math before formatting for display.
Upsert (insert or update)
Full page- PostgreSQL
INSERT ... ON CONFLICT (id) DO UPDATE SET ...- MySQL
INSERT ... ON DUPLICATE KEY UPDATE ...- SQL Server
MERGE INTO t USING src ON ... WHEN MATCHED ...- SQLite
INSERT ... ON CONFLICT (id) DO UPDATE SET ...
Only PostgreSQL and SQLite share syntax here. MySQL and standard SQL/SQL Server are both genuinely different shapes — see the full page.
Auto-incrementing primary key
- PostgreSQL
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY- MySQL
id INT AUTO_INCREMENT PRIMARY KEY- SQL Server
id INT IDENTITY(1,1) PRIMARY KEY- SQLite
id INTEGER PRIMARY KEY
SQLite doesn't need a keyword at all — INTEGER PRIMARY KEY is an alias for the internal rowid and auto-increments on its own.
Case-insensitive match
- PostgreSQL
col ILIKE 'a%'- MySQL
col LIKE 'a%' -- ci collation by default- SQL Server
col LIKE 'a%' -- ci collation by default- SQLite
col LIKE 'a%' -- ASCII case-insensitive by default
SQL Server and MySQL's default collations are usually already case-insensitive — this is for when they aren't, or you need it explicitly.
Find a substring’s position
Full page- PostgreSQL
POSITION('cd' IN s) -- or STRPOS(s, 'cd')- MySQL
INSTR(s, 'cd')- SQL Server
CHARINDEX('cd', s)- SQLite
instr(s, 'cd')
All 1-indexed; 0 means not found. Watch the argument order — POSITION reads needle-in-haystack, the others read haystack-then-needle.
Extract a substring
Full page- PostgreSQL
SUBSTRING(s FROM 2 FOR 3)- MySQL
SUBSTRING(s, 2, 3)- SQL Server
SUBSTRING(s, 2, 3)- SQLite
substr(s, 2, 3)
Positions are 1-indexed everywhere.
Split a delimited string
Full page- PostgreSQL
SPLIT_PART(s, ',', 2)- MySQL
SUBSTRING_INDEX(s, ',', 2)- SQL Server
SELECT value FROM STRING_SPLIT(s, ',')- SQLite
no built-in — instr()/substr() by hand
No standard form at all, and SQLite has nothing built in — see the full page for a workaround.
NULL-safe equality
Full page- PostgreSQL
a IS NOT DISTINCT FROM b- MySQL
a <=> b- SQL Server
(a = b) OR (a IS NULL AND b IS NULL)- SQLite
a IS NOT DISTINCT FROM b
Standard = treats two NULLs as unequal (unknown, actually). These operators say "yes, both NULL counts as equal."
Read a query plan
Full page- PostgreSQL
EXPLAIN [ANALYZE] SELECT ...- MySQL
EXPLAIN [ANALYZE] SELECT ... -- 8.0.18+ for ANALYZE- SQL Server
SET STATISTICS PROFILE ON- SQLite
EXPLAIN QUERY PLAN SELECT ...
"ANALYZE" variants actually execute the query — not a dry run on a mutating statement.
Start a transaction
Full page- PostgreSQL
BEGIN;- MySQL
START TRANSACTION;- SQL Server
BEGIN TRANSACTION;- SQLite
BEGIN;
COMMIT / ROLLBACK end it the same way on all four.
Quoting an identifier with special characters
- PostgreSQL
"Order Date"- MySQL
`Order Date`- SQL Server
[Order Date]- SQLite
"Order Date" -- also accepts [ ] and `
Mixing these up is one of the most common cross-engine porting errors — a query with "col" fails outright on MySQL.
Recursive CTE
Full page- PostgreSQL
WITH RECURSIVE r AS (...) SELECT ...- MySQL
WITH RECURSIVE r AS (...) SELECT ... -- 8.0.1+- SQL Server
WITH r AS (...) SELECT ... -- no RECURSIVE keyword needed- SQLite
WITH RECURSIVE r AS (...) SELECT ...
SQL Server infers recursion from the CTE referencing itself — it doesn’t use the RECURSIVE keyword at all.
Prefer to browse by topic instead? Open the cheat sheet or the full reference.