Get Started Free

SELECT Basics

Read rows and columns from one or more tables.

Basic query
SELECT id, name, price
FROM   products
WHERE  price > 100
ORDER BY price DESC
LIMIT  20;
Logical order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT.
Aliases & expressions
SELECT p.name        AS product,
       p.price * 1.2 AS price_incl_tax
FROM   products AS p;
DISTINCT
SELECT DISTINCT country FROM customers;
Removes duplicate rows from the result.

Filtering

Restrict rows with WHERE predicates.

Operators
WHERE age >= 18
  AND status <> 'banned'
  AND country IN ('US','CA','UK')
  AND price BETWEEN 10 AND 50
Pattern & null
WHERE email LIKE '%@gmail.com'
  AND name ILIKE 'jo%'       -- case-insensitive (PostgreSQL)
  AND deleted_at IS NULL
% = any run of chars, _ = single char.
EXISTS / IN subquery
SELECT * FROM orders o
WHERE EXISTS (
  SELECT 1 FROM refunds r WHERE r.order_id = o.id
);

Joins

Combine rows from two tables on a related key. Every example uses the same two small tables, so you can compare the results side by side.

Sample tables used below
customers
idname
1Ada
2Grace
3Alan
orders
idcustomer_idamount
101130
102115
103250
104420
Alan (3) has no orders, and order 104 points at customer 4, who doesn’t exist. Those two rows are what make each join type behave differently.
INNER JOIN
SELECT c.name, o.id, o.amount
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id;
nameidamount
Ada10130
Ada10215
Grace10350
Only rows that match on both sides. Alan and order 104 drop out. JOIN on its own means INNER JOIN.
LEFT (OUTER) JOIN
SELECT c.name, o.id, o.amount
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;
nameidamount
Ada10130
Ada10215
Grace10350
AlanNULLNULL
Every customer is kept. Where no order matches, the order columns are NULL.
RIGHT (OUTER) JOIN
SELECT c.name, o.id, o.amount
FROM customers c
RIGHT JOIN orders o ON o.customer_id = c.id;
nameidamount
Ada10130
Ada10215
Grace10350
NULL10420
Every order is kept, including the orphan 104. Same as a LEFT JOIN with the tables swapped. SQLite supports RIGHT JOIN from version 3.39.
FULL OUTER JOIN
SELECT c.name, o.id, o.amount
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.id;
nameidamount
Ada10130
Ada10215
Grace10350
AlanNULLNULL
NULL10420
Everything from both sides, matched where possible. Supported by PostgreSQL, SQL Server, Oracle and SQLite 3.39+. MySQL and MariaDB have no FULL JOIN; see the workaround card below.
CROSS JOIN
SELECT s.size, k.color
FROM sizes s
CROSS JOIN colors k;
sizes (S, M) × colors (red, blue)
sizecolor
Sred
Sblue
Mred
Mblue
Every combination (a Cartesian product): rows = left rows × right rows. No ON clause.
Self-join
-- employees(id, name, manager_id)
SELECT e.name, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;
namemanager
AdaNULL
GraceAda
AlanAda
Join a table to itself with two aliases, for hierarchies such as employee → manager. LEFT keeps the person with no manager.
Anti-join: rows with no match
SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

-- same result
SELECT name FROM customers c
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
name
Alan
Finds customers without orders. Prefer NOT EXISTS over NOT IN (subquery): if the subquery returns any NULL, NOT IN matches nothing.
FULL JOIN in MySQL
SELECT c.name, o.id FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
UNION ALL
SELECT c.name, o.id FROM customers c
RIGHT JOIN orders o ON o.customer_id = c.id
WHERE c.id IS NULL;
Emulates FULL OUTER JOIN: all LEFT JOIN rows plus the right-side rows that had no match. Gives the same five rows as the FULL JOIN above.
ON vs USING vs WHERE
-- USING: same column name on both sides
SELECT * FROM order_items JOIN shipments USING (order_id);

