Scripting and data cheat sheetFile cabinet icon

SQL cheat sheet

SQL syntax on one page: SELECT, WHERE, the four JOINs, GROUP BY, window functions, CTEs and upserts, with PostgreSQL, MySQL and SQLite differences noted.

Last updated

SQL is the language for asking a relational database for data and changing what is in it. It reads close to English, and the same core works on PostgreSQL, MySQL, SQLite, SQL Server and every other relational database. The reference below is grouped by what you are trying to do, and the filter box searches all of it at once. Type join to see every join, or mysql to see where MySQL differs.

Every snippet is standard SQL unless its notes say otherwise, and was run on PostgreSQL 18, MySQL 8.4 and SQLite 3.53. When the three disagree, the notes column names which one does what. Names like users and orders come from a small example shop; swap in your own.

SELECT queries

The core of SQL: read rows from one or more tables. Every example on this page runs against a small shop database with users, orders and products tables, so swap in your own names.

TaskSQLNotes
Select every columnSELECT * FROM users;Fine for exploring. Name the columns in real code
Select some columnsSELECT id, name FROM users;
Rename a column in the resultSELECT name AS full_name FROM users;An alias. AS is optional but easier to read
Calculate a columnSELECT name, price * 1.2 AS price_with_vat FROM products;
Unique valuesSELECT DISTINCT country FROM users;
Unique combinationsSELECT DISTINCT country, role FROM users;Distinct across both columns together, not each one
First 10 rowsSELECT * FROM users ORDER BY id LIMIT 10;Without ORDER BY, which 10 you get is not defined
Skip, then take (paging)SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 20;Rows 21 to 30
Standard SQL row limitSELECT * FROM users ORDER BY id FETCH FIRST 10 ROWS ONLY;PostgreSQL only of the three. MySQL and SQLite need LIMIT
Count the rowsSELECT COUNT(*) FROM users;
Join text togetherSELECT CONCAT(name, ' <', email, '>') FROM users;All three, SQLite from 3.44. MySQL returns NULL if any part is NULL; the others skip it. || also works in PostgreSQL and SQLite
Replace NULL with a defaultSELECT name, COALESCE(country, 'unknown') FROM users;Returns the first argument that is not NULL
If and elseSELECT name, CASE WHEN age < 18 THEN 'minor' ELSE 'adult' END AS age_group FROM users;A NULL age falls through to ELSE
Round a numberSELECT ROUND(price, 1) FROM products;
Convert a typeSELECT CAST(total AS DECIMAL(10, 0)) FROM orders;CAST(x AS INTEGER) fails in MySQL, which wants SIGNED. PostgreSQL also has total::integer
Today's dateSELECT CURRENT_DATE;SQLite returns text, 2026-09-26, in UTC
Current date and timeSELECT CURRENT_TIMESTAMP;SQLite returns text in UTC. PostgreSQL also has now()
Comment-- one line /* several lines */MySQL needs a space after the two dashes

Strings take single quotes: 'Ada'. Double quotes are for table and column names that need quoting, such as "order date", in standard SQL, PostgreSQL and SQLite. MySQL quotes names with backticks instead. Keywords are not case-sensitive, so select and SELECT are the same; capitals are just the convention.

Filtering rows with WHERE

TaskSQLNotes
CompareSELECT * FROM users WHERE age >= 18;= < > <= >= all work as you expect. Equals is one =
Not equalSELECT * FROM users WHERE role <> 'admin';!= works too. Rows where role is NULL are not returned
Both must be trueSELECT * FROM users WHERE age >= 18 AND country = 'US';
Either can be trueSELECT * FROM users WHERE role = 'admin' OR role = 'owner';
Mix AND and ORSELECT * FROM users WHERE country = 'US' AND (role = 'admin' OR role = 'owner');AND binds tighter than OR, so brackets are not optional here
Match a listSELECT * FROM users WHERE country IN ('US', 'CA', 'MX');
Not in a listSELECT * FROM orders WHERE status NOT IN ('cancelled', 'refunded');Rows where status is NULL are not returned either
In a rangeSELECT * FROM products WHERE price BETWEEN 10 AND 50;Includes both ends: 10 and 50 match
Dates in a monthSELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';Half-open range. Unlike BETWEEN, it stays right when the column has a time too
Starts withSELECT * FROM users WHERE name LIKE 'A%';% matches any run of characters, including none
Ends withSELECT * FROM users WHERE email LIKE '%@gmail.com';A leading % cannot use a normal index, so it is slow on big tables
Exactly one characterSELECT * FROM users WHERE name LIKE '_a%';_ matches one character. This finds names whose second letter is a
Ignore upper and lower caseSELECT * FROM users WHERE LOWER(email) = LOWER('Ada@Example.com');Works everywhere. PostgreSQL also has ILIKE. See the dialect table for who ignores case by default
Regular expressionSELECT * FROM users WHERE email REGEXP '^[a-z]+@';MySQL. PostgreSQL writes email ~ '^[a-z]+@'. SQLite has no regex built in
Has no valueSELECT * FROM users WHERE deleted_at IS NULL;= NULL never matches anything, not even NULL
Has a valueSELECT * FROM users WHERE deleted_at IS NOT NULL;
Not equal, treating NULL as a valueSELECT * FROM users WHERE country IS DISTINCT FROM 'US';PostgreSQL and SQLite. Unlike <>, it returns the rows where country is NULL. MySQL: NOT (country <=> 'US')
Has a match in another tableSELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);Users with at least one order

