DS DevShelfHub Projects · AI tools
Cheatsheets / SQL
Cheatsheet · Languages

SQL: SELECT, Joins, Window Functions, CTEs and DDL Reference Guide

By DevShelfHub

Select, joins, aggregation, window functions, CTEs, DDL, indexes, transactions — the daily SQL surface across Postgres/MySQL/SQLite.

117 items 8 min Joins Window CTE

Start hereQuick start · 6 you’ll reach for daily

FilterWHERE status = 'shipped'
JoinLEFT JOIN x ON x.id = …
GroupGROUP BY 1, 2 HAVING …
WindowOVER (PARTITION BY …)
CTEWITH x AS (…) SELECT …
UpsertON CONFLICT DO UPDATE

flavors coveredVersions

Flavors: PostgreSQL 16+ MySQL 8 / MariaDB 10.6+ SQLite 3.40+

Syntax leans Postgres (most expressive), with vendor differences called out where they bite. Standard SQL:2016 covers ~80% of rows here. Where a row uses a vendor-specific extension (e.g. JSONB, LATERAL, INSERT … ON CONFLICT), the desc says so.

clients · psqlSetup

psql -h host -U user -d dbnamePostgres CLI.
\dt / \d table / \df / \duTables / columns / functions / users.
\timing on / \x autoQuery timing + expanded-column display.
EXPLAIN ANALYZE SELECT …Runs the query, returns the plan + actual times.
mysql -h host -u user -p dbnameMySQL CLI.
sqlite3 path/to.dbSQLite REPL.
pgcli / mycli / litecliModern REPLs with auto-completion.
DBeaver / TablePlus / DataGripGUI clients. Use for ad-hoc work; commit migrations to SQL files.

read queriesSELECT

SELECT a, b FROM t WHERE p ORDER BY a LIMIT 20 OFFSET 40Logical order: FROM → WHERE → GROUP → HAVING → SELECT → ORDER → LIMIT.
SELECT a AS alias / FROM t AS xAliases.
DISTINCT colDrop duplicates. Cheap on indexed columns.
DISTINCT ON (col) col, other FROM t ORDER BY col, otherPostgres: first row per group.
CASE WHEN x > 0 THEN 'pos' ELSE 'neg' ENDInline conditional.
COALESCE(a, b, c)First non-null value.
NULLIF(a, b)Returns NULL if a = b.
IS NULL / IS NOT NULLUse these — = NULL is always NULL.
IN (subquery) / EXISTS (subquery)Use EXISTS for large dependent subqueries.
BETWEEN x AND yInclusive on both sides. Be wary on dates.
LIKE / ILIKE / SIMILAR TOPattern matching. ILIKE is case-insensitive (Postgres).
LIMIT N OFFSET MPagination. Keyset / cursor scales better than offset.

combining tablesJoins

INNER JOIN x ON x.id = y.x_idDefault. Drop rows with no match.
LEFT JOIN x ON …Keep rows from the left; columns are NULL when no match.
RIGHT JOIN x ON …Mirror of LEFT. Reorder the join instead — clearer.
FULL OUTER JOIN x ON …Both sides; NULL where unmatched.
CROSS JOIN xCartesian product. Combine with a CTE for known small sets.
USING (id)Short form when both sides have the same column name.
SELF JOIN: FROM t a JOIN t b ON …Same table twice. Tree walks, comparisons.
LATERAL (SELECT …) subPostgres: subquery can reference outer columns. Top-N-per-group.
Anti-join: LEFT JOIN … WHERE x.id IS NULLRows with no match.
Semi-join: WHERE EXISTS (SELECT 1 FROM …)Rows that match without duplicating.

group + summariseAggregation

