Skip to main content

SQL Quick Reference

A copy-paste reference for everyday SQL. Examples run on the practice database and show their result in a -- → comment. Syntax is PostgreSQL unless the dialect table says otherwise.

How to use this page

This page is for looking things up, not for learning from scratch. New to SQL? Start with the SQL cheat sheet, which explains each idea with an exercise. For the ordered plan with projects, see the SQL learning path.

Quick Navigation​

Querying: Clause order · Operators · Text · Numbers · NULL · Aggregates · Joins · Window functions

Structure: Data types · Tables & constraints · Indexes · Transactions · Dialects · Command-line tools

Recipes: Handy queries · One-liners


Clause order​

Written in this order — but run in a different one, which explains most "column does not exist" errors:

WrittenRunDoes
1. SELECT5Pick and compute columns
2. FROM / JOIN1Choose and combine tables
3. WHERE2Filter rows
4. GROUP BY3Make groups
5. HAVING4Filter groups
6. ORDER BY6Sort
7. LIMIT / OFFSET7Keep some rows

That's why WHERE can't use an alias defined in SELECT (it runs first), but ORDER BY can.


Operators​

OperatorExampleNote
=, <> (or !=), <, <=, >, >=price >= 20
AND, OR, NOTa AND (b OR c)AND binds tighter than OR — use brackets
IN (…) / NOT IN (…)status IN ('pending', 'shipped')NOT IN with a NULL in the list matches nothing
BETWEEN a AND bprice BETWEEN 20 AND 50Includes both ends
LIKE / ILIKEname LIKE 'A%'% any text, _ one character; ILIKE ignores case (PostgreSQL)
IS NULL / IS NOT NULLemail IS NULLNever = NULL
IS DISTINCT FROMa IS DISTINCT FROM bLike <>, but treats two NULLs as equal
EXISTS (subquery)EXISTS (SELECT 1 FROM …)True if the subquery returns any row
~email ~ '^[a-z]+@'Regular-expression match (PostgreSQL)

Text​

SELECT
UPPER('ada'), -- → ADA
LOWER('ADA'), -- → ada
LENGTH('Ada'), -- → 3
'Ada' || ' ' || 'L', -- → Ada L
CONCAT('Ada', NULL, '!'), -- → Ada! (CONCAT skips NULLs; || gives NULL)
SUBSTRING('keyboard' FROM 1 FOR 3), -- → key
TRIM(' hi '), -- → hi
REPLACE('a-b-c', '-', '+'), -- → a+b+c
POSITION('b' IN 'abc'), -- → 2
LEFT('keyboard', 3), -- → key
SPLIT_PART('ada@example.com', '@', 2), -- → example.com
LPAD('7', 3, '0'); -- → 007

Text functions differ most between databases. SQLite has no SPLIT_PART, LPAD, LEFT, or POSITION; it spells substring SUBSTR('keyboard', 1, 3) and position INSTR('abc', 'b').


Numbers​

SELECT
7 / 2, -- → 3 whole ÷ whole drops the decimals
7 / 2.0, -- → 3.5
7::numeric / 2, -- → 3.5 (CAST(7 AS NUMERIC) / 2 everywhere)
7 % 2, -- → 1
ROUND(2.567, 1), -- → 2.6
CEIL(2.1), -- → 3
FLOOR(2.9), -- → 2
ABS(-5), -- → 5
POWER(2, 10), -- → 1024
GREATEST(3, 9, 4), -- → 9 (not in SQLite: use MAX(3, 9, 4))
LEAST(3, 9, 4); -- → 3

