SQL Dialects Compared: Where MySQL, PostgreSQL, SQL Server and SQLite Disagree
SQL is a standard the way English is a standard: everyone agrees on the core and then diverges wherever it matters. A query that runs on MySQL can fail on PostgreSQL over a double quote, on SQL Server over a LIMIT, and on SQLite over a date. This guide is the map of those divergences — the ones that actually stop queries — and an argument that consistent formatting is a review tool, not decoration.
The short version
- Double quotes are for identifiers in standard SQL and PostgreSQL, but for strings in MySQL by default. That single difference breaks more ported queries than anything else.
- Row limiting is
LIMITin MySQL, PostgreSQL and SQLite,TOPorOFFSET … FETCHin SQL Server, andFETCH FIRSTin Oracle and DB2. - String concatenation is
||in the standard and most engines,+in SQL Server, andCONCAT()everywhere if you want to be portable. - Date arithmetic and formatting functions differ in every engine. Wrap them, or push them to the application.
- Format SQL the same way every time so that a diff shows a logic change, not a whitespace change.
Quoting: the one that breaks silently
Standard SQL uses single quotes for string literals and double quotes for identifiers that need protecting — a column named order, a table with a space in its name, a mixed-case name in PostgreSQL. MySQL, by default, treats double quotes as a second string delimiter and uses backticks for identifiers. SQL Server uses square brackets for identifiers and accepts double quotes only when QUOTED_IDENTIFIER is on, which it is in most modern connections but not all.
| Engine | String literal | Quoted identifier | Case handling for unquoted names |
|---|---|---|---|
| Standard / PostgreSQL | 'text' | "Order Id" | Folded to lower case; a quoted mixed-case name must always be quoted |
| MySQL / MariaDB | 'text' or "text" | `Order Id` | Tables case-sensitive on Linux by default, columns not |
| SQL Server | 'text' | [Order Id] or "Order Id" | Depends on database collation; usually case-insensitive |
| SQLite | 'text' | "Order Id", also accepts […] and backticks | Case-insensitive |
| BigQuery | 'text' or "text" | `project.dataset.table` | Case-insensitive for datasets, sensitive for columns |
The trap is that MySQL's double-quoted "string" is accepted by PostgreSQL — as a column reference. WHERE status = "active" runs in MySQL and fails in PostgreSQL with "column active does not exist", which is at least loud. The reverse is quieter: a PostgreSQL query with "Order Id" as an identifier runs in MySQL as a string comparison and returns nothing, with no error. Setting MySQL's ANSI_QUOTES mode makes it behave like the standard, and single-quoting every string literal makes the query portable in the first place.
Limiting rows
-- MySQL, PostgreSQL, SQLite
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 20;
-- SQL Server (2012+); ORDER BY is mandatory
SELECT * FROM orders ORDER BY created_at DESC OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
SELECT TOP 10 * FROM orders ORDER BY created_at DESC; -- no offset
-- Oracle 12c+, DB2, and the SQL:2008 standard
SELECT * FROM orders ORDER BY created_at DESC OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY;
Two things bite here beyond the syntax. Without an ORDER BY, a limited result is an arbitrary subset that can change between runs, and SQL Server enforces this by refusing OFFSET without one. And OFFSET-based pagination reads and discards every skipped row, so page 1,000 of a large table is slow on every engine; keyset pagination (WHERE created_at < :last_seen ORDER BY created_at DESC LIMIT 10) is the portable fix.
Strings, nulls and booleans
- Concatenation:
||in PostgreSQL, SQLite, Oracle and the standard;+in SQL Server (where||is a syntax error); MySQL treats||as logical OR unlessPIPES_AS_CONCATis set.CONCAT(a, b)works everywhere except older SQLite. - Null in concatenation:
'a' || NULLisNULLin PostgreSQL and Oracle;CONCAT('a', NULL)is'a'in MySQL and SQL Server butNULLin PostgreSQL. This inconsistency corrupts generated addresses and names across migrations. - Booleans: PostgreSQL has a real
BOOLEANwithTRUE/FALSE; MySQL'sBOOLEANisTINYINT(1); SQL Server has no boolean, onlyBIT, and cannot use a bit column directly inWHERE active— it needsWHERE active = 1. - Case-insensitive comparison: MySQL and SQL Server compare strings case-insensitively by default collation; PostgreSQL and SQLite are case-sensitive.
WHERE email = 'A@X.COM'matches in one and not the other. PostgreSQL'sILIKEorLOWER()on both sides is the portable form.
Format for the dialect you are actually running
Layerbit's SQL Formatter understands MySQL, MariaDB, PostgreSQL, SQL Server (T-SQL), SQLite, BigQuery, Snowflake and more, so it will not mangle [Order Id] in T-SQL or backticks in MySQL. Keyword case, indent size and style are configurable, and the query never leaves your browser.
Dates: assume nothing is portable
| Task | MySQL | PostgreSQL | SQL Server | SQLite |
|---|---|---|---|---|
| Current timestamp | NOW() | NOW(), CURRENT_TIMESTAMP | GETDATE(), SYSUTCDATETIME() | datetime('now') |
| Add 7 days | DATE_ADD(d, INTERVAL 7 DAY) | d + INTERVAL '7 days' | DATEADD(day, 7, d) | datetime(d, '+7 days') |
| Difference in days | DATEDIFF(a, b) | a::date - b::date | DATEDIFF(day, b, a) — argument order reversed | julianday(a) - julianday(b) |
| Format as text | DATE_FORMAT(d, '%Y-%m') | TO_CHAR(d, 'YYYY-MM') | FORMAT(d, 'yyyy-MM') | strftime('%Y-%m', d) |
| Truncate to month | DATE_FORMAT(d, '%Y-%m-01') | DATE_TRUNC('month', d) | DATETRUNC(month, d) (2022+) | date(d, 'start of month') |
| Epoch seconds | UNIX_TIMESTAMP(d) | EXTRACT(EPOCH FROM d) | DATEDIFF(second, '1970-01-01', d) | strftime('%s', d) |
Note the reversed argument order in SQL Server's DATEDIFF compared with MySQL's — a query that is copied across returns negated intervals and nothing warns you. Storage differs too: SQLite has no date type at all and stores whatever text or number you give it; MySQL's TIMESTAMP converts through the session time zone while DATETIME does not; PostgreSQL's timestamptz is the one that behaves the way people expect. For anything beyond trivial arithmetic, doing date work in the application language with a proper time-zone library is more portable than any SQL.
Upsert, returning, and other useful non-standards
"Insert or update if it exists" has four spellings: MySQL's INSERT … ON DUPLICATE KEY UPDATE, PostgreSQL's and SQLite's INSERT … ON CONFLICT (key) DO UPDATE SET …, and SQL Server's MERGE, which is more general and considerably easier to get wrong. Getting the inserted row back is RETURNING * in PostgreSQL and SQLite (3.35+), OUTPUT INSERTED.* in SQL Server, and LAST_INSERT_ID() as a separate call in MySQL. Window functions, common table expressions and FILTER clauses are now widely supported but arrived in MySQL only in version 8 and in SQLite only in 3.25, so a query using them fails on older installations with an unhelpful syntax error.
Why formatting is a review tool
None of the differences above are visible in a single-line query pasted from a log. Formatted, they are: every clause on its own line, joins aligned, subqueries indented, keywords in one case. The value is not aesthetic. A consistently formatted query means a code review shows the changed condition rather than a reflowed blob; a hand-written WHERE a = 1 OR b = 2 AND c = 3 reveals its precedence problem when the formatter indents AND under OR; and a JOIN without an ON that has silently become a cross join stands out on its own line.
Pick one style — keyword case, comma placement, indent width — and apply it mechanically in a pre-commit hook or on paste, so that the formatting never carries information and the diff only ever shows logic. A formatter that knows the dialect is a prerequisite, because one that treats [Order Id] as an array or backticks as garbage will break the query it is meant to tidy.
Write to the standard, test on the engine
Portable SQL is mostly discipline: single quotes for strings, quoted identifiers only when unavoidable, CONCAT over operators, explicit ORDER BY with every limit, booleans compared explicitly, dates handled in one place. None of it costs anything on the engine you use today, and all of it is what makes the migration or the second database possible later. And when a query does need an engine-specific feature, isolate it and comment it, so the next person knows that DATEDIFF's argument order was a decision and not a typo.
Related guides and tools
Frequently asked questions
Almost always a double-quoted string. MySQL accepts "active" as a string literal, but standard SQL and PostgreSQL treat double quotes as an identifier, so WHERE status = "active" looks for a column named active. Use single quotes for every string literal and the query becomes portable.
Either SELECT TOP n for a simple cap, or ORDER BY … OFFSET m ROWS FETCH NEXT n ROWS ONLY for limit with offset (SQL Server 2012 and later). The OFFSET form requires an ORDER BY clause. The same OFFSET/FETCH syntax is the SQL standard and also works in Oracle 12c+, DB2 and PostgreSQL.
Use CONCAT(a, b), which MySQL, PostgreSQL, SQL Server, Oracle and modern SQLite all support. The || operator is standard but is a syntax error in SQL Server and means logical OR in MySQL unless PIPES_AS_CONCAT is enabled; + works only in SQL Server. Be aware that CONCAT treats NULL as an empty string in MySQL and SQL Server but returns NULL in PostgreSQL.
It depends on the engine and collation. MySQL and SQL Server use case-insensitive collations by default, so 'abc' = 'ABC' is true. PostgreSQL and SQLite compare case-sensitively, so it is false. For a portable case-insensitive match, apply LOWER() to both sides or use PostgreSQL's ILIKE, and consider a case-insensitive collation or a functional index if the query is hot.
No. Whitespace and keyword case are irrelevant to the query planner, so a formatted query executes identically to the one-line original. The benefit is entirely for people: consistent formatting makes reviews show logic changes instead of reflowed text and exposes precedence and join mistakes that hide in a single line.