A WHERE clause keeps a row only when the condition is TRUE. Any comparison with NULL gives NULL, meaning unknown, which is neither true nor false, so the row is dropped. That one rule explains most surprises in this table.

ORDER BY, GROUP BY and HAVING

TaskSQLNotes
Sort, smallest firstSELECT * FROM users ORDER BY created_at;ASC is the default
Sort, largest firstSELECT * FROM users ORDER BY created_at DESC;
Sort by several columnsSELECT * FROM users ORDER BY country, name DESC;DESC applies to name only. country is still ascending
Put NULLs lastSELECT * FROM users ORDER BY age DESC NULLS LAST;PostgreSQL and SQLite. MySQL: ORDER BY age IS NULL, age DESC
Sort by a calculated columnSELECT id, total * 1.2 AS gross FROM orders ORDER BY gross DESC;ORDER BY can use an alias from SELECT. WHERE cannot
Random orderSELECT * FROM products ORDER BY RANDOM() LIMIT 1;PostgreSQL and SQLite. MySQL spells it RAND(). Slow on big tables
Group rowsSELECT country, COUNT(*) FROM users GROUP BY country;One row per country
Group by several columnsSELECT country, role, COUNT(*) FROM users GROUP BY country, role;One row per combination
Filter the groupsSELECT country, COUNT(*) FROM users GROUP BY country HAVING COUNT(*) > 100;
Filter rows, then groupsSELECT country, COUNT(*) FROM users WHERE active = TRUE GROUP BY country HAVING COUNT(*) >= 10;WHERE runs before grouping, HAVING after it
Biggest groups firstSELECT country, COUNT(*) AS n FROM users GROUP BY country ORDER BY n DESC;
Subtotals and a grand totalSELECT country, role, COUNT(*) FROM users GROUP BY ROLLUP (country, role);PostgreSQL and MySQL, which also accepts GROUP BY country, role WITH ROLLUP. Not SQLite. Total rows show NULL

SQL runs a query in a different order from the one you write it in: FROM and JOIN, then WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY and finally LIMIT. That is why WHERE cannot see an alias made in SELECT, why an aggregate like COUNT(*) belongs in HAVING rather than WHERE, and why ORDER BY can use both.

Joins

A join combines rows from two tables on a matching column, usually a foreign key such as orders.user_id pointing at users.id. The worked example further down shows every join on the same four rows.

JoinSQLReturns
INNER JOINSELECT u.name, o.total FROM orders o INNER JOIN users u ON o.user_id = u.id;Only rows with a match on both sides. JOIN on its own means INNER JOIN
LEFT JOINSELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id;Every user, with NULLs where a user has no orders
RIGHT JOINSELECT u.name, o.total FROM users u RIGHT JOIN orders o ON o.user_id = u.id;Every order, with NULLs where there is no user. The same as a LEFT JOIN with the tables swapped. SQLite 3.39 onwards
FULL OUTER JOINSELECT u.name, o.total FROM users u FULL OUTER JOIN orders o ON o.user_id = u.id;Every row from both sides. PostgreSQL and SQLite 3.39 onwards. MySQL has none, see the worked example
CROSS JOINSELECT s.size, c.colour FROM sizes s CROSS JOIN colours c;Every combination: 3 sizes and 4 colours give 12 rows
Self joinSELECT e.name, m.name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.id;A table joined to itself under two aliases
Three tablesSELECT o.id, p.name, i.quantity FROM orders o JOIN order_items i ON i.order_id = o.id JOIN products p ON p.id = i.product_id;Each JOIN adds one table
Rows with no matchSELECT u.* FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.id IS NULL;Users who have never ordered. Called an anti-join
Filter the right side, keep every left rowSELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';Put o.status in WHERE instead and it quietly becomes an INNER JOIN
Totals per row of the left tableSELECT u.name, COUNT(o.id) AS orders FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.name;COUNT(o.id), not COUNT(*), so users with no orders get 0 rather than 1