SELECT a, COUNT(*) FROM t GROUP BY aStandard count by group.
COUNT(*) / COUNT(col) / COUNT(DISTINCT col)All / non-null / distinct non-null.
SUM, AVG, MIN, MAXNumeric aggregates. AVG returns float / numeric.
STRING_AGG(col, ',' ORDER BY col) / GROUP_CONCATConcatenate per group. GROUP_CONCAT in MySQL.
ARRAY_AGG(col ORDER BY col)Aggregate into an array (Postgres).
JSON_AGG / JSONB_AGGAggregate rows into JSON arrays.
HAVING count(*) > 1Filter after grouping (post-aggregate predicate).
GROUP BY ROLLUP(a, b)Subtotals at each level + grand total.
GROUP BY CUBE(a, b)All combinations of grouping sets.
FILTER (WHERE p) inside aggregateSUM(x) FILTER (WHERE y > 0). Postgres-standard.

per-row but row-awareWindow functions

ROW_NUMBER() OVER (ORDER BY x)1, 2, 3 with stable ordering.
RANK() / DENSE_RANK()Tie handling: gaps vs no-gaps.
PARTITION BY a, bReset numbering / aggregation per partition.
LAG(x, 1) / LEAD(x, 1)Previous / next row in partition.
SUM(x) OVER (PARTITION BY a ORDER BY t ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)Running total.
NTILE(4) OVER (…)Bucket rows into quartiles / deciles.
FIRST_VALUE / LAST_VALUE / NTH_VALUEBoundary values within the window.
WINDOW w AS (PARTITION BY …)Name a window and reuse: SUM(x) OVER w.
Top-N-per-group via QUALIFY (DuckDB / Snowflake) or subquery + rankPostgres needs the subquery + rank filter pattern.
sql
-- Window functions: rank, partition, running totals.
SELECT
    order_id,
    customer_id,
    created_at,
    total,
    ROW_NUMBER() OVER (
        PARTITION BY customer_id
        ORDER BY     created_at DESC
    )                                                            AS recency_rank,
    SUM(total) OVER (
        PARTITION BY customer_id
        ORDER BY     created_at
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    )                                                            AS running_total,
    LAG(total)   OVER (PARTITION BY customer_id ORDER BY created_at) AS prev_total,
    NTILE(4)     OVER (ORDER BY total DESC)                          AS spend_quartile
FROM   orders
WHERE  status = 'shipped';

-- Common helpers:
--   ROW_NUMBER / RANK / DENSE_RANK / PERCENT_RANK
--   FIRST_VALUE / LAST_VALUE / NTH_VALUE
--   LEAD / LAG / NTILE
--   SUM / AVG / COUNT / MIN / MAX -- all work as window aggregates.

named subqueriesCTEs

WITH x AS (SELECT …) SELECT … FROM xNamed, single-use subquery. Improves readability.
WITH RECURSIVE descendants AS (…) SELECT …Recursive walk for trees / graphs.
Chained CTEsStack WITH a AS …, b AS … — each can reference the previous.
CTE as optimisation fencePostgres < 12 materialised CTEs unconditionally. 12+ inlines unless MATERIALIZED is set.
CTE for write operationsWITH inserted AS (INSERT … RETURNING *) SELECT … — Postgres.
Cycle detection: WITH RECURSIVE … CYCLE id SET is_cycle USING pathPostgres 14+. Avoid infinite walks.
sql
-- Named subqueries (CTEs)
WITH recent_orders AS (
    SELECT *
    FROM   orders
    WHERE  created_at >= NOW() - INTERVAL '30 days'
),
totals AS (
    SELECT customer_id, SUM(total) AS spent
    FROM   recent_orders
    GROUP  BY customer_id
)
SELECT c.id, c.name, t.spent
FROM   customers c
JOIN   totals    t ON t.customer_id = c.id
WHERE  t.spent > 1000;

-- Recursive CTE: walk a tree / graph.
WITH RECURSIVE descendants AS (
    SELECT id, parent_id, name, 0 AS depth
    FROM   categories
    WHERE  id = 1
    UNION ALL
    SELECT c.id, c.parent_id, c.name, d.depth + 1
    FROM   categories c
    JOIN   descendants d ON c.parent_id = d.id
)
SELECT * FROM descendants
ORDER  BY depth, name;

