PostgreSQL vs MySQL vs SQL Server vs SQLite: SQL Syntax Differences
On this page 14 sections ▾
- Why dialects differ at all
- Limiting rows: LIMIT, TOP and FETCH FIRST
- Quoting: identifiers versus strings
- String concatenation
- Case sensitivity when comparing text
- Dates and times
- Booleans
- Auto-increment primary keys
- Upsert: insert or update in one statement
- Window functions
- NULL-safe comparison
- Which one should you learn first?
- Frequently asked questions
- Related reading
Standard SQL exists on paper. In practice every database speaks its own dialect, and most of the differences hide in exactly the places a tutorial skips: how you limit rows, how you glue strings together, how you ask for “today”. This page puts the four databases you are most likely to meet side by side, so a query that works in one can be translated to another in seconds. Nothing here is theoretical — each row is a mistake someone makes in their first week on a new system.
Why dialects differ at all
The SQL standard defines the core — SELECT, JOIN, GROUP BY, subqueries — and every major database implements that core faithfully. What the standard left unspecified for years (row limiting, string functions, date arithmetic, identity columns) each vendor filled in their own way, and those choices are now decades old and impossible to change without breaking customers. So the rule of thumb: the logic of a query travels; the trimmings do not.
How to read the examples
Blocks marked runs in the playground execute on this site’s in-browser engine as written. Blocks marked needs a full database are for your real PostgreSQL, MySQL, SQL Server or SQLite — the playground would not recognise that vendor-specific syntax, and pretending otherwise would teach you the wrong thing.
Limiting rows: LIMIT, TOP and FETCH FIRST
The single most common portability error. Three of the four use LIMIT; SQL Server does not.
| Database | Syntax |
|---|---|
| PostgreSQL | ORDER BY unit_price DESC LIMIT 5 (also: FETCH FIRST 5 ROWS ONLY) |
| MySQL | ORDER BY unit_price DESC LIMIT 5 |
| SQLite | ORDER BY unit_price DESC LIMIT 5 |
| SQL Server | SELECT TOP 5 ... or ORDER BY unit_price DESC OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY |
SELECT name, unit_price
FROM Products
ORDER BY unit_price DESC
LIMIT 5;
SQL Server’s OFFSET ... FETCH form is the standard one and also works in PostgreSQL. It requires an ORDER BY, which is a good habit anyway: a limit without an order is a random sample.
-- SQL Server (and standard SQL)
SELECT name, unit_price
FROM Products
ORDER BY unit_price DESC
OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY;
Pagination follows the same split: LIMIT 10 OFFSET 20 in PostgreSQL, MySQL and SQLite; OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY in SQL Server.
Quoting: identifiers versus strings
Every database uses single quotes for string values. They disagree about how to quote a table or column name that clashes with a keyword or contains a space.
| Database | Identifier quotes | Note |
|---|---|---|
| PostgreSQL | "order" | Standard. Double-quoted names are case-sensitive; unquoted names fold to lower case. |
| MySQL | `order` | Backticks. Double quotes mean a string unless ANSI_QUOTES is on. |
| SQL Server | [order] | Square brackets. Double quotes also work when QUOTED_IDENTIFIER is on (the default). |
| SQLite | "order" | Standard double quotes; also accepts backticks and brackets for compatibility. |
The trap
In MySQL, WHERE status = "shipped" is a string comparison and works. Paste the same line into PostgreSQL and it looks for a column named shipped. Use single quotes for values everywhere and you never hit this.
String concatenation
Joining first_name and last_name into one column is where the operators diverge most visibly.
| Database | Syntax |
|---|---|
| PostgreSQL | first_name || ' ' || last_name or CONCAT(first_name, ' ', last_name) |
| MySQL | CONCAT(first_name, ' ', last_name) (|| means OR unless PIPES_AS_CONCAT is set) |
| SQL Server | first_name + ' ' + last_name or CONCAT(first_name, ' ', last_name) |
| SQLite | first_name || ' ' || last_name |
SELECT first_name || ' ' || last_name AS full_name, salary
FROM Employees
ORDER BY salary DESC
LIMIT 3;
CONCAT() is the most portable choice today — PostgreSQL 9.1+, MySQL, SQL Server 2012+ and SQLite 3.44+ all have it — and it has a second advantage: it treats NULL as an empty string, whereas || and + return NULL if any part is NULL.
Case sensitivity when comparing text
Does WHERE country = 'peru' match 'Peru'? It depends on the database, and the answer decides whether your filter silently drops rows.
| Database | Default | Case-insensitive match |
|---|---|---|
| PostgreSQL | Yes | ILIKE 'peru' or LOWER(country) = 'peru' |
| MySQL | No (depends on collation, usually *_ci) | Already insensitive; use a *_cs or *_bin collation to force sensitivity |
| SQL Server | No (depends on collation, usually CI) | Already insensitive; COLLATE Latin1_General_CS_AS to force sensitivity |
| SQLite | Yes for =, no for LIKE (ASCII only) | LOWER(country) = 'peru' |
The portable habit: compare LOWER(column) = LOWER(value) whenever user-typed input is involved. It works everywhere and makes the intent visible in the query.
Dates and times
Date handling is the biggest block of differences, because each vendor built its own function library. The good news is that the questions you ask are always the same four.
| Database | Function |
|---|---|
| PostgreSQL | NOW() or CURRENT_TIMESTAMP |
| MySQL | NOW() or CURRENT_TIMESTAMP |
| SQL Server | GETDATE() or SYSDATETIME() |
| SQLite | datetime('now') |
| Database | Expression |
|---|---|
| PostgreSQL | later_date - earlier_date (dates subtract to an integer number of days) |
| MySQL | DATEDIFF(later_date, earlier_date) |
| SQL Server | DATEDIFF(day, earlier_date, later_date) (note the reversed argument order) |
| SQLite | julianday(later_date) - julianday(earlier_date) |
| Database | Expression |
|---|---|
| PostgreSQL | DATE_TRUNC('month', order_date) |
| MySQL | DATE_FORMAT(order_date, '%Y-%m-01') |
| SQL Server | DATEFROMPARTS(YEAR(order_date), MONTH(order_date), 1) (2022+: DATETRUNC(month, order_date)) |
| SQLite | strftime('%Y-%m-01', order_date) |
| Database | Expression |
|---|---|
| PostgreSQL | order_date + INTERVAL '30 days' |
| MySQL | DATE_ADD(order_date, INTERVAL 30 DAY) |
| SQL Server | DATEADD(day, 30, order_date) |
| SQLite | date(order_date, '+30 days') |
The argument-order trap
MySQL’s DATEDIFF(a, b) computes a − b. SQL Server’s DATEDIFF(day, a, b) computes b − a. Same name, opposite sign. If a “days since signup” report is coming out negative, this is why.
The playground on this site accepts YEAR(), MONTH() and SQL Server-style DATEDIFF(day, a, b) — see SQL Date Functions for runnable examples of every date pattern.
Booleans
| Database | Type | Literals |
|---|---|---|
| PostgreSQL | BOOLEAN (a real type) | TRUE / FALSE; WHERE active works on its own |
| MySQL | BOOLEAN is an alias for TINYINT(1) | TRUE / FALSE are 1 / 0 |
| SQL Server | BIT | 1 / 0; WHERE active = 1 (a bare column is not allowed in WHERE) |
| SQLite | INTEGER | 1 / 0; TRUE / FALSE keywords since 3.23 |
Write WHERE active = 1 and it runs on all four. Write WHERE active and only PostgreSQL (and SQLite, loosely) accept it. Our sample database uses 1 and 0 for exactly this reason.
Auto-increment primary keys
| Database | Definition |
|---|---|
| PostgreSQL | id INT GENERATED ALWAYS AS IDENTITY (older: id SERIAL) |
| MySQL | id INT AUTO_INCREMENT PRIMARY KEY |
| SQL Server | id INT IDENTITY(1,1) PRIMARY KEY |
| SQLite | id INTEGER PRIMARY KEY (AUTOINCREMENT is optional and rarely needed) |
Getting the id you just inserted differs too: RETURNING id (PostgreSQL, SQLite 3.35+), LAST_INSERT_ID() (MySQL), SCOPE_IDENTITY() (SQL Server).
Upsert: insert or update in one statement
“Insert this row, or update it if the key already exists” is one of the least portable statements in SQL.
-- PostgreSQL and SQLite 3.24+
INSERT INTO Products (product_id, name, category, unit_price, in_stock)
VALUES (201, 'Aurora Laptop 14"', 'Hardware', 1249, 40)
ON CONFLICT (product_id) DO UPDATE
SET unit_price = EXCLUDED.unit_price, in_stock = EXCLUDED.in_stock;
-- MySQL
INSERT INTO Products (product_id, name, category, unit_price, in_stock)
VALUES (201, 'Aurora Laptop 14"', 'Hardware', 1249, 40)
ON DUPLICATE KEY UPDATE
unit_price = VALUES(unit_price), in_stock = VALUES(in_stock);
-- SQL Server
MERGE Products AS target
USING (SELECT 201 AS product_id, 1249 AS unit_price, 40 AS in_stock) AS src
ON target.product_id = src.product_id
WHEN MATCHED THEN UPDATE SET unit_price = src.unit_price, in_stock = src.in_stock
WHEN NOT MATCHED THEN INSERT (product_id, name, category, unit_price, in_stock)
VALUES (201, 'Aurora Laptop 14"', 'Hardware', 1249, 40);
Window functions
ROW_NUMBER, RANK and friends are standard SQL, but support arrived at very different times, and older servers are still common in companies.
| Database | Since |
|---|---|
| PostgreSQL | 8.4 (2009) — full support for years |
| MySQL | 8.0 (2018) — MySQL 5.7 has none; MariaDB 10.2+ |
| SQL Server | 2005 for ranking functions; 2012 for frames (ROWS BETWEEN, LAG/LEAD) |
| SQLite | 3.25 (2018) |
If you are on MySQL 5.7 and need a rank, the classic workaround is a correlated subquery counting rows with a higher value. The window functions guide covers the modern syntax and marks which examples run in this site’s engine.
NULL-safe comparison
NULL = NULL is not true in any database — it is unknown. When you genuinely want “both NULL counts as equal”, each vendor has its own spelling:
| Database | Operator |
|---|---|
| PostgreSQL | a IS NOT DISTINCT FROM b |
| MySQL | a <=> b |
| SQL Server | a IS NOT DISTINCT FROM b (2022+; otherwise COALESCE tricks) |
| SQLite | a IS b |
Which one should you learn first?
It matters less than beginners fear. Ninety percent of what you learn transfers unchanged; this page is the other ten percent. Practical advice:
- Learning on your own: PostgreSQL. It follows the standard most closely, so habits formed there port cleanly, and it is free everywhere.
- Joining a team: whatever the team runs. Ask on day one, then read the matching column of this page.
- Data analyst roles: expect PostgreSQL, MySQL or a warehouse (BigQuery, Snowflake, Redshift) — all close to PostgreSQL in feel.
- Enterprise / .NET shops: SQL Server. Learn TOP, GETDATE and bracket quoting early.
- Mobile or embedded: SQLite, which is deliberately small but surprisingly standard.
Everything on this site is written to stay dialect-neutral where possible, and to say so plainly where it is not. Start with the beginner’s guide and the syntax you learn will work almost everywhere.
Frequently asked questions
Is SQL the same in every database?
The core is the same: SELECT, WHERE, JOIN, GROUP BY, ORDER BY and subqueries work identically in PostgreSQL, MySQL, SQL Server and SQLite. The differences live in row limiting, string and date functions, quoting rules, identity columns and upserts. Learn the core once, then keep a translation table like this one for the rest.
Which SQL dialect is closest to the standard?
PostgreSQL follows the ANSI/ISO standard most closely, which is why habits learned there transfer well. SQL Server is close but uses TOP and a different function library. MySQL and SQLite are pragmatic and permissive, accepting several spellings for the same thing.
Why does LIMIT not work in SQL Server?
SQL Server never adopted the LIMIT keyword. Use SELECT TOP n for a simple cap, or the standard OFFSET n ROWS FETCH NEXT m ROWS ONLY form after an ORDER BY, which also works in PostgreSQL.
Do SQL interview questions depend on the dialect?
Rarely. Interviewers ask about joins, aggregation, subqueries, window functions and NULL handling, all of which are standard. Most will accept any dialect as long as you say which one you are writing. If you are unsure, write standard SQL and mention the vendor-specific alternative.
Which dialect does the playground on this site use?
The in-browser engine (AlaSQL) accepts LIMIT, both || and CONCAT() for strings, and YEAR(), MONTH() and DATEDIFF(day, a, b) for dates, so it behaves like a mix of MySQL and SQL Server for the functions covered here. Examples that need vendor-specific syntax are marked as needing a full database rather than presented as runnable.
Related reading
- SQL ORDER BY and LIMIT: Sorting, Top-N and Pagination
- SQL Commands: DDL, DML, DCL and TCL Explained
- SQL Date Functions: YEAR, MONTH, DATEDIFF & Ranges
- SQL String Functions: UPPER, SUBSTRING, TRIM & More
- SQL Window Functions: ROW_NUMBER, RANK & PARTITION BY
- SQL Cheat Sheet: Every Clause and Function on One Page
About the author
SQL Practice
SQL Practice publishes free SQL tutorials, an in-browser playground and 42 practice exercises, built so you can run what you read. Every runnable example in our tutorials is executed against the site’s sample database before it is published. Read how we keep tutorials accurate, or report a mistake.