Give every table a short alias (users u) and prefix every column with it (u.name). Once two tables both have an id or a name column, an unprefixed one is an error or, worse, the wrong column.

Aggregate functions

An aggregate turns many rows into one value. On its own it summarises the whole table; with GROUP BY it gives one value per group.

FunctionSQLNotes
COUNT(*)SELECT COUNT(*) FROM orders;Every row, NULLs and all
COUNT(column)SELECT COUNT(deleted_at) FROM users;Only rows where the column is not NULL
COUNT(DISTINCT column)SELECT COUNT(DISTINCT user_id) FROM orders;Different values, ignoring NULL
SUMSELECT SUM(total) FROM orders;NULL, not 0, when there are no rows. COALESCE(SUM(total), 0) fixes that
AVGSELECT AVG(total) FROM orders;NULLs are left out, not counted as 0
MIN and MAXSELECT MIN(total), MAX(total) FROM orders;Work on text and dates too
Per groupSELECT user_id, SUM(total) FROM orders GROUP BY user_id;
Round the resultSELECT ROUND(AVG(total), 2) FROM orders;
Join values into one stringSELECT country, STRING_AGG(name, ', ' ORDER BY name) FROM users GROUP BY country;PostgreSQL, and SQLite from 3.44. MySQL: GROUP_CONCAT(name ORDER BY name SEPARATOR ', ')
Join values into one string, MySQLSELECT country, GROUP_CONCAT(name ORDER BY name SEPARATOR ', ') FROM users GROUP BY country;MySQL. SQLite has GROUP_CONCAT(name, ', '), but in MySQL that glues the two arguments together
Collect into a JSON arraySELECT JSON_ARRAYAGG(name) FROM users;PostgreSQL and MySQL. SQLite: json_group_array(name)
Count only some rowsSELECT COUNT(*) FILTER (WHERE status = 'paid') FROM orders;PostgreSQL and SQLite. Not MySQL
Count only some rows, anywhereSELECT SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid FROM orders;Works on all three, MySQL included

Every column in SELECT must either be inside an aggregate or listed in GROUP BY. PostgreSQL and MySQL stop with an error when it is not. SQLite runs the query anyway and picks a value from some row in the group, which looks fine until it is wrong.

Window functions

A window function calculates across a set of related rows, like an aggregate, but keeps every row instead of collapsing them. OVER says which rows: PARTITION BY splits them into groups and ORDER BY sets the order inside each one. MySQL has them from 8.0 and SQLite from 3.25.

TaskSQLNotes
Number the rowsSELECT id, total, ROW_NUMBER() OVER (ORDER BY total DESC) AS rn FROM orders;1, 2, 3 with no ties. Which of two equal rows comes first is not defined
Rank, with gaps after tiesSELECT id, total, RANK() OVER (ORDER BY total DESC) FROM orders;1, 2, 2, 4
Rank, no gapsSELECT id, total, DENSE_RANK() OVER (ORDER BY total DESC) FROM orders;1, 2, 2, 3
Rank within each groupSELECT user_id, total, RANK() OVER (PARTITION BY user_id ORDER BY total DESC) FROM orders;Numbering starts again for each user
Running totalSELECT created_at, total, SUM(total) OVER (ORDER BY created_at, id) AS running_total FROM orders;The id breaks ties. Rows with the same created_at would otherwise be added in one step
Running total per groupSELECT user_id, total, SUM(total) OVER (PARTITION BY user_id ORDER BY created_at, id) FROM orders;
Moving averageSELECT created_at, AVG(total) OVER (ORDER BY created_at, id ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) FROM orders;This row and the two before it
Percentage of the totalSELECT id, 100.0 * total / SUM(total) OVER () AS pct FROM orders;Empty OVER () means all rows. The 100.0 avoids whole-number division, see the gotchas
Previous rowSELECT created_at, total, LAG(total) OVER (ORDER BY created_at, id) FROM orders;NULL on the first row. LAG(total, 1, 0) gives 0 instead
Next rowSELECT created_at, total, LEAD(total) OVER (ORDER BY created_at, id) FROM orders;
Change since the previous rowSELECT created_at, total - LAG(total) OVER (ORDER BY created_at, id) AS diff FROM orders;Not AS change: CHANGE is a reserved word in MySQL
First value in each groupSELECT user_id, total, FIRST_VALUE(total) OVER (PARTITION BY user_id ORDER BY created_at) AS first_order FROM orders;
Split into equal bucketsSELECT id, total, NTILE(4) OVER (ORDER BY total) AS quartile FROM orders;
Reuse one windowSELECT id, RANK() OVER w, SUM(total) OVER w FROM orders WINDOW w AS (PARTITION BY user_id ORDER BY total DESC);WINDOW goes after GROUP BY and HAVING, before ORDER BY