scalar & correlatedSubqueries

WHERE x = (SELECT …)Scalar subquery. Must return one row, one column.
WHERE x IN (SELECT …)Set membership.
WHERE EXISTS (SELECT 1 FROM … WHERE x.id = o.id)Correlated subquery. Often faster than IN on large sets.
SELECT a, (SELECT MAX(x) FROM t2 WHERE t2.a_id = a.id) FROM aPer-row scalar subquery. Easy to read; risk of N+1.
FROM (SELECT …) subDerived table. CTEs are usually clearer.
DecorrelationRewrite correlated subqueries as joins when performance matters.

schema changesDDL

CREATE TABLE t (…)Use NOT NULL + defaults aggressively. NULL costs you later.
CREATE TABLE t (LIKE source INCLUDING ALL)Copy structure (Postgres).
CREATE TABLE t AS SELECT … / CTASMaterialise a query result into a new table.
ALTER TABLE t ADD COLUMN x TEXTAdd column. Defaults can lock the table; in Postgres 11+ adding NULL default is free.
ALTER TABLE t ALTER COLUMN x SET NOT NULLAdd NOT NULL constraint. Backfill first.
ALTER TABLE t RENAME COLUMN old TO newRename. Watch app code + views.
CREATE INDEX i ON t (a, b)Composite index. Order matters (leftmost prefix).
CREATE INDEX CONCURRENTLYPostgres: build without blocking writes. Always use in prod.
CREATE UNIQUE INDEX i ON t (lower(email))Functional / expression index.
CREATE INDEX i ON t (status) WHERE status = 'pending'Partial index. Small, focused.
DROP TABLE t / TRUNCATE tDelete table / fast-clear rows.
CHECK (…) / FOREIGN KEY (…) ON DELETE CASCADEDeclare data invariants in the schema.
GENERATED ALWAYS AS (a + b) STOREDComputed columns.
sql
-- Tables, constraints, indexes (Postgres-leaning syntax).