-- filter in ON: keeps unmatched left rows
LEFT JOIN orders o ON o.customer_id = c.id AND o.amount > 20
-- filter in WHERE: drops them (acts like INNER)
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.amount > 20
USING works in PostgreSQL, MySQL, SQLite and Oracle, but not in SQL Server. With outer joins, a condition on the right table belongs in ON if you want to keep unmatched rows.

Grouping & Aggregates

Summarize many rows into grouped results.

GROUP BY + HAVING
SELECT region, COUNT(*) AS n, SUM(amount) AS total
FROM   orders
GROUP BY region
HAVING SUM(amount) > 10000
ORDER BY total DESC;
WHERE filters rows; HAVING filters groups (after aggregation).
Aggregate functions
COUNT(*)          COUNT(DISTINCT x)
SUM(x)  AVG(x)
MIN(x)  MAX(x)
STRING_AGG(name, ', ')   -- PostgreSQL, SQL Server
GROUP_CONCAT(name)       -- MySQL, SQLite
Conditional aggregate
SELECT
  COUNT(*) FILTER (WHERE status='paid')  AS paid,
  SUM(CASE WHEN refunded THEN amount ELSE 0 END) AS refunded
FROM orders;
FILTER is PostgreSQL; CASE works everywhere.
GROUP BY rules
SELECT customer_id,
       COUNT(*)    AS orders,
       AVG(amount) AS avg_amount
FROM   orders
WHERE  status <> 'cancelled'   -- rows, before grouping
GROUP  BY customer_id
HAVING COUNT(*) >= 3;          -- groups, after
Every selected column must be in GROUP BY or inside an aggregate. COUNT(col) skips NULLs; COUNT(*) counts rows.
ROLLUP subtotals
SELECT region, product, SUM(amount)
FROM sales
GROUP BY ROLLUP (region, product);
-- MySQL: GROUP BY region, product WITH ROLLUP
Adds subtotal rows per region plus a grand total (NULL in the rolled-up columns). Not supported in SQLite.

Set Operations

Stack the results of two queries with the same number of columns.

UNION / UNION ALL
SELECT email FROM customers
UNION          -- removes duplicates
SELECT email FROM newsletter;

SELECT id FROM a UNION ALL SELECT id FROM b;  -- keeps them
UNION ALL skips the duplicate check, so it is faster when duplicates are impossible or wanted.
INTERSECT / EXCEPT
-- in both
SELECT email FROM customers
INTERSECT
SELECT email FROM newsletter;

-- in the first, not the second
SELECT email FROM customers
EXCEPT
SELECT email FROM unsubscribed;
Oracle spells EXCEPT as MINUS (21c+ accepts both). MySQL supports INTERSECT and EXCEPT from 8.0.31.
Sorting a combined result
SELECT name, 'customer' AS kind FROM customers
UNION ALL
SELECT name, 'supplier' FROM suppliers
ORDER BY name;
One ORDER BY at the end sorts the whole result. Column names come from the first query.

Sorting & Paging

Order and slice result sets.

ORDER BY & LIMIT
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;
MySQL/PostgreSQL/SQLite use LIMIT/OFFSET.
Standard FETCH
SELECT * FROM posts
ORDER BY created_at DESC
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;
ANSI syntax, SQL Server, Oracle, PostgreSQL.
Keyset pagination
SELECT * FROM posts
WHERE created_at < $last_seen
ORDER BY created_at DESC
LIMIT 20;
Faster than OFFSET for deep pages.

Subqueries & CTEs

Name intermediate results for readability and reuse.