Window functions run after WHERE, so WHERE rn = 1 is an error. Put the query in a CTE or subquery and filter outside it, as the top-N-per-group example below does.

Subqueries and CTEs

A subquery is a query inside another one, in brackets. A CTE (common table expression) is a subquery with a name, written first with WITH, which makes long queries read top to bottom. MySQL has CTEs from 8.0.

TaskSQLNotes
Compare with one calculated valueSELECT * FROM orders WHERE total > (SELECT AVG(total) FROM orders);The subquery must return one row and one column
Match a list from another querySELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 100);
No match in another querySELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);Safer than NOT IN, which returns nothing at all if the subquery gives a NULL
A value for every rowSELECT u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count FROM users u;A correlated subquery: it refers to the outer row
Query the result of a querySELECT AVG(n) FROM (SELECT user_id, COUNT(*) AS n FROM orders GROUP BY user_id) AS per_user;MySQL needs the alias, per_user here. Give one everywhere
Name a subquery (CTE)WITH big_orders AS (SELECT * FROM orders WHERE total > 100) SELECT user_id, COUNT(*) FROM big_orders GROUP BY user_id;
Several CTEsWITH paid AS (SELECT * FROM orders WHERE status = 'paid'), spend AS (SELECT user_id, SUM(total) AS spent FROM paid GROUP BY user_id) SELECT * FROM spend WHERE spent > 100;Separated by commas. Each can read the ones before it
Count from 1 to 10WITH RECURSIVE n (i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 10) SELECT i FROM n;A recursive CTE. PostgreSQL and MySQL need the word RECURSIVE; SQLite does not mind either way

UNION, INTERSECT and EXCEPT

These stack the results of two queries. Both must return the same number of columns, with types that match, and the column names come from the first query.

TaskSQLNotes
In either, duplicates removedSELECT name FROM users UNION SELECT name FROM employees;
In either, duplicates keptSELECT name FROM users UNION ALL SELECT name FROM employees;Faster, since nothing is checked. Use it when duplicates cannot happen or do not matter
In bothSELECT name FROM users INTERSECT SELECT name FROM employees;MySQL 8.0.31 and later
In the first but not the secondSELECT name FROM users EXCEPT SELECT name FROM employees;MySQL 8.0.31 and later
Sort the combined resultSELECT name FROM users UNION SELECT name FROM employees ORDER BY name;One ORDER BY at the very end sorts everything

INSERT, UPDATE and DELETE

TaskSQLNotes
Insert a rowINSERT INTO users (name, email) VALUES ('Edsger', 'edsger@example.com');Columns you leave out get their default, or NULL
Insert several rowsINSERT INTO users (name, email) VALUES ('Edsger', 'edsger@example.com'), ('Barbara', 'barbara@example.com');One statement is much faster than one INSERT per row
Insert the result of a queryINSERT INTO archived_orders SELECT * FROM orders WHERE created_at < '2026-01-01';Columns are matched by position, not by name
Get the new id backINSERT INTO users (name, email) VALUES ('Edsger', 'edsger@example.com') RETURNING id;PostgreSQL and SQLite. MySQL: run SELECT LAST_INSERT_ID(); straight after
Update rowsUPDATE users SET active = FALSE WHERE id = 1;Without WHERE, every row in the table changes
Update several columnsUPDATE users SET name = 'Ada Lovelace', role = 'admin' WHERE id = 1;
Update from the current valueUPDATE products SET price = price * 1.1 WHERE category = 'books';
Update using another tableUPDATE orders SET status = 'vip' FROM users WHERE users.id = orders.user_id AND users.role = 'owner';PostgreSQL and SQLite
Update using another table, MySQLUPDATE orders o JOIN users u ON u.id = o.user_id SET o.status = 'vip' WHERE u.role = 'owner';MySQL
Delete rowsDELETE FROM users WHERE active = FALSE;Without WHERE, every row goes
Delete every rowDELETE FROM order_items;The table stays, empty
Empty a big table fastTRUNCATE TABLE order_items;PostgreSQL and MySQL. SQLite has no TRUNCATE; DELETE with no WHERE is already fast there
Insert or update (upsert)INSERT INTO users (email, name) VALUES ('ada@example.com', 'Ada') ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;PostgreSQL and SQLite. Needs a unique constraint on email. EXCLUDED is the row you tried to insert
Insert or update, MySQLINSERT INTO users (email, name) VALUES ('ada@example.com', 'Ada') AS new ON DUPLICATE KEY UPDATE name = new.name;MySQL 8.0.19 and later. The older VALUES(name) form is deprecated
Insert unless it already existsINSERT INTO users (email, name) VALUES ('ada@example.com', 'Ada') ON CONFLICT (email) DO NOTHING;PostgreSQL and SQLite. MySQL has INSERT IGNORE, which also hides other errors
All or nothing (transaction)BEGIN; COMMIT;Statements in between are saved together at COMMIT. MySQL also takes START TRANSACTION
Undo the transactionROLLBACK;Instead of COMMIT. Nothing since BEGIN is saved
Undo part of a transactionSAVEPOINT before_delete; ROLLBACK TO SAVEPOINT before_delete;Inside BEGIN. Keeps what came before the savepoint

