Patterns — Writing SQL Queries
Contents
- CTE pipelines
- Window functions (ranking, running totals, deduplication)
- NULL-safe predicates
- Upserts
- Aggregation patterns
- Parameterized queries by language
- Dialect deviations (MySQL, SQLite)
- Gotchas
CTE pipelines
sql
WITH recent_orders AS (
SELECT user_id, total_cents, created_at
FROM orders
WHERE created_at >= now() - INTERVAL '30 days'
),
user_totals AS (
SELECT user_id, SUM(total_cents) AS spend_cents
FROM recent_orders
GROUP BY user_id
)
SELECT u.id, u.email, t.spend_cents
FROM users u
JOIN user_totals t ON t.user_id = u.id
ORDER BY t.spend_cents DESC;Window functions
Latest row per group (deduplication):
sql
SELECT id, user_id, status
FROM (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders o
) ranked
WHERE rn = 1;Running total and rank:
sql
SELECT created_at::date AS day,
SUM(total_cents) AS day_total,
SUM(SUM(total_cents)) OVER (ORDER BY created_at::date) AS running_total,
RANK() OVER (ORDER BY SUM(total_cents) DESC) AS day_rank
FROM orders
GROUP BY created_at::date;ROW_NUMBER = no ties · RANK = ties skip numbers · DENSE_RANK = ties don't skip.
NULL-safe predicates
sql
-- Anti-join: rows in users with no orders. Prefer NOT EXISTS.
SELECT u.id
FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- NULL-safe equality (PostgreSQL):
WHERE a IS NOT DISTINCT FROM b
-- Safe division:
SELECT paid_cents::numeric / NULLIF(total_cents, 0) AS paid_ratio;Upserts
sql
INSERT INTO settings (user_id, key, value)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, key)
DO UPDATE SET value = EXCLUDED.value, updated_at = now();Aggregation patterns
Conditional aggregation (pivot-lite):
sql
SELECT user_id,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_count,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_count
FROM orders
GROUP BY user_id;HAVING filters groups, WHERE filters rows — apply WHERE first for less work:
sql
SELECT user_id, COUNT(*) AS n
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY user_id
HAVING COUNT(*) >= 5;Parameterized queries by language
python
# psycopg (PostgreSQL)
cur.execute("SELECT id FROM users WHERE email = %s", (email,))
# sqlite3
cur.execute("SELECT id FROM users WHERE email = ?", (email,))javascript
// node-postgres
await pool.query('SELECT id FROM users WHERE email = $1', [email]);Identifiers (table/column names) cannot be parameters — validate them against an allowlist if dynamic.
Dialect deviations (MySQL, SQLite)
| PostgreSQL | MySQL | SQLite |
|---|---|---|
ON CONFLICT … DO UPDATE |
ON DUPLICATE KEY UPDATE |
ON CONFLICT … DO UPDATE (3.24+) |
COUNT(*) FILTER (WHERE …) |
SUM(CASE WHEN … THEN 1 ELSE 0 END) |
same as MySQL |
now() - INTERVAL '30 days' |
NOW() - INTERVAL 30 DAY |
datetime('now', '-30 days') |
::type cast |
CAST(x AS type) |
CAST(x AS type) |
string || string |
CONCAT(a, b) (|| needs PIPES_AS_CONCAT) |
|| |
Gotchas
NOT IN (subquery)returns zero rows if the subquery yields any NULL — useNOT EXISTS.COUNT(col)skips NULLs;COUNT(*)counts rows. Different numbers on nullable columns.UNIONdeduplicates (and sorts on many engines);UNION ALLis what you usually want.- Integer division truncates in PostgreSQL:
1/2 = 0. Cast one side tonumeric. ORDER BYin a subquery/CTE is not guaranteed to survive to the outer query — order at the outermost level.BETWEEN a AND bis inclusive on both ends; for timestamp ranges use>= a AND < b_nextto avoid double-counting boundaries.- Window functions cannot appear in
WHERE/HAVING— wrap in a subquery (see deduplication pattern).