WITH (CTE)
WITH top_customers AS (
  SELECT customer_id, SUM(amount) AS spent
  FROM orders GROUP BY customer_id
  HAVING SUM(amount) > 1000
)
SELECT c.name, t.spent
FROM top_customers t
JOIN customers c ON c.id = t.customer_id;
Derived table
SELECT region, AVG(total) FROM (
  SELECT region, SUM(amount) AS total
  FROM orders GROUP BY region, month
) s
GROUP BY region;
Recursive CTE
WITH RECURSIVE tree AS (
  SELECT id, parent_id FROM cat WHERE id = 1
  UNION ALL
  SELECT c.id, c.parent_id
  FROM cat c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree;
Walk hierarchies and graphs. SQL Server and Oracle omit the RECURSIVE keyword.
Scalar & correlated subquery
-- scalar: one value
SELECT name, price,
       price - (SELECT AVG(price) FROM products) AS vs_avg
FROM products;

-- correlated: runs per outer row
SELECT * FROM products p
WHERE price > (SELECT AVG(price) FROM products
               WHERE category = p.category);
A scalar subquery must return at most one row and one column.
Multiple CTEs
WITH paid AS (
  SELECT * FROM orders WHERE status = 'paid'
), per_customer AS (
  SELECT customer_id, SUM(amount) AS total
  FROM paid GROUP BY customer_id
)
SELECT * FROM per_customer ORDER BY total DESC;
Separate CTEs with commas; later ones can read earlier ones.

Window Functions

Compute across a set of rows without collapsing them.

Ranking
SELECT name, region, amount,
  ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn,
  RANK()       OVER (PARTITION BY region ORDER BY amount DESC) AS rnk
FROM sales;
Filter rn = 1 to get top row per group.
Running total & lag
SELECT day, amount,
  SUM(amount) OVER (ORDER BY day) AS running,
  LAG(amount) OVER (ORDER BY day) AS prev_day
FROM daily;
Top N per group
WITH ranked AS (
  SELECT *, ROW_NUMBER() OVER (
    PARTITION BY region ORDER BY amount DESC) AS rn
  FROM sales
)
SELECT * FROM ranked WHERE rn <= 3;
Window functions can’t go in WHERE, so rank in a CTE and filter outside.
RANK vs DENSE_RANK
-- amounts: 90, 80, 80, 70
ROW_NUMBER()  -- 1, 2, 3, 4
RANK()        -- 1, 2, 2, 4
DENSE_RANK()  -- 1, 2, 2, 3
NTILE(4)      -- splits rows into 4 buckets
Frames & moving averages
SELECT day, amount,
  AVG(amount) OVER (
    ORDER BY day
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS avg_7d,
  LEAD(amount) OVER (ORDER BY day)         AS next_day,
  FIRST_VALUE(amount) OVER (ORDER BY day)  AS first_day
FROM daily;
Window functions need MySQL 8.0+ or SQLite 3.25+; PostgreSQL and SQL Server have them all.

Modifying Data

Insert, update, delete and upsert rows.

INSERT
INSERT INTO users (name, email)
VALUES ('Ada', 'ada@x.com'),
       ('Bo',  'bo@x.com');
UPDATE / DELETE
UPDATE orders SET status = 'shipped'
WHERE id = 42;

DELETE FROM carts
WHERE updated_at < NOW() - INTERVAL '30 days';
Always include a WHERE, no WHERE means every row.
Upsert
INSERT INTO counters (key, n) VALUES ('hits', 1)
ON CONFLICT (key) DO UPDATE
  SET n = counters.n + 1;
PostgreSQL/SQLite. MySQL: ON DUPLICATE KEY UPDATE.

Tables & Keys (DDL)

Define and change schema objects.

CREATE TABLE
CREATE TABLE products (
  id      SERIAL PRIMARY KEY,
  name    VARCHAR(120) NOT NULL,
  price   NUMERIC(10,2) DEFAULT 0,
  created TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE
ALTER TABLE products ADD COLUMN sku TEXT;
ALTER TABLE products
  ADD CONSTRAINT uq_sku UNIQUE (sku);
Indexes & keys
CREATE INDEX idx_orders_cust ON orders (customer_id);
CREATE UNIQUE INDEX uq_email ON users (email);

FOREIGN KEY (customer_id) REFERENCES customers(id)
Index columns you filter or join on frequently.
Auto-increment keys
-- PostgreSQL 10+
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY
-- MySQL / MariaDB
id INT AUTO_INCREMENT PRIMARY KEY
-- SQLite
id INTEGER PRIMARY KEY
-- SQL Server
id INT IDENTITY(1,1) PRIMARY KEY
In SQLite, INTEGER PRIMARY KEY becomes the rowid and auto-assigns values.
Constraints
CREATE TABLE order_items (
  order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  sku      TEXT NOT NULL,
  qty      INT  NOT NULL CHECK (qty > 0),
  PRIMARY KEY (order_id, sku)
);
SQLite only enforces foreign keys after PRAGMA foreign_keys = ON;. MySQL enforces CHECK from 8.0.16.

Indexes & Performance

Make filters, joins and sorts fast, and check what the planner actually does.

Composite index
CREATE INDEX idx_orders_cust_date
  ON orders (customer_id, created_at);

-- uses the index
WHERE customer_id = 7 AND created_at > '2026-01-01'
WHERE customer_id = 7
-- usually can't seek on it (leading column missing)
WHERE created_at > '2026-01-01'
Column order matters: put equality filters first, then range or sort columns.
Partial / filtered index
-- PostgreSQL, SQLite, SQL Server
CREATE INDEX idx_orders_open
  ON orders (created_at)
  WHERE status = 'open';
Indexes only the rows you query often, so it stays small. Not available in MySQL.
Covering index
-- PostgreSQL 11+, SQL Server
CREATE INDEX idx_orders_cust
  ON orders (customer_id) INCLUDE (amount);
Carries extra columns so the query can be answered from the index alone. In MySQL and SQLite, add the column to the key instead.
See the query plan
EXPLAIN SELECT * FROM orders WHERE customer_id = 7;

EXPLAIN ANALYZE SELECT ...;   -- PostgreSQL, MySQL 8.0.18+
EXPLAIN QUERY PLAN SELECT ...; -- SQLite
SQL Server: use “Display Estimated Execution Plan” or SET SHOWPLAN_XML ON. Paste a plan into our SQL Explain Plan Visualizer to read it as a tree.
Drop an index
DROP INDEX idx_orders_open;              -- PostgreSQL, SQLite, Oracle
DROP INDEX idx_orders_open ON orders;    -- MySQL, SQL Server
Every index speeds up reads but slows down writes, so drop the ones nothing uses.

Handy Functions

Frequently reached-for scalar helpers.

Null & conditional
COALESCE(nickname, name, 'anon')
NULLIF(a, b)         -- NULL when a = b
CASE WHEN x > 0 THEN 'pos' ELSE 'neg' END
String
UPPER(s)  LOWER(s)  TRIM(s)  LENGTH(s)
CONCAT(a, ' ', b)
SUBSTRING(s FROM 1 FOR 3)
REPLACE(s, 'a', 'b')
Date & time
NOW()   CURRENT_DATE
DATE_TRUNC('month', ts)          -- PostgreSQL
EXTRACT(YEAR FROM ts)
ts + INTERVAL '7 days'
Function names vary a little by database engine.

Transactions

Group statements so they all succeed or all fail together.

Commit or roll back
BEGIN;                 -- MySQL: START TRANSACTION
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;                -- or ROLLBACK; to undo both
SQL Server: BEGIN TRANSACTION … COMMIT. Oracle starts a transaction implicitly, so you only COMMIT or ROLLBACK.
Savepoints
BEGIN;
INSERT INTO orders (id, customer_id) VALUES (105, 1);
SAVEPOINT before_items;
INSERT INTO order_items (order_id, sku) VALUES (105, 'X');
ROLLBACK TO SAVEPOINT before_items;  -- undo only the item
COMMIT;
Partial rollback inside a transaction. SQL Server uses SAVE TRANSACTION name and ROLLBACK TRANSACTION name.
Isolation level
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- READ UNCOMMITTED | READ COMMITTED
-- REPEATABLE READ  | SERIALIZABLE
Defaults: READ COMMITTED in PostgreSQL, SQL Server and Oracle; REPEATABLE READ in MySQL (InnoDB). SQLite transactions are serializable.
DDL inside a transaction
BEGIN;
ALTER TABLE users ADD COLUMN phone TEXT;
UPDATE users SET phone = '';
ROLLBACK;  -- the new column is gone too
Works in PostgreSQL, SQL Server and SQLite. In MySQL and Oracle, DDL statements commit implicitly and cannot be rolled back.

Dialect Differences

The same task in PostgreSQL, MySQL, SQLite and SQL Server. The core of SQL is portable; these are the spots where it isn’t.

TaskPostgreSQLMySQLSQLiteSQL Server
First N rowsLIMIT 10LIMIT 10LIMIT 10TOP 10 or OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY
Auto-increment keyGENERATED ALWAYS AS IDENTITY or SERIALAUTO_INCREMENTINTEGER PRIMARY KEYIDENTITY(1,1)
Concatenate stringsa || b, CONCAT()CONCAT(a, b)a || ba + b, CONCAT()
Current date & timeNOW()NOW()datetime('now')GETDATE(), SYSDATETIME()
Quote an identifier"order"`order`"order"[order] or "order"
Boolean columnBOOLEANBOOLEAN (alias of TINYINT(1))no type, use 0/1BIT
UpsertON CONFLICT ... DO UPDATEON DUPLICATE KEY UPDATEON CONFLICT ... DO UPDATEMERGE
Join strings in a groupSTRING_AGG(x, ',')GROUP_CONCAT(x)GROUP_CONCAT(x)STRING_AGG(x, ',') (2017+)
Case-insensitive matchILIKE or LOWER(x) LIKELIKE (default collations are case-insensitive)LIKE (case-insensitive for ASCII)LIKE (depends on collation; default is case-insensitive)
Window functionsyes8.0+3.25+yes
RIGHT / FULL JOINyes / yesyes / no3.39+ / 3.39+yes / yes

More information about SQL

SQL (Structured Query Language) is the standard language for working with relational databases, PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite and more. It's one of the most valuable and durable skills in software: nearly every application, analytics stack, and data pipeline touches a relational database, and SQL is how you read from and write to it.

What is SQL?

SQL is a declarative language, you describe what data you want and the database's query planner figures out how to get it. A single SELECT can filter, join, group, and sort across millions of rows.

The main categories of SQL

Why it matters

Well-written SQL, with the right joins, indexes, and window functions, lets the database do work your application would otherwise do slowly and at scale. It's portable knowledge: the core language is the same across engines, with small dialect differences.

Frequently asked questions

What is the difference between WHERE and HAVING?

WHERE filters individual rows before grouping; HAVING filters groups after GROUP BY has aggregated them.

What is the difference between INNER JOIN and LEFT JOIN?

INNER JOIN returns only rows that match in both tables. LEFT JOIN returns all rows from the left table, filling unmatched right-side columns with NULL.

Is SQL the same across all databases?

The core (SELECT/JOIN/GROUP BY/DML/DDL) is standardized and portable. Functions, pagination syntax, and some features differ per engine, this cheat sheet notes the common dialect differences.

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

A LEFT JOIN keeps every row from the left table and only matching rows from the right. A FULL OUTER JOIN keeps every row from both tables, with NULLs where there is no match on the other side. MySQL has no FULL OUTER JOIN, so it is emulated with a LEFT JOIN UNION ALL a RIGHT JOIN filtered to unmatched rows.

How do I print or save this SQL cheat sheet as a PDF?

Click the Print / Save as PDF button at the top of the page, or press Ctrl+P (Cmd+P on a Mac), and choose Save as PDF as the printer. The print layout drops the navigation and keeps the sections and examples.

VisuaLeaf

Run this SQL against your database in VisuaLeaf

VisuaLeaf connects to PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite and more, with a visual query builder, schema diagrams, charts, and a query profiler, plus native MongoDB support.

MongoDB & SQL in one client
Visual query & aggregation builders
Live visual schema diagrams
Charts, dashboards & query profiler
Connection manager for local, cloud & teams
Free Community Edition