Tags: web-dev analytics concept
SQL Dialects
Date: 2026-09-27
The SQL standard covers the shape; every engine differs on the details you’d assume were standard — division, quoting, string matching, dates, NULL ordering. The dangerous differences are the ones that don’t error: the same query runs everywhere and returns a different number.
A SQL dialect is one engine’s version of the language — the standard, minus what it doesn’t implement, plus its own extensions and defaults. The four met most in web and analytics work: Postgres and MySQL (application databases), SQLite (embedded, local, tests) and BigQuery (warehouse; its dialect is called GoogleSQL).
The silent ones — same query, different answer
Integer division
SELECT 5 / 2
Postgres 2 integer ÷ integer = integer, truncated
SQLite 2
MySQL 2.5000
BigQuery 2.5 / always returns FLOAT64; integer division is DIV(5, 2)
This is the one that corrupts conversion rates. COUNT(purchases) / COUNT(sessions) is 0 in Postgres for any rate under 100%. Multiply by 1.0 or cast one side to a decimal first.
String comparison and case
SELECT 'Email' = 'email'
MySQL true default collations are case-insensitive
Postgres false
SQLite false but LIKE is case-insensitive for ASCII
BigQuery false
A GROUP BY channel that merges Email and email in MySQL keeps them as two rows in BigQuery. The same data, migrated, gives different channel totals — UTM Governance. Postgres has ILIKE for case-insensitive matching; elsewhere, LOWER() both sides.
NULL sort order
ORDER BY x ASC where do NULLs go? NULLS FIRST / LAST available?
Postgres last (treated as largest) yes
MySQL first (treated as smallest) no — ORDER BY x IS NULL, x
SQLite first yes, since 3.30
BigQuery first yes
Matters for “latest value” queries: ORDER BY last_seen DESC LIMIT 1 returns a NULL row first in Postgres.
Loose GROUP BY
- Postgres and BigQuery reject a selected column that is neither grouped nor aggregated
- MySQL rejects it by default (
ONLY_FULL_GROUP_BY), but many older installs switched that off - SQLite accepts it and returns a value from some row in the group — Aggregation and Grouping
The loud ones — errors, at least
Quoting
identifiers string literals
Postgres "double" 'single' only
SQLite "double" (backticks 'single'
accepted)
MySQL `backtick` 'single' or "double" (unless ANSI_QUOTES)
BigQuery `backtick` 'single' or "double"
WHERE status = "paid" works in MySQL and BigQuery and fails in Postgres with column “paid” does not exist — because double quotes name a column there.
Concatenation. 'a' || 'b' is concatenation in Postgres, SQLite and BigQuery; in MySQL || is logical OR unless the PIPES_AS_CONCAT mode is on. CONCAT() works in all four.
Dates. The area with the least agreement.
truncate to month Postgres date_trunc('month', ts)
BigQuery DATE_TRUNC(d, MONTH) / TIMESTAMP_TRUNC(ts, MONTH)
MySQL DATE_FORMAT(ts, '%Y-%m-01')
SQLite strftime('%Y-%m-01', ts)
difference Postgres ts2 - ts1 → an interval
BigQuery TIMESTAMP_DIFF(ts2, ts1, MINUTE)
MySQL TIMESTAMPDIFF(MINUTE, ts1, ts2) ← argument order reversed
SQLite (julianday(ts2) - julianday(ts1)) * 1440
date series Postgres generate_series(...)
BigQuery GENERATE_DATE_ARRAY(...) + UNNEST
MySQL, SQLite recursive CTE
The recursive version is in Common Table Expressions.
Timestamps and time zones — each engine has a type that stores an absolute instant and a type that stores wall-clock time with no zone, under different names: Postgres timestamptz vs timestamp; BigQuery TIMESTAMP vs DATETIME; MySQL TIMESTAMP vs DATETIME. SQLite has no date type at all — text, real or integer, by convention. Mixing the two kinds is how a day’s orders end up split across two dates — Timezones and Date Boundaries.
Feature gaps
Postgres MySQL SQLite BigQuery
window functions yes 8.0+ 3.25+ yes
FULL OUTER JOIN yes no 3.39+ yes
QUALIFY no no no yes
upsert ON CONFLICT ON DUPLICATE ON CONFLICT MERGE
KEY UPDATE
arrays / nested rows arrays JSON only JSON only ARRAY, STRUCT, UNNEST
QUALIFYfilters on a window function’s result without a wrapping subquery — also in Snowflake, DuckDB and Databricks. Not in the SQL standard. [CHECK: a Postgres patch was proposed in 2025; check whether any release has shipped it] — Window Functions- MySQL’s missing
FULL OUTER JOINis emulated as aLEFT JOINUNIONaRIGHT JOIN— Joins - BigQuery’s nested and repeated fields — an event row holding an array of item structs, as the GA4 (Google Analytics 4) export does — need
UNNESTto flatten, and an unguardedUNNESTin a join is a fan-out waiting to happen
[CHECK: the version thresholds above are from memory and documentation searches as of 2026-09; confirm against the engine version actually deployed.]
Working across them
- Test against the engine production uses. SQLite in tests and Postgres in production is how integer division and case-sensitivity bugs pass CI — Test Data · Integration Testing
- ORMs and query builders paper over the loud differences, not the silent ones. They translate quoting and upserts; they don’t change what
5 / 2returns - SQLGlot (a Python library) transpiles between dialects, and is useful for migrations — useful, not authoritative on semantics
- When migrating analytics from an app database to a warehouse, reconcile totals by day and by channel before switching dashboards over. Case sensitivity and time zones produce most of the difference — Replatforming