Before running an UPDATE or DELETE on data you care about, run it as a SELECT with the same WHERE and check the rows it finds. Better still, run it inside BEGIN, check, then COMMIT or ROLLBACK. MySQL and SQLite save each statement straight away unless a transaction is open.

CREATE, ALTER and DROP tables

TaskSQLNotes
Create a tableCREATE TABLE tags (id INTEGER PRIMARY KEY, name VARCHAR(50) NOT NULL);You supply the id yourself, except in SQLite
Auto-numbered id, PostgreSQLCREATE TABLE tags (id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR(50) NOT NULL);The SQL standard form. SERIAL is the older PostgreSQL shorthand
Auto-numbered id, MySQLCREATE TABLE tags (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL);
Auto-numbered id, SQLiteCREATE TABLE tags (id INTEGER PRIMARY KEY, name VARCHAR(50) NOT NULL);Must be spelled INTEGER exactly. INT PRIMARY KEY does not number itself
Create only if missingCREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY, name VARCHAR(50) NOT NULL);
Create a table from a queryCREATE TABLE paid_orders AS SELECT * FROM orders WHERE status = 'paid';Copies the columns and rows, not the keys or indexes
Add a columnALTER TABLE users ADD COLUMN phone VARCHAR(20);
Add a column with a defaultALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT 0;Existing rows get the default
Rename a columnALTER TABLE users RENAME COLUMN name TO full_name;MySQL 8.0 and SQLite 3.25 onwards
Remove a columnALTER TABLE users DROP COLUMN age;SQLite 3.35 onwards, and not for a column in a key or index
Change a column's typeALTER TABLE users ALTER COLUMN age TYPE BIGINT;PostgreSQL. MySQL: ALTER TABLE users MODIFY age BIGINT; SQLite cannot, so rebuild the table
Rename a tableALTER TABLE users RENAME TO customers;
Delete a tableDROP TABLE tags;The data goes with it. There is no undo outside a transaction
Delete only if it existsDROP TABLE IF EXISTS tags;
Save a query as a viewCREATE VIEW active_users AS SELECT * FROM users WHERE deleted_at IS NULL;Then SELECT * FROM active_users. It stores the query, not the rows

Constraints and indexes

Constraints are rules the database enforces on every write. The first rows here are column and table definitions that go inside CREATE TABLE ( ... ).

TaskSQLNotes
Primary keyid INTEGER PRIMARY KEYUnique and never NULL. One per table
Primary key over two columnsPRIMARY KEY (order_id, product_id)After the columns. The pair must be unique
Requiredname VARCHAR(100) NOT NULL
No duplicatesemail VARCHAR(255) UNIQUESeveral NULLs are still allowed, on all three
Default valuecreated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
Check a ruleprice DECIMAL(10, 2) CHECK (price >= 0)MySQL enforces CHECK from 8.0.16. Older versions accepted it and did nothing
Foreign keyFOREIGN KEY (user_id) REFERENCES users (id)Write it as its own line. MySQL 8.4 silently ignores the short form, user_id INTEGER REFERENCES users (id)
Delete children with their parentFOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADEON DELETE SET NULL and ON DELETE RESTRICT are the other usual choices
Turn foreign keys on in SQLitePRAGMA foreign_keys = ON;SQLite only. Off by default, and set per connection
Add a rule to an existing tableALTER TABLE orders ADD CONSTRAINT orders_total_not_negative CHECK (total >= 0);PostgreSQL and MySQL. Recent SQLite takes a CHECK here (3.53 does, 3.45 did not), but UNIQUE and FOREIGN KEY still mean rebuilding the table
Index a columnCREATE INDEX idx_orders_created_at ON orders (created_at);Speeds up WHERE, JOIN and ORDER BY on that column. Slows writes a little
Unique indexCREATE UNIQUE INDEX idx_products_name ON products (name);
Index over two columnsCREATE INDEX idx_orders_user_date ON orders (user_id, created_at);Helps filters on user_id, or on both. Not on created_at alone
Index only some rowsCREATE INDEX idx_users_live_email ON users (email) WHERE deleted_at IS NULL;A partial index. PostgreSQL and SQLite
Remove an indexDROP INDEX idx_orders_created_at;MySQL needs the table: DROP INDEX idx_orders_created_at ON orders;
See how a query will runEXPLAIN SELECT * FROM orders WHERE user_id = 1;SQLite wants EXPLAIN QUERY PLAN. EXPLAIN ANALYZE also runs it and shows real timings in PostgreSQL and MySQL

