PostgreSQL vs MySQL vs SQL Server vs SQLite: SQL Syntax Differences
On this page 15 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
- Integer division
- 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, give or take a few behaviours such as integer division and where NULLs sort. 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
The playground on this site runs SQLite in your browser, so in every table below the SQLite row is the one that works there. Blocks marked runs in the playground execute as written. Blocks marked not in SQLite are for your real PostgreSQL, MySQL or SQL Server — SQLite would reject 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. In SQL Server 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. The playground, being SQLite, understands only the LIMIT form.
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. SQLite, and so this playground, falls back to reading "shipped" as text when no column has that name, so the query runs and the playground shows a warning, but the habit breaks the moment you move. 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 or CONCAT(first_name, ' ', last_name) (3.44+) |
SELECT first_name || ' ' || last_name AS full_name, salary
FROM Employees
ORDER BY salary DESC
LIMIT 3;
CONCAT() is the most portable spelling today — PostgreSQL 9.1+, MySQL, SQL Server 2012+ and SQLite 3.44+ all have it — but what it does with a NULL is not portable. PostgreSQL, SQL Server and SQLite skip NULL arguments; MySQL’s CONCAT() returns NULL if any argument is NULL, and only its CONCAT_WS() skips them. The operators are stricter still: || in PostgreSQL and SQLite, and + in SQL Server by default, return NULL if any part is NULL, while Oracle’s || treats NULL as empty text. In the playground, 'a' || NULL is NULL and CONCAT('a', NULL) is 'a'. When a part can be missing, wrap it in COALESCE(part, '') and every database agrees; the string functions guide runs all three side by side.
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 | = | LIKE | To ignore case |
|---|---|---|---|
| PostgreSQL | case-sensitive | case-sensitive | ILIKE 'peru' or LOWER(country) = 'peru' |
| MySQL | follows the collation, usually a case-insensitive *_ci one | same as = | Already insensitive; use a *_cs or *_bin collation to force sensitivity |
| SQL Server | follows the collation, usually a case-insensitive CI one | same as = | Already insensitive; COLLATE Latin1_General_CS_AS to force sensitivity |
| SQLite (the playground) | case-sensitive | ignores case for the letters A–Z only | LOWER(country) = 'peru' or country = 'peru' COLLATE NOCASE |
The SQLite row is easy to see in the playground: typed in lower case, = misses both Peruvian customers, while LIKE and LOWER() find them.
SELECT
(SELECT COUNT(*) FROM Customers WHERE country = 'peru') AS equals_peru,
(SELECT COUNT(*) FROM Customers WHERE country LIKE 'peru') AS like_peru,
(SELECT COUNT(*) FROM Customers WHERE LOWER(country) = 'peru') AS lower_peru;
| equals_peru | like_peru | lower_peru |
|---|---|---|
| 0 | 2 | 2 |
● 1 row · produced by running this query on the sample database
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') or CURRENT_TIMESTAMP (both in UTC) |
| 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.
In the playground the SQLite row of each table is the one that runs: julianday() for day counts, date(d, '+30 days') for shifting, strftime() for month starts. SQLite has no YEAR(), MONTH() or DAY(), so the playground adds those three as helpers; DATEDIFF, DATEADD and NOW() are not available. SQL Date Functions has runnable examples of each 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; WHERE active works (non-zero is true) |
| 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; WHERE active works (non-zero is true) |
With a 1/0 integer column like the sample database’s active, WHERE active = 1 runs on all four. A bare WHERE active works on that column in MySQL and SQLite; PostgreSQL accepts a bare column only when it is a real BOOLEAN (and then rejects = 1), and SQL Server never does. Our sample database uses 1 and 0 because that is the form every database can read.
Integer division
Divide one whole number by another and three of the four keep the answer whole: 7 / 2 is 3, not 3.5. Nothing errors, which makes it the quietest bug on this page: a share worked out from two counts loses its fraction and can come back as 0.
| Database | 7 / 2 | To get 3.5 |
|---|---|---|
| PostgreSQL | 3 | 7 / 2.0 or CAST(7 AS NUMERIC) / 2 |
| MySQL | 3.5000 | Already a decimal: / never truncates; DIV is MySQL’s whole-number division |
| SQL Server | 3 | 7 / 2.0 or CAST(7 AS DECIMAL(10,2)) / 2 |
| SQLite (the playground) | 3 | 7 / 2.0 or 7 * 1.0 / 2 |
SELECT SUM(active) / COUNT(*) AS active_share_wrong,
ROUND(SUM(active) * 1.0 / COUNT(*), 3) AS active_share
FROM Employees;
| active_share_wrong | active_share |
|---|---|
| 0 | 0.917 |
● 1 row · produced by running this query on the sample database
Eleven of the twelve employees are active, yet the first column says 0: both sides are integers, so SQLite divides 11 by 12 and drops the fraction. Multiplying by 1.0 first turns one side into a decimal, and 0.917 comes back. The money columns in the sample database are stored as REAL, so salary / 12 already keeps its fraction; the trap is counts and 1/0 flags.
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);
The first form runs in the playground as written: product 201 already exists, so SQLite updates its price and stock instead of inserting a second row.
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. SQLite, and so the playground, supports window functions including frames, LAG and LEAD.
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 (recent versions also accept IS NOT DISTINCT FROM) |
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 row of each table on 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. It is also what this site’s playground runs.
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?
Mostly. SELECT, WHERE, JOIN, GROUP BY, ORDER BY and subqueries mean the same thing in PostgreSQL, MySQL, SQL Server and SQLite, but a few behaviours differ even in that core: where NULLs sort, whether 7 / 2 is 3 or 3.5, whether FULL OUTER JOIN exists (MySQL has none), and how strictly GROUP BY is enforced. Beyond the core, 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?
SQLite. The playground runs SQLite 3.49 in your browser, compiled to WebAssembly, so the SQLite row of every table on this page is the one that applies there: LIMIT, || and CONCAT(), and SQLite's own date functions such as date(), strftime() and julianday(). It also adds YEAR(), MONTH() and DAY(), which SQLite lacks, so MySQL-style date examples run. Syntax from other databases, such as TOP, DATEDIFF or ILIKE, is 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: Core Syntax, Joins and Functions 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.