Skip to content
Reference

PostgreSQL vs MySQL vs SQL Server vs SQLite: SQL Syntax Differences

11 min readUpdated September 24, 2026Every example verified
On this page 15 sections ▾
  1. Why dialects differ at all
  2. Limiting rows: LIMIT, TOP and FETCH FIRST
  3. Quoting: identifiers versus strings
  4. String concatenation
  5. Case sensitivity when comparing text
  6. Dates and times
  7. Booleans
  8. Integer division
  9. Auto-increment primary keys
  10. Upsert: insert or update in one statement
  11. Window functions
  12. NULL-safe comparison
  13. Which one should you learn first?
  14. Frequently asked questions
  15. 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.

Return the 5 most expensive products
DatabaseSyntax
PostgreSQLORDER BY unit_price DESC LIMIT 5 (also: FETCH FIRST 5 ROWS ONLY)
MySQLORDER BY unit_price DESC LIMIT 5
SQLiteORDER BY unit_price DESC LIMIT 5
SQL ServerSELECT 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.

Quoting a column called "order"
DatabaseIdentifier quotesNote
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.

Build a full name
DatabaseSyntax
PostgreSQLfirst_name || ' ' || last_name or CONCAT(first_name, ' ', last_name)
MySQLCONCAT(first_name, ' ', last_name) (|| means OR unless PIPES_AS_CONCAT is set)
SQL Serverfirst_name + ' ' + last_name or CONCAT(first_name, ' ', last_name)
SQLitefirst_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.

Is text comparison case-sensitive by default?
Database=LIKETo ignore case
PostgreSQLcase-sensitivecase-sensitiveILIKE 'peru' or LOWER(country) = 'peru'
MySQLfollows the collation, usually a case-insensitive *_ci onesame as =Already insensitive; use a *_cs or *_bin collation to force sensitivity
SQL Serverfollows the collation, usually a case-insensitive CI onesame as =Already insensitive; COLLATE Latin1_General_CS_AS to force sensitivity
SQLite (the playground)case-sensitiveignores case for the letters A–Z onlyLOWER(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_perulike_perulower_peru
022

● 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.

Today's timestamp
DatabaseFunction
PostgreSQLNOW() or CURRENT_TIMESTAMP
MySQLNOW() or CURRENT_TIMESTAMP
SQL ServerGETDATE() or SYSDATETIME()
SQLitedatetime('now') or CURRENT_TIMESTAMP (both in UTC)
Days between two dates (later minus earlier)
DatabaseExpression
PostgreSQLlater_date - earlier_date (dates subtract to an integer number of days)
MySQLDATEDIFF(later_date, earlier_date)
SQL ServerDATEDIFF(day, earlier_date, later_date) (note the reversed argument order)
SQLitejulianday(later_date) - julianday(earlier_date)
Truncate a date to the first of its month
DatabaseExpression
PostgreSQLDATE_TRUNC('month', order_date)
MySQLDATE_FORMAT(order_date, '%Y-%m-01')
SQL ServerDATEFROMPARTS(YEAR(order_date), MONTH(order_date), 1) (2022+: DATETRUNC(month, order_date))
SQLitestrftime('%Y-%m-01', order_date)
Add 30 days
DatabaseExpression
PostgreSQLorder_date + INTERVAL '30 days'
MySQLDATE_ADD(order_date, INTERVAL 30 DAY)
SQL ServerDATEADD(day, 30, order_date)
SQLitedate(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

How true and false are stored
DatabaseTypeLiterals
PostgreSQLBOOLEAN (a real type)TRUE / FALSE; WHERE active works on its own
MySQLBOOLEAN is an alias for TINYINT(1)TRUE / FALSE are 1 / 0; WHERE active works (non-zero is true)
SQL ServerBIT1 / 0; WHERE active = 1 (a bare column is not allowed in WHERE)
SQLiteINTEGER1 / 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.

What 7 / 2 returns
Database7 / 2To get 3.5
PostgreSQL37 / 2.0 or CAST(7 AS NUMERIC) / 2
MySQL3.5000Already a decimal: / never truncates; DIV is MySQL’s whole-number division
SQL Server37 / 2.0 or CAST(7 AS DECIMAL(10,2)) / 2
SQLite (the playground)37 / 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_wrongactive_share
00.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

A self-numbering id column
DatabaseDefinition
PostgreSQLid INT GENERATED ALWAYS AS IDENTITY (older: id SERIAL)
MySQLid INT AUTO_INCREMENT PRIMARY KEY
SQL Serverid INT IDENTITY(1,1) PRIMARY KEY
SQLiteid 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.

When window functions became available
DatabaseSince
PostgreSQL8.4 (2009) — full support for years
MySQL8.0 (2018) — MySQL 5.7 has none; MariaDB 10.2+
SQL Server2005 for ranking functions; 2012 for frames (ROWS BETWEEN, LAG/LEAD)
SQLite3.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:

Treat two NULLs as equal
DatabaseOperator
PostgreSQLa IS NOT DISTINCT FROM b
MySQLa <=> b
SQL Servera IS NOT DISTINCT FROM b (2022+; otherwise COALESCE tricks)
SQLitea 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.

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.