PostgreSQL does not index a foreign key column for you, and SQLite does not either; MySQL does. Without one, a join on that column and every delete from the parent table scan the whole child table.

The four joins, side by side

Joins are easier to see than to describe. Take three users and four orders. Linus has never ordered, and order 13 was a guest checkout with no user. This setup runs unchanged on all three databases.

CREATE TABLE users (id INTEGER PRIMARY KEY, name VARCHAR(50));
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, total DECIMAL(10, 2));
 
INSERT INTO users VALUES (1, 'Ada'), (2, 'Grace'), (3, 'Linus');
INSERT INTO orders VALUES (10, 1, 25.00), (11, 1, 40.00), (12, 2, 15.00), (13, NULL, 30.00);

Each join below is SELECT u.name, o.id, o.total FROM users u <join> orders o ON o.user_id = u.id, with only the join changed.

JoinRowsWhat you get
INNER JOIN3Ada 10, Ada 11, Grace 12. Linus and order 13 have no match, so both are dropped
LEFT JOIN4The three above, plus Linus with NULL for the order
RIGHT JOIN4The three above, plus order 13 with NULL for the name
FULL OUTER JOIN5All of it: the three matches, Linus and order 13
CROSS JOIN (no ON)12Every user paired with every order, 3 times 4

MySQL has no FULL OUTER JOIN. Get the same five rows by stacking a LEFT JOIN on the unmatched rows of a RIGHT JOIN:

SELECT u.name, o.id, o.total
FROM users u LEFT JOIN orders o ON o.user_id = u.id
UNION ALL
SELECT u.name, o.id, o.total
FROM users u RIGHT JOIN orders o ON o.user_id = u.id
WHERE u.id IS NULL;

UNION ALL and the WHERE u.id IS NULL together keep each row once. A plain UNION would also work here, but it would merge any rows that are genuinely identical, so the WHERE form is the safe one.

Top N per group with a window function

"The biggest order for each customer" is one of the most searched SQL questions, and GROUP BY alone cannot answer it: MAX(total) gives the amount but not the rest of the row. Number the rows within each customer, then keep the first.

WITH ranked AS (
  SELECT
    o.user_id,
    o.id AS order_id,
    o.total,
    ROW_NUMBER() OVER (PARTITION BY o.user_id ORDER BY o.total DESC, o.id) AS rn
  FROM orders o
  WHERE o.user_id IS NOT NULL
)
SELECT u.name, r.order_id, r.total
FROM ranked r
JOIN users u ON u.id = r.user_id
WHERE r.rn = 1
ORDER BY r.total DESC;

On the tables above this returns two rows: Ada with order 11 for 40.00, and Grace with order 12 for 15.00. Change r.rn = 1 to r.rn <= 3 for the top three per customer. Use RANK() instead of ROW_NUMBER() if tied orders should all come back; the o.id in the ORDER BY only decides which tied row wins when you want exactly one.

A recursive CTE: walking an org chart

A recursive CTE starts from some rows, then joins back to itself until no new rows turn up. It is how SQL walks a tree, such as employees and their managers or categories inside categories.

CREATE TABLE employees (id INTEGER PRIMARY KEY, name VARCHAR(50), manager_id INTEGER);
INSERT INTO employees VALUES (1, 'Ada', NULL), (2, 'Grace', 1), (3, 'Alan', 1), (4, 'Linus', 2);
 
WITH RECURSIVE chain (id, name, manager_id, depth) AS (
  SELECT id, name, manager_id, 0
  FROM employees
  WHERE manager_id IS NULL                -- start at the top
 
  UNION ALL
 
  SELECT e.id, e.name, e.manager_id, c.depth + 1
  FROM employees e
  JOIN chain c ON e.manager_id = c.id     -- then everyone who reports to a row found so far
)
SELECT name, depth
FROM chain
ORDER BY depth, name;
name   depth
Ada    0
Alan   1
Grace  1
Linus  2

