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.
Searches the task, the command and the third column. Press / from anywhere on the page.
147 commands
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.
| Task | SQL | Notes |
|---|---|---|
| Select every column | SELECT * FROM users; | Fine for exploring. Name the columns in real code |
| Select some columns | SELECT id, name FROM users; | |
| Rename a column in the result | SELECT name AS full_name FROM users; | An alias. AS is optional but easier to read |
| Calculate a column | SELECT name, price * 1.2 AS price_with_vat FROM products; | |
| Unique values | SELECT DISTINCT country FROM users; | |
| Unique combinations | SELECT DISTINCT country, role FROM users; | Distinct across both columns together, not each one |
| First 10 rows | SELECT * 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 limit | SELECT * FROM users ORDER BY id FETCH FIRST 10 ROWS ONLY; | PostgreSQL only of the three. MySQL and SQLite need LIMIT |
| Count the rows | SELECT COUNT(*) FROM users; | |
| Join text together | SELECT 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 default | SELECT name, COALESCE(country, 'unknown') FROM users; | Returns the first argument that is not NULL |
| If and else | SELECT name, CASE WHEN age < 18 THEN 'minor' ELSE 'adult' END AS age_group FROM users; | A NULL age falls through to ELSE |
| Round a number | SELECT ROUND(price, 1) FROM products; | |
| Convert a type | SELECT 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 date | SELECT CURRENT_DATE; | SQLite returns text, 2026-09-26, in UTC |
| Current date and time | SELECT 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
| Task | SQL | Notes |
|---|---|---|
| Compare | SELECT * FROM users WHERE age >= 18; | = < > <= >= all work as you expect. Equals is one = |
| Not equal | SELECT * FROM users WHERE role <> 'admin'; | != works too. Rows where role is NULL are not returned |
| Both must be true | SELECT * FROM users WHERE age >= 18 AND country = 'US'; | |
| Either can be true | SELECT * FROM users WHERE role = 'admin' OR role = 'owner'; | |
| Mix AND and OR | SELECT * FROM users WHERE country = 'US' AND (role = 'admin' OR role = 'owner'); | AND binds tighter than OR, so brackets are not optional here |
| Match a list | SELECT * FROM users WHERE country IN ('US', 'CA', 'MX'); | |
| Not in a list | SELECT * FROM orders WHERE status NOT IN ('cancelled', 'refunded'); | Rows where status is NULL are not returned either |
| In a range | SELECT * FROM products WHERE price BETWEEN 10 AND 50; | Includes both ends: 10 and 50 match |
| Dates in a month | SELECT * 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 with | SELECT * FROM users WHERE name LIKE 'A%'; | % matches any run of characters, including none |
| Ends with | SELECT * FROM users WHERE email LIKE '%@gmail.com'; | A leading % cannot use a normal index, so it is slow on big tables |
| Exactly one character | SELECT * FROM users WHERE name LIKE '_a%'; | _ matches one character. This finds names whose second letter is a |
| Ignore upper and lower case | SELECT * 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 expression | SELECT * FROM users WHERE email REGEXP '^[a-z]+@'; | MySQL. PostgreSQL writes email ~ '^[a-z]+@'. SQLite has no regex built in |
| Has no value | SELECT * FROM users WHERE deleted_at IS NULL; | = NULL never matches anything, not even NULL |
| Has a value | SELECT * FROM users WHERE deleted_at IS NOT NULL; | |
| Not equal, treating NULL as a value | SELECT * 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 table | SELECT * 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
| Task | SQL | Notes |
|---|---|---|
| Sort, smallest first | SELECT * FROM users ORDER BY created_at; | ASC is the default |
| Sort, largest first | SELECT * FROM users ORDER BY created_at DESC; | |
| Sort by several columns | SELECT * FROM users ORDER BY country, name DESC; | DESC applies to name only. country is still ascending |
| Put NULLs last | SELECT * FROM users ORDER BY age DESC NULLS LAST; | PostgreSQL and SQLite. MySQL: ORDER BY age IS NULL, age DESC |
| Sort by a calculated column | SELECT id, total * 1.2 AS gross FROM orders ORDER BY gross DESC; | ORDER BY can use an alias from SELECT. WHERE cannot |
| Random order | SELECT * FROM products ORDER BY RANDOM() LIMIT 1; | PostgreSQL and SQLite. MySQL spells it RAND(). Slow on big tables |
| Group rows | SELECT country, COUNT(*) FROM users GROUP BY country; | One row per country |
| Group by several columns | SELECT country, role, COUNT(*) FROM users GROUP BY country, role; | One row per combination |
| Filter the groups | SELECT country, COUNT(*) FROM users GROUP BY country HAVING COUNT(*) > 100; | |
| Filter rows, then groups | SELECT country, COUNT(*) FROM users WHERE active = TRUE GROUP BY country HAVING COUNT(*) >= 10; | WHERE runs before grouping, HAVING after it |
| Biggest groups first | SELECT country, COUNT(*) AS n FROM users GROUP BY country ORDER BY n DESC; | |
| Subtotals and a grand total | SELECT 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.
| Join | SQL | Returns |
|---|---|---|
| INNER JOIN | SELECT 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 JOIN | SELECT 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 JOIN | SELECT 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 JOIN | SELECT 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 JOIN | SELECT s.size, c.colour FROM sizes s CROSS JOIN colours c; | Every combination: 3 sizes and 4 colours give 12 rows |
| Self join | SELECT 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 tables | SELECT 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 match | SELECT 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 row | SELECT 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 table | SELECT 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.
| Function | SQL | Notes |
|---|---|---|
| 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 |
| SUM | SELECT SUM(total) FROM orders; | NULL, not 0, when there are no rows. COALESCE(SUM(total), 0) fixes that |
| AVG | SELECT AVG(total) FROM orders; | NULLs are left out, not counted as 0 |
| MIN and MAX | SELECT MIN(total), MAX(total) FROM orders; | Work on text and dates too |
| Per group | SELECT user_id, SUM(total) FROM orders GROUP BY user_id; | |
| Round the result | SELECT ROUND(AVG(total), 2) FROM orders; | |
| Join values into one string | SELECT 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, MySQL | SELECT 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 array | SELECT JSON_ARRAYAGG(name) FROM users; | PostgreSQL and MySQL. SQLite: json_group_array(name) |
| Count only some rows | SELECT COUNT(*) FILTER (WHERE status = 'paid') FROM orders; | PostgreSQL and SQLite. Not MySQL |
| Count only some rows, anywhere | SELECT 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.
| Task | SQL | Notes |
|---|---|---|
| Number the rows | SELECT 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 ties | SELECT id, total, RANK() OVER (ORDER BY total DESC) FROM orders; | 1, 2, 2, 4 |
| Rank, no gaps | SELECT id, total, DENSE_RANK() OVER (ORDER BY total DESC) FROM orders; | 1, 2, 2, 3 |
| Rank within each group | SELECT user_id, total, RANK() OVER (PARTITION BY user_id ORDER BY total DESC) FROM orders; | Numbering starts again for each user |
| Running total | SELECT 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 group | SELECT user_id, total, SUM(total) OVER (PARTITION BY user_id ORDER BY created_at, id) FROM orders; | |
| Moving average | SELECT 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 total | SELECT 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 row | SELECT 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 row | SELECT created_at, total, LEAD(total) OVER (ORDER BY created_at, id) FROM orders; | |
| Change since the previous row | SELECT 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 group | SELECT user_id, total, FIRST_VALUE(total) OVER (PARTITION BY user_id ORDER BY created_at) AS first_order FROM orders; | |
| Split into equal buckets | SELECT id, total, NTILE(4) OVER (ORDER BY total) AS quartile FROM orders; | |
| Reuse one window | SELECT 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.
| Task | SQL | Notes |
|---|---|---|
| Compare with one calculated value | SELECT * FROM orders WHERE total > (SELECT AVG(total) FROM orders); | The subquery must return one row and one column |
| Match a list from another query | SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 100); | |
| No match in another query | SELECT * 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 row | SELECT 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 query | SELECT 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 CTEs | WITH 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 10 | WITH 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.
| Task | SQL | Notes |
|---|---|---|
| In either, duplicates removed | SELECT name FROM users UNION SELECT name FROM employees; | |
| In either, duplicates kept | SELECT 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 both | SELECT name FROM users INTERSECT SELECT name FROM employees; | MySQL 8.0.31 and later |
| In the first but not the second | SELECT name FROM users EXCEPT SELECT name FROM employees; | MySQL 8.0.31 and later |
| Sort the combined result | SELECT name FROM users UNION SELECT name FROM employees ORDER BY name; | One ORDER BY at the very end sorts everything |
INSERT, UPDATE and DELETE
| Task | SQL | Notes |
|---|---|---|
| Insert a row | INSERT INTO users (name, email) VALUES ('Edsger', 'edsger@example.com'); | Columns you leave out get their default, or NULL |
| Insert several rows | INSERT 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 query | INSERT INTO archived_orders SELECT * FROM orders WHERE created_at < '2026-01-01'; | Columns are matched by position, not by name |
| Get the new id back | INSERT INTO users (name, email) VALUES ('Edsger', 'edsger@example.com') RETURNING id; | PostgreSQL and SQLite. MySQL: run SELECT LAST_INSERT_ID(); straight after |
| Update rows | UPDATE users SET active = FALSE WHERE id = 1; | Without WHERE, every row in the table changes |
| Update several columns | UPDATE users SET name = 'Ada Lovelace', role = 'admin' WHERE id = 1; | |
| Update from the current value | UPDATE products SET price = price * 1.1 WHERE category = 'books'; | |
| Update using another table | UPDATE orders SET status = 'vip' FROM users WHERE users.id = orders.user_id AND users.role = 'owner'; | PostgreSQL and SQLite |
| Update using another table, MySQL | UPDATE orders o JOIN users u ON u.id = o.user_id SET o.status = 'vip' WHERE u.role = 'owner'; | MySQL |
| Delete rows | DELETE FROM users WHERE active = FALSE; | Without WHERE, every row goes |
| Delete every row | DELETE FROM order_items; | The table stays, empty |
| Empty a big table fast | TRUNCATE 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, MySQL | INSERT 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 exists | INSERT 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 transaction | ROLLBACK; | Instead of COMMIT. Nothing since BEGIN is saved |
| Undo part of a transaction | SAVEPOINT 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
| Task | SQL | Notes |
|---|---|---|
| Create a table | CREATE TABLE tags (id INTEGER PRIMARY KEY, name VARCHAR(50) NOT NULL); | You supply the id yourself, except in SQLite |
| Auto-numbered id, PostgreSQL | CREATE 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, MySQL | CREATE TABLE tags (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL); | |
| Auto-numbered id, SQLite | CREATE 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 missing | CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY, name VARCHAR(50) NOT NULL); | |
| Create a table from a query | CREATE TABLE paid_orders AS SELECT * FROM orders WHERE status = 'paid'; | Copies the columns and rows, not the keys or indexes |
| Add a column | ALTER TABLE users ADD COLUMN phone VARCHAR(20); | |
| Add a column with a default | ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT 0; | Existing rows get the default |
| Rename a column | ALTER TABLE users RENAME COLUMN name TO full_name; | MySQL 8.0 and SQLite 3.25 onwards |
| Remove a column | ALTER TABLE users DROP COLUMN age; | SQLite 3.35 onwards, and not for a column in a key or index |
| Change a column's type | ALTER TABLE users ALTER COLUMN age TYPE BIGINT; | PostgreSQL. MySQL: ALTER TABLE users MODIFY age BIGINT; SQLite cannot, so rebuild the table |
| Rename a table | ALTER TABLE users RENAME TO customers; | |
| Delete a table | DROP TABLE tags; | The data goes with it. There is no undo outside a transaction |
| Delete only if it exists | DROP TABLE IF EXISTS tags; | |
| Save a query as a view | CREATE 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 ( ... ).
| Task | SQL | Notes |
|---|---|---|
| Primary key | id INTEGER PRIMARY KEY | Unique and never NULL. One per table |
| Primary key over two columns | PRIMARY KEY (order_id, product_id) | After the columns. The pair must be unique |
| Required | name VARCHAR(100) NOT NULL | |
| No duplicates | email VARCHAR(255) UNIQUE | Several NULLs are still allowed, on all three |
| Default value | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |
| Check a rule | price DECIMAL(10, 2) CHECK (price >= 0) | MySQL enforces CHECK from 8.0.16. Older versions accepted it and did nothing |
| Foreign key | FOREIGN 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 parent | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE | ON DELETE SET NULL and ON DELETE RESTRICT are the other usual choices |
| Turn foreign keys on in SQLite | PRAGMA foreign_keys = ON; | SQLite only. Off by default, and set per connection |
| Add a rule to an existing table | ALTER 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 column | CREATE INDEX idx_orders_created_at ON orders (created_at); | Speeds up WHERE, JOIN and ORDER BY on that column. Slows writes a little |
| Unique index | CREATE UNIQUE INDEX idx_products_name ON products (name); | |
| Index over two columns | CREATE 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 rows | CREATE INDEX idx_users_live_email ON users (email) WHERE deleted_at IS NULL; | A partial index. PostgreSQL and SQLite |
| Remove an index | DROP INDEX idx_orders_created_at; | MySQL needs the table: DROP INDEX idx_orders_created_at ON orders; |
| See how a query will run | EXPLAIN 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.
| Join | Rows | What you get |
|---|---|---|
INNER JOIN | 3 | Ada 10, Ada 11, Grace 12. Linus and order 13 have no match, so both are dropped |
LEFT JOIN | 4 | The three above, plus Linus with NULL for the order |
RIGHT JOIN | 4 | The three above, plus order 13 with NULL for the name |
FULL OUTER JOIN | 5 | All of it: the three matches, Linus and order 13 |
CROSS JOIN (no ON) | 12 | Every 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 2The 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.
| Task | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Auto-numbered id | GENERATED ALWAYS AS IDENTITY | AUTO_INCREMENT | INTEGER PRIMARY KEY |
| Quote a name | "order" | `order` | "order" |
| Join text | || or CONCAT | CONCAT (|| means OR) | || or CONCAT |
| Upsert | ON CONFLICT ... DO UPDATE | ON DUPLICATE KEY UPDATE | ON CONFLICT ... DO UPDATE |
| New row's id | RETURNING id | LAST_INSERT_ID() | RETURNING id |
| Join values into a string | STRING_AGG | GROUP_CONCAT | STRING_AGG or GROUP_CONCAT |
FULL OUTER JOIN | Yes | No | Yes |
NULLS FIRST / NULLS LAST | Yes | No | Yes |
| NULLs in an ascending sort | Last | First | First |
7 / 2 | 3 | 3.5000 | 3 |
'Ada' = 'ada' | False | True | False |
| Regular expressions | ~ | REGEXP | Not built in |
| Boolean type | BOOLEAN | TINYINT(1), with TRUE as 1 | Stored as 1 and 0 |
| Column types | Enforced | Enforced | Loose 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".
| Task | psql (PostgreSQL) | mysql (MySQL) | sqlite3 (SQLite) |
|---|---|---|---|
| Connect | psql -U postgres -d shop | mysql -u root -p shop | sqlite3 shop.db |
| List databases | \l | SHOW DATABASES; | .databases |
| Switch database | \c shop | USE shop; | .open shop.db |
| List tables | \dt | SHOW TABLES; | .tables |
| Describe a table | \d users | DESCRIBE users; | .schema users |
| Run a file of SQL | \i setup.sql | source setup.sql | .read setup.sql |
| Show one column per line | \x | End the query with \G | .mode line |
| Get help | \? | help | .help |
| Quit | \q | exit | .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 right | What actually happens | Do this instead |
|---|---|---|
WHERE email = NULL | Matches nothing, not even NULLs | WHERE email IS NULL |
WHERE status NOT IN (SELECT status FROM ...) | No rows at all if the subquery returns a single NULL | NOT EXISTS, or filter the NULLs out of the subquery |
UPDATE users SET active = FALSE; | Every row changed: the WHERE was forgotten | Run 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 join | Move the condition into ON |
COUNT(*) after a LEFT JOIN | Users with no orders count as 1 | COUNT(o.id) |
SUM(total) on no rows | NULL, not 0 | COALESCE(SUM(total), 0) |
SELECT 7 / 2 | 3 in PostgreSQL and SQLite: whole numbers divide to a whole number | 7.0 / 2, or multiply by 100.0 first |
total / SUM(total) OVER () in SQLite | 0 for every row: SQLite stores 25.00 as the whole number 25 | 100.0 * total / SUM(total) OVER () |
CAST(2.5 AS INTEGER) | 3 in PostgreSQL, 2 in SQLite, a syntax error in MySQL | ROUND 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 BY | Some 10 rows, not necessarily the same 10 next time | Always ORDER BY a unique column |
"Ada" for a string | A 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.4 | Accepted and ignored: no foreign key is created | FOREIGN KEY (user_id) REFERENCES users (id) |
| Foreign keys in SQLite | Not checked unless you turn them on | PRAGMA foreign_keys = ON; on every connection |
GROUP_CONCAT(name, ', ') in MySQL | Glues each name to a comma instead of using it as the separator | GROUP_CONCAT(name SEPARATOR ', ') |
f"SELECT * FROM users WHERE name = '{name}'" in code | SQL injection | A ? 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.