CREATE TABLE customers (
    id          BIGSERIAL  PRIMARY KEY,
    email       CITEXT     NOT NULL UNIQUE,
    name        TEXT       NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE orders (
    id            BIGSERIAL  PRIMARY KEY,
    customer_id   BIGINT     NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
    status        TEXT       NOT NULL CHECK (status IN ('pending','shipped','cancelled')),
    total         NUMERIC(12, 2) NOT NULL CHECK (total >= 0),
    metadata      JSONB,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (customer_id, id)
);

-- Indexes
CREATE INDEX orders_customer_status_idx
    ON orders (customer_id, status, created_at DESC);

-- Partial index — only the rows you actually query for.
CREATE INDEX orders_pending_idx
    ON orders (created_at)
    WHERE status = 'pending';

-- Add a column without downtime.
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMPTZ;
ALTER TABLE orders RENAME COLUMN total TO amount_total;

make queries fastIndexes

B-tree (default)Equality + range + ORDER BY. The workhorse.
HashEquality only. Postgres mostly — rarely beats B-tree.
GIN / GISTPostgres: arrays, JSONB, full-text, ranges, geometry.
BRINPostgres: huge naturally-ordered tables (append-only logs).
Index order mattersLeftmost-prefix rule. (a, b) helps WHERE a=… but not WHERE b=….
Covering / include columnsCREATE INDEX … (a) INCLUDE (b). Index-only scans.
PartialOnly the hot rows: WHERE deleted = false.
FunctionalIndex on expression: lower(email), date_trunc('day', ts).
EXPLAIN (ANALYZE, BUFFERS)Postgres: actual time + buffer reads. Read top-down.
pg_stat_statements / sys.dm_exec_query_statsFind the slow + frequent queries.

ACID & isolationTransactions

BEGIN; … COMMIT; / ROLLBACK;Explicit transaction.
SAVEPOINT s; … ROLLBACK TO s;Nested partial rollback.
SET TRANSACTION ISOLATION LEVEL REPEATABLE READPer-transaction isolation override.
READ COMMITTED (default)Each statement sees data committed before it started.
REPEATABLE READSnapshot at first statement; same view through the txn.
SERIALIZABLEAs if transactions ran one at a time. Postgres uses SSI (predicate locking).
SELECT … FOR UPDATERow-level lock. Hold until COMMIT.
SELECT … FOR UPDATE SKIP LOCKEDQueue / work-stealing patterns. Skip rows others have locked.
Idempotent writesUse natural unique keys + ON CONFLICT DO NOTHING for safe retries.

insert · update · upsertWrites

INSERT INTO t (a, b) VALUES (…), (…)Multi-row insert.
INSERT INTO t SELECT … FROM sourceInsert-from-select.
INSERT … RETURNING id, created_atRead what you wrote (Postgres / SQLite).
INSERT … ON CONFLICT (col) DO UPDATE SET …Preferred Postgres / SQLite upsert.
INSERT … ON DUPLICATE KEY UPDATEMySQL upsert. Triggers on any unique-key conflict.
UPDATE t SET x = y FROM other WHERE …Joined update (Postgres).
DELETE FROM t WHERE … RETURNING *Delete + return removed rows.
MERGE INTO target USING src ON … WHEN MATCHED … WHEN NOT MATCHED …SQL-standard upsert. Postgres 15+, MySQL 8, all the big DBs.
sql
-- Postgres: INSERT ... ON CONFLICT (the "upsert").
INSERT INTO inventory (sku, qty)
VALUES ('ABC-1', 10)
ON CONFLICT (sku) DO UPDATE
    SET qty = inventory.qty + EXCLUDED.qty,
        updated_at = NOW();

-- INSERT IGNORE-like behaviour: skip duplicates.
INSERT INTO inventory (sku, qty)
VALUES ('ABC-1', 10)
ON CONFLICT (sku) DO NOTHING;

-- RETURNING — read what you wrote in one round trip.
INSERT INTO orders (customer_id, total)
VALUES (42, 120.00)
RETURNING id, created_at;

-- UPDATE ... FROM (Postgres) for joined updates.
UPDATE orders o
SET    status = 'cancelled'
FROM   refunds r
WHERE  r.order_id = o.id
  AND  r.requested_at >= NOW() - INTERVAL '7 days';

-- MySQL upsert: INSERT ... ON DUPLICATE KEY UPDATE.
-- SQLite upsert: INSERT ... ON CONFLICT (same syntax as Postgres).

make it fastPerformance

EXPLAIN ANALYZERun + return the plan with actual times.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)Postgres: include I/O + buffer reads.
Seq Scan vs Index Scan vs Index-Only ScanThree core access methods. Index-Only is usually best.
Nested Loop vs Hash Join vs Merge JoinPick changes with row counts + indexes.
ANALYZE / VACUUM ANALYZERefresh statistics so the planner picks good plans.
pg_stat_statementsTop-by-total-time queries. Source of optimisation work.
Avoid SELECT *Fewer columns = less I/O + index-only scans possible.
Avoid functions on indexed columns in WHEREWHERE lower(email) = … can’t use a plain email index; build a functional index.
Use LIMIT in correlated subqueriesOtherwise the planner pessimistically scans everything.
Partition huge tablesRange / list / hash partitioning. Postgres native, MySQL too.

joins + CTE + windowEnd-to-end · Top customer per month

Aggregate, rank, filter, join — the structure of most real analytics queries. Swap the columns and it’s your reporting query.