The WHERE manager_id IS NULL part runs once. The part after UNION ALL runs again and again, each time on the rows the last round found, and stops when a round finds nothing. If the data has a loop, such as two people managing each other, it never stops: add WHERE c.depth < 20 to the second part as a guard.

Parameters, not string building

Queries in application code take values from users, and pasting those values into the SQL string is how SQL injection happens. Every database library has placeholders instead. Here it is with Python's built-in sqlite3 module:

import sqlite3
 
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT UNIQUE)")
 
with conn:  # commits on success, rolls back on an exception
    conn.executemany(
        "INSERT INTO users (name, email) VALUES (?, ?)",
        [("Ada", "ada@example.com"), ("Grace", "grace@example.com")],
    )
 
email = "ada@example.com' OR '1'='1"  # an attacker's input
row = conn.execute("SELECT id, name FROM users WHERE email = ?", (email,)).fetchone()
print(row)  # None: the whole string was compared as an email address
 
row = conn.execute("SELECT id, name FROM users WHERE email = ?", ("ada@example.com",)).fetchone()
print(row)  # (1, 'Ada')

The ? is filled in by the database, never by string formatting, so the quote in the attacker's input is just a character in an email address. PostgreSQL's psycopg uses %s for the same job and PHP's PDO takes ? or :name. The Python cheat sheet covers the rest of the language, and the PHP cheat sheet has the same pattern with PDO. In R, DBI takes the same ? placeholders, and the R cheat sheet shows dplyr writing the WHERE, GROUP BY and HAVING for you.

Dialect differences at a glance

The places where PostgreSQL, MySQL and SQLite most often disagree. Everything here was checked on the versions named at the top of the page.

TaskPostgreSQLMySQLSQLite
Auto-numbered idGENERATED ALWAYS AS IDENTITYAUTO_INCREMENTINTEGER PRIMARY KEY
Quote a name"order"`order`"order"
Join text|| or CONCATCONCAT (|| means OR)|| or CONCAT
UpsertON CONFLICT ... DO UPDATEON DUPLICATE KEY UPDATEON CONFLICT ... DO UPDATE
New row's idRETURNING idLAST_INSERT_ID()RETURNING id
Join values into a stringSTRING_AGGGROUP_CONCATSTRING_AGG or GROUP_CONCAT
FULL OUTER JOINYesNoYes
NULLS FIRST / NULLS LASTYesNoYes
NULLs in an ascending sortLastFirstFirst
7 / 233.50003
'Ada' = 'ada'FalseTrueFalse
Regular expressions~REGEXPNot built in
Boolean typeBOOLEANTINYINT(1), with TRUE as 1Stored as 1 and 0
Column typesEnforcedEnforcedLoose unless the table is STRICT

Getting around psql, mysql and sqlite3

Each database ships with a command-line client, and each has its own shortcuts for the questions SQL itself does not answer, such as "what tables are there".

Taskpsql (PostgreSQL)mysql (MySQL)sqlite3 (SQLite)
Connectpsql -U postgres -d shopmysql -u root -p shopsqlite3 shop.db
List databases\lSHOW DATABASES;.databases
Switch database\c shopUSE shop;.open shop.db
List tables\dtSHOW TABLES;.tables
Describe a table\d usersDESCRIBE users;.schema users
Run a file of SQL\i setup.sqlsource setup.sql.read setup.sql
Show one column per line\xEnd the query with \G.mode line
Get help\?help.help
Quit\qexit.quit

To try any of it without installing a server, sqlite3 is a single program that keeps the whole database in one file, and Python ships with SQLite built in. For PostgreSQL or MySQL, the Docker cheat sheet has a compose file that starts PostgreSQL next to an app.

Gotchas

The mistakes almost everyone makes in their first month of SQL.

