Cross-engine SQL guidance focused on where PostgreSQL, MySQL, and SQLite diverge, and where
correct-looking SQL fails silently. Skip anything you'd get from a general SQL reference; this
file exists for the parts that surprise a competent developer moving between engines.
Engine divergence traps
- SQLite type affinity is advisory, not enforced. A column declared
INTEGER still accepts
and stores a string: the declared type only selects an affinity, not a constraint. Code that
assumes SQLite rejects wrong-typed inserts the way PostgreSQL/MySQL do will silently store
garbage.
- Upsert syntax differs and is not interchangeable: PostgreSQL uses
INSERT ... ON CONFLICT (col) DO UPDATE SET x = EXCLUDED.x; MySQL uses
INSERT ... ON DUPLICATE KEY UPDATE x = VALUES(x). Porting one to the other by search-replace
fails to parse rather than misbehaving, but the EXCLUDED/VALUES() reference syntax is the
part people forget to translate.
FULL OUTER JOIN does not exist in MySQL. Rewrite as
LEFT JOIN ... UNION SELECT ... RIGHT JOIN ...; there is no direct substitute keyword.
- Foreign-key columns are auto-indexed by MySQL but not by PostgreSQL. A
REFERENCES clause
in PostgreSQL creates no index: joins and cascading deletes on that column do full scans until
you add CREATE INDEX explicitly. This is the single most common missing-index bug when a
schema is ported from MySQL to PostgreSQL.
EXPLAIN ANALYZE is MySQL 8.0.18+ only; earlier MySQL has plain EXPLAIN. SQLite's
equivalent is EXPLAIN QUERY PLAN, not EXPLAIN (bare EXPLAIN in SQLite dumps VDBE
bytecode, not a query plan: a frequent tool-invocation mistake).
Current engine versions
- PostgreSQL: 18, 17 (LTS), 16, 15, 14 supported; 13 and earlier reached EOL November 2025.
PG 18 adds async I/O (concurrent readahead/seqscan) and, notably, flips the default for
generated columns from
STORED to VIRTUAL when neither keyword is specified: a schema
written for PG ≤17 that omitted the keyword changes behavior on upgrade (computed-on-write
becomes computed-on-read). PG 17 adds SQL/JSON (JSON_TABLE, JSON_QUERY, JSON_VALUE,
JSON_EXISTS). PG 15 adds MERGE (full MATCHED/NOT MATCHED upsert). pg_stat_io (PG 16+)
gives per-backend-type I/O stats; incremental sort (PG 13+) exploits existing index order to
cut ORDER BY cost: look for "Incremental Sort" in the plan rather than assuming a full sort ran.
- MySQL: 8.4 LTS and 9.x Innovation releases.
- SQLite: 3.48+, type-affinity system (see trap above), single-file database.
Query patterns that hide bugs
- CTE materialization is a silent performance cliff, not just a readability choice.
PostgreSQL (12+) inlines a CTE referenced once but materializes it if referenced more than
once: the same query can regress hard after an innocuous second reference is added. Force the
behavior explicitly instead of relying on the reference-count heuristic:
WITH expensive_calc AS MATERIALIZED (
SELECT user_id, SUM(total) AS lifetime_value FROM orders GROUP BY user_id
)
SELECT * FROM expensive_calc WHERE lifetime_value > 1000;
LAST_VALUE() silently returns the wrong row without an explicit frame. The default window
frame ends at the current row, so LAST_VALUE(total) OVER (PARTITION BY user_id ORDER BY order_date) returns the current row's value, not the partition's actual last value. It needs
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to mean what the name implies.
ROWS, RANGE, and GROUPS framing are not interchangeable when ORDER BY has
duplicate values: ROWS counts physical rows, RANGE counts by value distance (e.g. a 7-day
window by timestamp), GROUPS (PG 11+) counts peer groups of tied rows. Picking the wrong one
silently double- or under-counts tied rows in a moving aggregate.
LATERAL lets a subquery in FROM reference preceding FROM items (PostgreSQL, MySQL
8.0.14+): the standard way to do top-N-per-group without a window-function-plus-filter:SELECT u.name, t.total FROM users u
LEFT JOIN LATERAL (
SELECT total FROM orders WHERE user_id = u.id ORDER BY total DESC LIMIT 3
) t ON true;
- Composite indexes obey a leftmost-prefix rule: an index on
(user_id, status) serves
WHERE user_id = ? and WHERE user_id = ? AND status = ?, but not WHERE status = ? alone.
Column order in the index, not in the query, decides usability.
OR across different columns defeats single-index use. WHERE email = ? OR name = ?
forces a scan even with indexes on both columns individually; rewrite as a UNION of two
single-column-filtered queries to let each half use its own index.
Schema design traps
- Polymorphic association (
commentable_type + commentable_id) cannot carry a foreign-key
constraint. Referential integrity is enforced only at the application layer: an orphaned or
mistyped commentable_id is not caught by the database, ever.
- Monetary values stored as
FLOAT/DOUBLE accumulate rounding error (0.1 + 0.2 != 0.3
in IEEE 754). Use DECIMAL/NUMERIC, or store integer minor units (cents).
GENERATED ... STORED vs VIRTUAL: PostgreSQL supported only STORED through v17 (18+
adds VIRTUAL, see version note above); MySQL and SQLite support both. VIRTUAL recomputes on
read and cannot be indexed the same way STORED can: check which one a query actually needs
before assuming "generated column" means "persisted and indexable."
Transactions and isolation
- Isolation-level defaults differ by engine and by spec-compliance: PostgreSQL defaults to
READ COMMITTED; MySQL defaults to REPEATABLE READ. Standard SQL REPEATABLE READ still
permits phantom reads, but PostgreSQL's REPEATABLE READ prevents phantoms anyway (it's
closer to snapshot isolation): code tested against Postgres at that isolation level can see new
phantom-read failures purely from being pointed at MySQL, with no code change.
FOR UPDATE SKIP LOCKED is the standard queue-worker pattern (skip rows another worker
already locked rather than blocking): SELECT * FROM jobs WHERE status='pending' FOR UPDATE SKIP LOCKED LIMIT 1. FOR UPDATE NOWAIT fails immediately instead of blocking: useful to
distinguish real contention from a hang.
- Advisory locks (PostgreSQL) have two different lifetimes:
pg_advisory_lock/
pg_advisory_unlock are session-scoped and outlive the transaction unless explicitly released;
pg_advisory_xact_lock auto-releases at commit. Using the session variant inside a connection
pool leaks locks across borrowed connections if the unlock call is ever skipped on an error path.
- Deadlock avoidance is ordering, not detection: acquire locks on multiple rows in a
consistent order (e.g., always lower ID first) across every code path that touches those rows
together; a
lock_timeout (PG) / innodb_lock_wait_timeout (MySQL) bounds the blast radius
when ordering discipline still gets violated somewhere.
Migrations
ALTER TABLE ... ADD COLUMN ... DEFAULT x is instant in PostgreSQL 11+ (no table rewrite,
metadata-only) but a full table rewrite on older PostgreSQL and on MySQL before 8.0's instant
DDL support: the same statement is a no-op-cost change on one version and a
locks-the-table-for-the-duration change on another.
CREATE INDEX CONCURRENTLY (PostgreSQL) can fail and leave an invalid index behind rather
than rolling back cleanly: it cannot run inside a transaction, so a failure mid-build does not
undo. Always check pg_index.indisvalid after a concurrent build and DROP INDEX + retry if
it's false; don't assume "the command returned" means "the index is usable."
- Renaming a column with zero downtime is expand-contract, not
RENAME COLUMN: add the new
column, backfill, deploy code that writes both, deploy code that reads only the new column,
then drop the old one. A bare RENAME COLUMN breaks every in-flight deployment still reading
the old name.
- Dropping a column safely requires the drain step: stop writing from the application first,
let old code paths fully roll off, only then
DROP COLUMN: dropping while old code still
references the column errors mid-request rather than failing at deploy time.
SQL injection surfaces beyond parameterized values
Parameterized queries close the value-injection path but leave two others open:
- Wildcard injection in
LIKE: if user input is interpolated as the pattern, a value of %
matches every row even through a parameterized query, because %/_ are pattern metacharacters
the parameterization doesn't escape. Escape them in application code before binding, or use an
explicit ESCAPE clause: ... LIKE '%' || $1 || '%' ESCAPE ''.
- Identifier injection: parameters bind values, not table/column names:
f"SELECT {column_name} FROM {table_name}" is unparameterizable by definition. The only safe pattern is
whitelisting identifiers against a known set before interpolating them, plus quoting
(quote_ident() in PostgreSQL) as defense in depth.
Tooling
pg_format --spaces 2 --keyword-case 2 input.sql: PostgreSQL-aware formatter (pgFormatter).
sqlfluff lint --dialect postgres query.sql / sqlfluff fix --dialect postgres query.sql:
multi-dialect linter/autofixer (postgres, mysql, sqlite, bigquery, ...).
pgTAP: TAP-protocol unit tests written in SQL (SELECT has_table(...), SELECT has_column(...)), runs inside PostgreSQL itself rather than an external test runner.
Style convention (Emacs sql-indent)
Right-align keywords so clause bodies start at a consistent column, and put commas at line start:
SELECT c.customer_id
, c.customer_name
, COUNT(o.order_id) AS order_count
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
WHERE c.status = 'active'
GROUP BY c.customer_id
, c.customer_name
HAVING COUNT(o.order_id) > 5
ORDER BY order_count DESC
LIMIT 100;
Uppercase keywords, snake_case identifiers, explicit AS on aliases, one column per line.
Related
- context7-usage: fetch current PostgreSQL/MySQL/SQLite docs
(library IDs:
/websites/postgresql, /websites/dev_mysql_doc_refman_9_4_en, /sqlite/sqlite)
instead of relying on training-data recall of version-specific behavior.
- serena-usage: navigate schema definitions and find existing query
patterns across a codebase before adding a new one.
- investigation-patterns: for tracing a query-performance
regression back to a plan change rather than guessing from the query text.
1---2name: sql-ecosystem3description: Use when working with SQL databases (SELECT/INSERT/UPDATE/DELETE, CREATE TABLE, JOIN, INDEX, EXPLAIN, transactions, or migrations) across PostgreSQL, MySQL, and SQLite.4---56Cross-engine SQL guidance focused on where PostgreSQL, MySQL, and SQLite diverge, and where7correct-looking SQL fails silently. Skip anything you'd get from a general SQL reference; this8file exists for the parts that surprise a competent developer moving between engines.910## Engine divergence traps1112- **SQLite type affinity is advisory, not enforced.** A column declared `INTEGER` still accepts13 and stores a string: the declared type only selects an affinity, not a constraint. Code that14 assumes SQLite rejects wrong-typed inserts the way PostgreSQL/MySQL do will silently store15 garbage.16- **Upsert syntax differs and is not interchangeable**: PostgreSQL uses17 `INSERT ... ON CONFLICT (col) DO UPDATE SET x = EXCLUDED.x`; MySQL uses18 `INSERT ... ON DUPLICATE KEY UPDATE x = VALUES(x)`. Porting one to the other by search-replace19 fails to parse rather than misbehaving, but the `EXCLUDED`/`VALUES()` reference syntax is the20 part people forget to translate.21- **`FULL OUTER JOIN` does not exist in MySQL.** Rewrite as22 `LEFT JOIN ... UNION SELECT ... RIGHT JOIN ...`; there is no direct substitute keyword.23- **Foreign-key columns are auto-indexed by MySQL but not by PostgreSQL.** A `REFERENCES` clause24 in PostgreSQL creates no index: joins and cascading deletes on that column do full scans until25 you add `CREATE INDEX` explicitly. This is the single most common missing-index bug when a26 schema is ported from MySQL to PostgreSQL.27- **`EXPLAIN ANALYZE` is MySQL 8.0.18+ only**; earlier MySQL has plain `EXPLAIN`. SQLite's28 equivalent is `EXPLAIN QUERY PLAN`, not `EXPLAIN` (bare `EXPLAIN` in SQLite dumps VDBE29 bytecode, not a query plan: a frequent tool-invocation mistake).3031## Current engine versions3233- **PostgreSQL**: 18, 17 (LTS), 16, 15, 14 supported; 13 and earlier reached EOL November 2025.34 PG 18 adds async I/O (concurrent readahead/seqscan) and, notably, **flips the default for35 generated columns from `STORED` to `VIRTUAL`** when neither keyword is specified: a schema36 written for PG ≤17 that omitted the keyword changes behavior on upgrade (computed-on-write37 becomes computed-on-read). PG 17 adds SQL/JSON (`JSON_TABLE`, `JSON_QUERY`, `JSON_VALUE`,38 `JSON_EXISTS`). PG 15 adds `MERGE` (full MATCHED/NOT MATCHED upsert). `pg_stat_io` (PG 16+)39 gives per-backend-type I/O stats; incremental sort (PG 13+) exploits existing index order to40 cut ORDER BY cost: look for "Incremental Sort" in the plan rather than assuming a full sort ran.41- **MySQL**: 8.4 LTS and 9.x Innovation releases.42- **SQLite**: 3.48+, type-affinity system (see trap above), single-file database.4344## Query patterns that hide bugs4546- **CTE materialization is a silent performance cliff, not just a readability choice.**47 PostgreSQL (12+) inlines a CTE referenced once but materializes it if referenced more than48 once: the same query can regress hard after an innocuous second reference is added. Force the49 behavior explicitly instead of relying on the reference-count heuristic:50 ```sql51 WITH expensive_calc AS MATERIALIZED (52 SELECT user_id, SUM(total) AS lifetime_value FROM orders GROUP BY user_id53 )54 SELECT * FROM expensive_calc WHERE lifetime_value > 1000;55 ```56- **`LAST_VALUE()` silently returns the wrong row without an explicit frame.** The default window57 frame ends at the current row, so `LAST_VALUE(total) OVER (PARTITION BY user_id ORDER BY58 order_date)` returns the *current* row's value, not the partition's actual last value. It needs59 `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING` to mean what the name implies.60- **`ROWS`, `RANGE`, and `GROUPS` framing are not interchangeable** when `ORDER BY` has61 duplicate values: `ROWS` counts physical rows, `RANGE` counts by value distance (e.g. a 7-day62 window by timestamp), `GROUPS` (PG 11+) counts peer groups of tied rows. Picking the wrong one63 silently double- or under-counts tied rows in a moving aggregate.64- **`LATERAL` lets a subquery in `FROM` reference preceding `FROM` items** (PostgreSQL, MySQL65 8.0.14+): the standard way to do top-N-per-group without a window-function-plus-filter:66 ```sql67 SELECT u.name, t.total FROM users u68 LEFT JOIN LATERAL (69 SELECT total FROM orders WHERE user_id = u.id ORDER BY total DESC LIMIT 370 ) t ON true;71 ```72- **Composite indexes obey a leftmost-prefix rule**: an index on `(user_id, status)` serves73 `WHERE user_id = ?` and `WHERE user_id = ? AND status = ?`, but not `WHERE status = ?` alone.74 Column order in the index, not in the query, decides usability.75- **`OR` across different columns defeats single-index use.** `WHERE email = ? OR name = ?`76 forces a scan even with indexes on both columns individually; rewrite as a `UNION` of two77 single-column-filtered queries to let each half use its own index.7879## Schema design traps8081- **Polymorphic association (`commentable_type` + `commentable_id`) cannot carry a foreign-key82 constraint.** Referential integrity is enforced only at the application layer: an orphaned or83 mistyped `commentable_id` is not caught by the database, ever.84- **Monetary values stored as `FLOAT`/`DOUBLE` accumulate rounding error** (`0.1 + 0.2 != 0.3`85 in IEEE 754). Use `DECIMAL`/`NUMERIC`, or store integer minor units (cents).86- **`GENERATED ... STORED` vs `VIRTUAL`**: PostgreSQL supported only `STORED` through v17 (18+87 adds `VIRTUAL`, see version note above); MySQL and SQLite support both. `VIRTUAL` recomputes on88 read and cannot be indexed the same way `STORED` can: check which one a query actually needs89 before assuming "generated column" means "persisted and indexable."9091## Transactions and isolation9293- **Isolation-level defaults differ by engine and by spec-compliance**: PostgreSQL defaults to94 `READ COMMITTED`; MySQL defaults to `REPEATABLE READ`. Standard SQL `REPEATABLE READ` still95 permits phantom reads, but **PostgreSQL's `REPEATABLE READ` prevents phantoms anyway** (it's96 closer to snapshot isolation): code tested against Postgres at that isolation level can see new97 phantom-read failures purely from being pointed at MySQL, with no code change.98- **`FOR UPDATE SKIP LOCKED`** is the standard queue-worker pattern (skip rows another worker99 already locked rather than blocking): `SELECT * FROM jobs WHERE status='pending' FOR UPDATE100 SKIP LOCKED LIMIT 1`. `FOR UPDATE NOWAIT` fails immediately instead of blocking: useful to101 distinguish real contention from a hang.102- **Advisory locks (PostgreSQL) have two different lifetimes**: `pg_advisory_lock`/103 `pg_advisory_unlock` are session-scoped and outlive the transaction unless explicitly released;104 `pg_advisory_xact_lock` auto-releases at commit. Using the session variant inside a connection105 pool leaks locks across borrowed connections if the unlock call is ever skipped on an error path.106- **Deadlock avoidance is ordering, not detection**: acquire locks on multiple rows in a107 consistent order (e.g., always lower ID first) across every code path that touches those rows108 together; a `lock_timeout` (PG) / `innodb_lock_wait_timeout` (MySQL) bounds the blast radius109 when ordering discipline still gets violated somewhere.110111## Migrations112113- **`ALTER TABLE ... ADD COLUMN ... DEFAULT x` is instant in PostgreSQL 11+** (no table rewrite,114 metadata-only) but a full table rewrite on older PostgreSQL and on MySQL before 8.0's instant115 DDL support: the same statement is a no-op-cost change on one version and a116 locks-the-table-for-the-duration change on another.117- **`CREATE INDEX CONCURRENTLY` (PostgreSQL) can fail and leave an invalid index behind** rather118 than rolling back cleanly: it cannot run inside a transaction, so a failure mid-build does not119 undo. Always check `pg_index.indisvalid` after a concurrent build and `DROP INDEX` + retry if120 it's false; don't assume "the command returned" means "the index is usable."121- **Renaming a column with zero downtime is expand-contract, not `RENAME COLUMN`**: add the new122 column, backfill, deploy code that writes both, deploy code that reads only the new column,123 then drop the old one. A bare `RENAME COLUMN` breaks every in-flight deployment still reading124 the old name.125- **Dropping a column safely requires the drain step**: stop writing from the application first,126 let old code paths fully roll off, only then `DROP COLUMN`: dropping while old code still127 references the column errors mid-request rather than failing at deploy time.128129## SQL injection surfaces beyond parameterized values130131Parameterized queries close the value-injection path but leave two others open:132133- **Wildcard injection in `LIKE`**: if user input is interpolated as the pattern, a value of `%`134 matches every row even through a parameterized query, because `%`/`_` are pattern metacharacters135 the parameterization doesn't escape. Escape them in application code before binding, or use an136 explicit `ESCAPE` clause: `... LIKE '%' || $1 || '%' ESCAPE ''`.137- **Identifier injection**: parameters bind values, not table/column names: `f"SELECT138 {column_name} FROM {table_name}"` is unparameterizable by definition. The only safe pattern is139 whitelisting identifiers against a known set before interpolating them, plus quoting140 (`quote_ident()` in PostgreSQL) as defense in depth.141142## Tooling143144- `pg_format --spaces 2 --keyword-case 2 input.sql`: PostgreSQL-aware formatter (pgFormatter).145- `sqlfluff lint --dialect postgres query.sql` / `sqlfluff fix --dialect postgres query.sql`:146 multi-dialect linter/autofixer (postgres, mysql, sqlite, bigquery, ...).147- `pgTAP`: TAP-protocol unit tests written in SQL (`SELECT has_table(...)`, `SELECT148 has_column(...)`), runs inside PostgreSQL itself rather than an external test runner.149150## Style convention (Emacs sql-indent)151152Right-align keywords so clause bodies start at a consistent column, and put commas at line start:153154```sql155SELECT c.customer_id156 , c.customer_name157 , COUNT(o.order_id) AS order_count158 FROM customers AS c159 LEFT JOIN orders AS o160 ON c.customer_id = o.customer_id161 WHERE c.status = 'active'162 GROUP BY c.customer_id163 , c.customer_name164HAVING COUNT(o.order_id) > 5165 ORDER BY order_count DESC166 LIMIT 100;167```168169Uppercase keywords, snake_case identifiers, explicit `AS` on aliases, one column per line.170171## Related172173- [context7-usage](../context7-usage/SKILL.md): fetch current PostgreSQL/MySQL/SQLite docs174 (library IDs: `/websites/postgresql`, `/websites/dev_mysql_doc_refman_9_4_en`, `/sqlite/sqlite`)175 instead of relying on training-data recall of version-specific behavior.176- [serena-usage](../serena-usage/SKILL.md): navigate schema definitions and find existing query177 patterns across a codebase before adding a new one.178- [investigation-patterns](../investigation-patterns/SKILL.md): for tracing a query-performance179 regression back to a plan change rather than guessing from the query text.