sql
-- "Top customer by month" — joins + window + CTE in one.
WITH monthly AS (
    SELECT
        DATE_TRUNC('month', created_at)::DATE AS month,
        customer_id,
        SUM(total)                            AS spent
    FROM   orders
    WHERE  status = 'shipped'
      AND  created_at >= NOW() - INTERVAL '12 months'
    GROUP  BY 1, 2
),
ranked AS (
    SELECT
        month,
        customer_id,
        spent,
        ROW_NUMBER() OVER (PARTITION BY month ORDER BY spent DESC) AS rank
    FROM   monthly
)
SELECT
    r.month,
    c.name      AS top_customer,
    r.spent
FROM   ranked   r
JOIN   customers c ON c.id = r.customer_id
WHERE  r.rank = 1
ORDER  BY r.month;

Best practiceGood to know

Default to NOT NULL. Every nullable column is a future CASE WHEN x IS NULL THEN … in someone’s query. Only allow NULL when “unknown” is a meaningful state.
Always CREATE INDEX CONCURRENTLY in production (Postgres). Plain CREATE INDEX takes an ACCESS EXCLUSIVE lock — writes block. The concurrent version is slower but online.
Read query plans top-down. Indented nodes feed their parent. The bottom-most nodes are the access methods; the top is what you asked for. Time + row counts at each node tell you where the cost is.

Common trapsWatch out for

NULL = NULL is NULL, not TRUE. Use IS NULL. NOT IN (subquery with NULLs) can return zero rows by surprise — use NOT EXISTS.
Implicit casts hide bugs. WHERE id = '42' against a BIGINT column may or may not use the index, depending on engine. Cast in code, not in the WHERE clause.
OFFSET pagination grows linearly. LIMIT 50 OFFSET 100000 reads 100050 rows. Switch to keyset pagination (WHERE id > ?) on big tables.

Go deeperSee also

SQL FAQ

What is SQL and what databases use it?

SQL (Structured Query Language) is the standard language for querying and manipulating relational databases. It is used by PostgreSQL, MySQL, SQLite, MariaDB, SQL Server, Oracle, Snowflake, BigQuery, and DuckDB — among many others. Most databases follow ANSI SQL with their own extensions for features like window functions, JSON, and full-text search.

What is the difference between INNER JOIN, LEFT JOIN, and FULL OUTER JOIN?

INNER JOIN returns only rows where the join condition matches in both tables. LEFT JOIN returns all rows from the left table and matching rows from the right — unmatched right columns are NULL. RIGHT JOIN is the mirror image. FULL OUTER JOIN returns all rows from both tables, with NULLs for non-matching sides. Use LEFT JOIN most of the time — it is the most intuitive.

What are SQL window functions?

Window functions compute a value across a set of rows related to the current row without collapsing them into a group. Use OVER (PARTITION BY col ORDER BY col) to define the window. ROW_NUMBER() assigns a unique row number, RANK() and DENSE_RANK() handle ties, LAG/LEAD access adjacent rows, and SUM/AVG compute running totals. They require SQL 2003 or later.

What is a CTE in SQL?

A Common Table Expression (WITH name AS (...) SELECT ...) is a named temporary result set scoped to the current query. CTEs make complex queries readable by breaking them into named steps. Recursive CTEs (WITH RECURSIVE) can traverse hierarchical data like org charts or file trees. Most databases have supported CTEs since SQL 2005.

How do SQL indexes work?

An index is a data structure (usually a B-tree) that lets the database find rows matching a WHERE or JOIN condition without scanning every row. Create with CREATE INDEX idx_name ON table(col). Indexes speed up reads but slow down writes and use extra storage. Add indexes on columns used in WHERE, JOIN ON, and ORDER BY clauses. Use EXPLAIN to see if the query planner uses them.

What are SQL transactions and what is ACID?

A transaction groups multiple SQL statements into an atomic unit: BEGIN; UPDATE ...; UPDATE ...; COMMIT. If any statement fails, ROLLBACK undoes all changes. ACID stands for Atomicity (all-or-nothing), Consistency (constraints hold), Isolation (concurrent transactions do not interfere), and Durability (committed data survives crashes). Use transactions for any multi-step operation that must not be partially applied.