Looks rightWhat actually happensDo this instead
WHERE email = NULLMatches nothing, not even NULLsWHERE email IS NULL
WHERE status NOT IN (SELECT status FROM ...)No rows at all if the subquery returns a single NULLNOT EXISTS, or filter the NULLs out of the subquery
UPDATE users SET active = FALSE;Every row changed: the WHERE was forgottenRun the WHERE as a SELECT first, inside BEGIN
LEFT JOIN orders o ... WHERE o.status = 'paid'Users with no orders disappear: the join is now an inner joinMove the condition into ON
COUNT(*) after a LEFT JOINUsers with no orders count as 1COUNT(o.id)
SUM(total) on no rowsNULL, not 0COALESCE(SUM(total), 0)
SELECT 7 / 23 in PostgreSQL and SQLite: whole numbers divide to a whole number7.0 / 2, or multiply by 100.0 first
total / SUM(total) OVER () in SQLite0 for every row: SQLite stores 25.00 as the whole number 25100.0 * total / SUM(total) OVER ()
CAST(2.5 AS INTEGER)3 in PostgreSQL, 2 in SQLite, a syntax error in MySQLROUND first, and say what you mean
WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31'Misses most of 31 January when the column holds a time>= '2026-01-01' AND < '2026-02-01'
LIMIT 10 with no ORDER BYSome 10 rows, not necessarily the same 10 next timeAlways ORDER BY a unique column
"Ada" for a stringA column name. PostgreSQL fails; SQLite only falls back to a string when no column is called Ada'Ada'
user_id INTEGER REFERENCES users (id) in MySQL 8.4Accepted and ignored: no foreign key is createdFOREIGN KEY (user_id) REFERENCES users (id)
Foreign keys in SQLiteNot checked unless you turn them onPRAGMA foreign_keys = ON; on every connection
GROUP_CONCAT(name, ', ') in MySQLGlues each name to a comma instead of using it as the separatorGROUP_CONCAT(name SEPARATOR ', ')
f"SELECT * FROM users WHERE name = '{name}'" in codeSQL injectionA ? or %s placeholder, as in the example above

When an ORDER BY cannot use an index, PostgreSQL sorts in memory with a quick sort, keeps only the rows it needs in a heap when there is a LIMIT, and switches to an external merge sort on disk when the rows do not fit in memory. EXPLAIN ANALYZE names the one it picked as the sort method. All three are on the site as step-through visualisations.

Common questions

Which SQL does this cheat sheet cover?

Standard SQL, as PostgreSQL, MySQL and SQLite speak it. Every snippet was run on PostgreSQL 18.6, MySQL 8.4.11, the current long-term support release, and SQLite 3.53.4. Most of the page works unchanged on all three. Where they differ, the notes column says which database does what and gives the alternative. SQL Server and Oracle follow the same standard for everything in the core tables, but have their own spelling for things like row limits, which this page does not cover.

What is the difference between WHERE and HAVING?

WHERE filters rows before they are grouped, and HAVING filters groups after GROUP BY has made them. So a condition on a plain column, like country = 'GB', goes in WHERE, and a condition on an aggregate, like COUNT(*) > 10, goes in HAVING. Filtering in WHERE where you can is also faster, because fewer rows reach the grouping step.

What is the difference between INNER JOIN and LEFT JOIN?

An INNER JOIN returns only the rows that have a match in both tables. A LEFT JOIN returns every row from the left table, the one before the word JOIN, and fills the right table's columns with NULL where there is no match. Use INNER JOIN for orders with their customer, and LEFT JOIN for every customer with their orders if they have any. The worked example on this page shows all four joins on the same rows.

What is the difference between UNION and UNION ALL?

UNION stacks the results of two queries and removes duplicate rows. UNION ALL stacks them and keeps everything. UNION ALL is faster because the database does not have to compare every row, so use it whenever duplicates are impossible or you want them kept.

When should I use a CTE instead of a subquery?

Use a CTE, written with WITH, when a query has more than one step or uses the same subquery twice. It gives each step a name and lets the query read top to bottom. A short subquery used once, like WHERE total > (SELECT AVG(total) FROM orders), is fine inline. PostgreSQL 12 and later, MySQL and SQLite can usually fold a simple CTE into the main query just as they would a subquery, so the choice is mostly about readability. You need a CTE for recursion, such as walking a tree of managers.

What is the difference between DELETE, TRUNCATE and DROP?

DELETE removes rows and can take a WHERE clause to remove only some. TRUNCATE removes every row at once and is much faster on a big table, but takes no WHERE; SQLite does not have it. DROP TABLE removes the table itself, structure and data. In PostgreSQL all three can be rolled back inside a transaction. In MySQL, TRUNCATE and DROP commit straight away and cannot be undone.

Is SQL case-sensitive?

Keywords are not: SELECT, select and Select are the same everywhere. Table and column names depend on the database: PostgreSQL folds unquoted names to lower case, MySQL table names follow the file system, so they are case-sensitive on Linux, and SQLite ignores case. Comparing text is the part that catches people out. In PostgreSQL 'Ada' = 'ada' is false. In MySQL's default collation it is true. In SQLite, = is case-sensitive but LIKE ignores case for plain English letters.

How do I stop SQL injection?

Never build a query by pasting user input into the SQL string. Use parameters, the ? or %s or :name placeholders your database library provides, and pass the values separately. The database then treats them as data, never as SQL. Table names, column names and ASC or DESC cannot be parameters, so check those against a fixed list of allowed values. The worked example on this page shows it in Python.

See all cheat sheets

Want this explained by a cat?

The videos cover the same ground in sixty seconds. If there is a tool you want a cheat sheet for next, ask.