Use NUMERIC(10, 2) (or DECIMAL) for money — REAL and FLOAT round (0.1 + 0.2 isn't exactly 0.3).


NULL​

SELECT
COALESCE(NULL, NULL, 'x'), -- → x first non-NULL
NULLIF(5, 5), -- → NULL NULL if equal (avoids ÷ 0: x / NULLIF(y, 0))
NULL = NULL, -- → NULL unknown, not true
NULL IS NULL, -- → true
1 + NULL; -- → NULL any maths with NULL is NULL

COUNT(*) counts rows; COUNT(col) skips NULLs; SUM, AVG, MIN, MAX ignore NULLs. ORDER BY col NULLS LAST puts empty values at the end.


Aggregates​

SELECT
COUNT(*) AS rows_, -- → 5
COUNT(DISTINCT category) AS categories, -- → 3
SUM(price) AS total, -- → 307.73
ROUND(AVG(price), 2) AS average, -- → 61.55
STRING_AGG(name, ', ' ORDER BY name) AS names -- → Desk, Keyboard, Lamp, Mouse, Notebook
FROM products;

SELECT category, COUNT(*) FILTER (WHERE price > 30) AS over_30 -- FILTER: PostgreSQL / SQLite
FROM products GROUP BY category ORDER BY category;
-- → electronics 1 · furniture 2 · stationery 0

STRING_AGG is GROUP_CONCAT in MySQL and SQLite.


Joins​

JoinRows returnedTypical use
a JOIN b ON …Pairs that matchOrders with their customer
a LEFT JOIN b ON …All of a; b columns NULL when no matchCustomers with or without orders
a LEFT JOIN b ON … WHERE b.id IS NULLa rows with no match ("anti-join")Customers who never ordered
a FULL JOIN b ON …All of bothCompare two lists
a CROSS JOIN bEvery combinationBuild a grid (every product × every month)
a JOIN a AS a2 ON …A table with itself ("self-join")Employee with their manager
a JOIN b USING (col)Same as ON a.col = b.col, one output columnTables that share a column name

Put conditions on the right table of a LEFT JOIN in ON, not WHERE, or the unmatched rows disappear.


Window functions​

SELECT name, price,
ROW_NUMBER() OVER w AS row_num, -- 1, 2, 3 …
RANK() OVER w AS rank_, -- ties share, then skip
DENSE_RANK() OVER w AS dense, -- ties share, no skip
NTILE(2) OVER w AS half, -- split into 2 buckets
LAG(price) OVER w AS pricier_one, -- previous row in the window
SUM(price) OVER w AS running_total, -- total so far
FIRST_VALUE(name) OVER w AS most_expensive
FROM products
WINDOW w AS (ORDER BY price DESC) -- name a window once, reuse it
ORDER BY price DESC;
-- → Desk 199.00 1 … running_total 199.00 · Keyboard 49.99 2 … 248.99 · …

Frames — which rows SUM/AVG add up:

FrameMeaning
(none, with ORDER BY)From the first row to this one (a running total)
ROWS BETWEEN 2 PRECEDING AND CURRENT ROWThis row and the 2 before — a 3-row moving average
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGThe whole partition
SELECT id, ordered_at,
COUNT(*) OVER (ORDER BY ordered_at ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS last_3
FROM orders ORDER BY ordered_at;
-- → 101 1 · 102 2 · 103 3 · 104 3 · 105 3 · 106 3

Data types​

KindPostgreSQLMySQLSQLiteSQL Server
Whole numberINTEGER, BIGINTINT, BIGINTINTEGERINT, BIGINT
Exact decimal (money)NUMERIC(10,2)DECIMAL(10,2)NUMERICDECIMAL(10,2)
TextTEXT, VARCHAR(n)VARCHAR(n), TEXTTEXTNVARCHAR(n)
True/falseBOOLEANBOOLEAN (= TINYINT(1))INTEGER 0/1BIT
DateDATEDATETEXT '2026-09-27'DATE
Moment in timeTIMESTAMPTZDATETIME / TIMESTAMPTEXT (ISO 8601)DATETIMEOFFSET
Unique idUUIDCHAR(36) / BINARY(16)TEXTUNIQUEIDENTIFIER
JSONJSONBJSONTEXT + JSON functionsNVARCHAR(MAX) + JSON functions

Tables & constraints​

CREATE TABLE IF NOT EXISTS coupons (
code TEXT PRIMARY KEY,
percent INTEGER NOT NULL CHECK (percent BETWEEN 1 AND 90),
product_id INTEGER REFERENCES products (id) ON DELETE CASCADE, -- delete coupons with the product
expires_on DATE,
active BOOLEAN NOT NULL DEFAULT TRUE
);

ALTER TABLE coupons ADD COLUMN note TEXT;
ALTER TABLE coupons RENAME COLUMN note TO description;
ALTER TABLE coupons ALTER COLUMN description SET DEFAULT ''; -- PostgreSQL
ALTER TABLE coupons ADD CONSTRAINT coupons_expiry_future CHECK (expires_on > '2026-01-01');
ALTER TABLE coupons DROP CONSTRAINT coupons_expiry_future;
ALTER TABLE coupons DROP COLUMN description;

CREATE TABLE products_backup AS SELECT * FROM products; -- copy structure and data
TRUNCATE products_backup; -- delete all rows, fast (SQLite: DELETE FROM)
DROP TABLE IF EXISTS products_backup;
ON DELETE …When the parent row is deleted
(default) NO ACTION / RESTRICTRefuse
CASCADEDelete the child rows too
SET NULLSet the foreign key to NULL

Indexes​

CREATE INDEX idx_orders_customer ON orders (customer_id);                  -- ordinary (B-tree)
CREATE INDEX idx_orders_status_date ON orders (status, ordered_at DESC); -- several columns
CREATE UNIQUE INDEX idx_customers_email ON customers (LOWER(email)); -- on an expression
CREATE INDEX idx_orders_pending ON orders (ordered_at) WHERE status = 'pending'; -- partial: only some rows
DROP INDEX idx_orders_pending;
Index type (PostgreSQL)Good for
B-tree (default)=, <, >, BETWEEN, ORDER BY, LIKE 'abc%'
Hash= only
GINJSONB, arrays, full-text search
GiST / BRINGeometric data / huge tables sorted by time

An index is usually not used for LIKE '%abc', a function on the column (WHERE LOWER(email) = … needs an index on LOWER(email)), or a column whose type doesn't match the value.


Transactions​

BEGIN;                                                  -- START TRANSACTION in MySQL
UPDATE products SET price = price * 1.1 WHERE category = 'stationery';
SAVEPOINT before_desk; -- a point you can roll back to
UPDATE products SET price = 0 WHERE id = 3;
ROLLBACK TO SAVEPOINT before_desk; -- undo only the desk change
COMMIT;
SELECT name, price FROM products WHERE id IN (3, 5) ORDER BY id;
-- → Desk 199.00 · Notebook 3.58

BEGIN ISOLATION LEVEL SERIALIZABLE; -- PostgreSQL: set the level per transaction
SELECT COUNT(*) FROM orders WHERE status = 'pending';
COMMIT;

Dialects​

The same idea, spelled by each database:

TaskPostgreSQLMySQLSQLiteSQL Server
First N rowsLIMIT 10LIMIT 10LIMIT 10SELECT TOP 10 … / FETCH FIRST 10 ROWS ONLY
Auto-numbered idGENERATED ALWAYS AS IDENTITYAUTO_INCREMENTINTEGER PRIMARY KEYIDENTITY(1,1)
Join texta || bCONCAT(a, b)a || ba + b / CONCAT(a, b)
Text list per groupSTRING_AGG(x, ',')GROUP_CONCAT(x)GROUP_CONCAT(x)STRING_AGG(x, ',')
Case-insensitive matchILIKELIKE (usually already)LIKE (ASCII)LIKE (usually already)
Today / nowCURRENT_DATE / NOW()CURDATE() / NOW()DATE('now') / DATETIME('now')CAST(GETDATE() AS DATE) / GETDATE()
Add 7 daysd + INTERVAL '7 days'DATE_ADD(d, INTERVAL 7 DAY)DATE(d, '+7 days')DATEADD(day, 7, d)
Days betweend2 - d1DATEDIFF(d2, d1)JULIANDAY(d2) - JULIANDAY(d1)DATEDIFF(day, d1, d2)
Part of a dateEXTRACT(MONTH FROM d)MONTH(d)STRFTIME('%m', d)MONTH(d)
First day of monthDATE_TRUNC('month', d)DATE_FORMAT(d, '%Y-%m-01')DATE(d, 'start of month')DATETRUNC(month, d)
Format a dateTO_CHAR(d, 'YYYY-MM')DATE_FORMAT(d, '%Y-%m')STRFTIME('%Y-%m', d)FORMAT(d, 'yyyy-MM')
Insert or updateON CONFLICT (k) DO UPDATEON DUPLICATE KEY UPDATEON CONFLICT (k) DO UPDATEMERGE
Return changed rowsRETURNING *—RETURNING *OUTPUT inserted.*
Query planEXPLAIN ANALYZEEXPLAIN ANALYZEEXPLAIN QUERY PLANSET SHOWPLAN_TEXT ON
Read a JSON fielddata ->> 'k'data ->> '$.k'JSON_EXTRACT(data, '$.k')JSON_VALUE(data, '$.k')
Quote a name"order"`order`"order"[order]

Command-line tools​

Taskpsql (PostgreSQL)mysqlsqlite3
Connectpsql -h host -U user -d shopmysql -h host -u user -p shopsqlite3 shop.db
List tables\dtSHOW TABLES;.tables
Describe a table\d ordersDESCRIBE orders;.schema orders
Run a file\i setup.sqlSOURCE setup.sql;.read setup.sql
Show query time\timing(shown by default).timer on
Readable wide rows\xend the query with \G.mode line
Export to CSV\copy (SELECT …) TO 'out.csv' CSV HEADERSELECT … INTO OUTFILE 'out.csv'.mode csv then .output out.csv
Quit\qexit.quit

Handy queries​

-- Find duplicate values
SELECT city, COUNT(*) FROM customers
WHERE city IS NOT NULL
GROUP BY city HAVING COUNT(*) > 1;
-- → London 2

-- Second most expensive product ("Nth highest")
SELECT name, price FROM products ORDER BY price DESC LIMIT 1 OFFSET 1;
-- → Keyboard 49.99

-- Top 1 per group (most expensive product per category)
SELECT category, name, price FROM (
SELECT p.*, ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
FROM products p
) ranked
WHERE rn = 1 ORDER BY category;
-- → electronics Keyboard 49.99 · furniture Desk 199.00 · stationery Notebook 3.25

-- Rows in one table but not the other
SELECT id FROM products
EXCEPT
SELECT product_id FROM order_items;
-- → 5

-- Fast "next page" (keyset pagination): remember the last id you showed
SELECT id, ordered_at FROM orders WHERE id > 103 ORDER BY id LIMIT 2;
-- → 104 2026-03-01 · 105 2026-03-20

-- Percentage of the total
SELECT category, ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM products GROUP BY category ORDER BY category;
-- → electronics 40.0 · furniture 40.0 · stationery 20.0

-- Delete duplicates, keeping the lowest id in each group
CREATE TABLE signups (id INTEGER PRIMARY KEY, email TEXT);
INSERT INTO signups VALUES (1, 'a@x.io'), (2, 'b@x.io'), (3, 'a@x.io'), (4, 'a@x.io');

DELETE FROM signups
WHERE EXISTS (
SELECT 1 FROM signups other
WHERE other.email = signups.email AND other.id < signups.id -- an older row with the same email exists
);

SELECT * FROM signups ORDER BY id;
-- → 1 a@x.io · 2 b@x.io

OFFSET pagination reads and throws away every skipped row, so page 1,000 is slow; keyset pagination (WHERE id > last_seen) stays fast.


One-liners to Remember​

TaskSQL
Count rowsSELECT COUNT(*) FROM t;
Distinct valuesSELECT DISTINCT col FROM t;
How many distinctSELECT COUNT(DISTINCT col) FROM t;
Latest rowSELECT * FROM t ORDER BY created_at DESC LIMIT 1;
Empty-safe averageCOALESCE(AVG(x), 0)
Safe divisiona / NULLIF(b, 0)
Text to numberCAST('42' AS INTEGER) or '42'::int
Yes/no as 1/0CASE WHEN cond THEN 1 ELSE 0 END
Does any row exist?SELECT EXISTS (SELECT 1 FROM t WHERE …);
Copy a table's shapeCREATE TABLE t2 (LIKE t INCLUDING ALL); (PostgreSQL)
Table sizesSELECT relname, pg_size_pretty(pg_total_relation_size(relid)) FROM pg_stat_user_tables;
Running queriesSELECT pid, state, query FROM pg_stat_activity;

Need More Detail?​

This page is the quick answer. For explanations with exercises, use the SQL cheat sheet. For indexing internals and interview questions, see SQL: The Complete Guide. For habits that keep SQL safe and fast, see SQL Best Practices.

Last updated: September 2026