SQL Basic Syntax Cheat Sheet: SELECT, JOIN, GROUP BY, Subqueries, and Window Functions

A reference cheat sheet covering SELECT, WHERE, JOIN, GROUP BY, HAVING, subqueries, CASE expressions, and window functions, each with a runnable query and its result against one shared sample dataset. Standard SQL, with dialect notes.

SQL has a lot of syntax to keep straight, and things like “the four kinds of JOIN,” “when to use WHERE vs. HAVING,” and “how window functions are written” are exactly the kind of thing people re-search every time they need them. This article is a reference cheat sheet that uses just two consistent sample tables (employees and departments) to walk through everything from basic SELECT to subqueries and window functions, always as a “query + result” pair you can check at a glance. The syntax follows standard SQL, with dialect notes only where MySQL and PostgreSQL genuinely diverge.

1. Setting Up the Sample Tables

Every example below runs against these two tables: employees (7 rows) and departments (4 rows). I’ve deliberately mixed in “an employee with no department assigned” (Frank) and “a department with no employees” (HR) — that’s what makes the differences between JOIN types and subqueries visible.

CREATE TABLE departments (
    dept_id   INTEGER PRIMARY KEY,
    dept_name TEXT NOT NULL,
    location  TEXT NOT NULL
);

CREATE TABLE employees (
    emp_id    INTEGER PRIMARY KEY,
    name      TEXT NOT NULL,
    dept_id   INTEGER REFERENCES departments(dept_id),
    salary    INTEGER NOT NULL,
    hire_year INTEGER NOT NULL
);

INSERT INTO departments (dept_id, dept_name, location) VALUES
  (1, 'Engineering', 'Tokyo'),
  (2, 'Sales',       'Osaka'),
  (3, 'Marketing',   'Tokyo'),
  (4, 'HR',          'Fukuoka');

INSERT INTO employees (emp_id, name, dept_id, salary, hire_year) VALUES
  (101, 'Alice', 1,    720000, 2019),
  (102, 'Bob',   2,    580000, 2020),
  (103, 'Carol', 1,    810000, 2018),
  (104, 'Dave',  3,    540000, 2021),
  (105, 'Eve',   2,    610000, 2022),
  (106, 'Frank', NULL, 500000, 2023),
  (107, 'Grace', 1,    690000, 2021);

departments

dept_iddept_namelocation
1EngineeringTokyo
2SalesOsaka
3MarketingTokyo
4HRFukuoka

employees

emp_idnamedept_idsalaryhire_year
101Alice17200002019
102Bob25800002020
103Carol18100002018
104Dave35400002021
105Eve26100002022
106FrankNULL5000002023
107Grace16900002021

Frank has not been assigned a department yet (dept_id is NULL), and nobody has been assigned to HR yet (dept_id = 4) — both deliberately.

2. SELECT Basics — Choosing Columns and WHERE

Selecting columns

SELECT name, salary FROM employees;

This pulls just name and salary from the 7 rows in employees. The row count doesn’t change — still 7 rows.

Filtering with comparison operators

SELECT name, salary FROM employees WHERE salary >= 600000;
namesalary
Alice720000
Carol810000
Eve610000
Grace690000

Combining conditions with AND / OR

SELECT name, dept_id, salary FROM employees
WHERE dept_id = 1 AND salary >= 700000;
namedept_idsalary
Alice1720000
Carol1810000

AND keeps rows that satisfy both conditions; OR keeps rows that satisfy either one. AND binds tighter than OR, so when you mix them, it’s safer to make the grouping explicit with parentheses, e.g. (dept_id = 1 OR dept_id = 3) AND salary >= 700000.

Pattern matching with LIKE

SELECT name FROM employees WHERE name LIKE 'A%';   -- prefix match
SELECT name FROM employees WHERE name LIKE '%e';    -- suffix match

'A%' (starts with A) matches only Alice. '%e' (ends with e) matches Alice, Dave, and Eve — 3 rows. % is a wildcard for zero or more characters, and _ matches exactly one character.

Matching a list with IN

SELECT name, dept_id FROM employees WHERE dept_id IN (1, 3);
namedept_id
Alice1
Carol1
Dave3
Grace1

This means the same thing as dept_id = 1 OR dept_id = 3, but IN reads more cleanly once you have several candidates.

Range checks with BETWEEN

SELECT name, salary FROM employees
WHERE salary BETWEEN 550000 AND 700000;
namesalary
Bob580000
Eve610000
Grace690000

BETWEEN A AND B is equivalent to salary >= A AND salary <= B — a closed interval that includes both endpoints.

Checking for NULL

SELECT name FROM employees WHERE dept_id IS NULL;
name
Frank

NULL represents “no value present,” a special state that = NULL can never match. Always use IS NULL / IS NOT NULL.

3. Sorting and Limiting Row Counts

ORDER BY

SELECT name, salary FROM employees ORDER BY salary DESC;
namesalary
Carol810000
Alice720000
Grace690000
Eve610000
Bob580000
Dave540000
Frank500000

You can specify ASC (ascending, the default) or DESC (descending). With multiple columns, the leftmost key takes priority.

SELECT name, dept_id, salary FROM employees
ORDER BY dept_id ASC, salary DESC;
namedept_idsalary
Carol1810000
Alice1720000
Grace1690000
Eve2610000
Bob2580000
Dave3540000
FrankNULL500000

Within each dept_id group, salary DESC takes over. NULL placement is a dialect difference: PostgreSQL puts NULLs last by default for ASC, while MySQL puts them first (the table above follows PostgreSQL’s default). If you need precise control, use NULLS LAST / NULLS FIRST (standard SQL, supported by PostgreSQL).

Limiting rows with LIMIT / OFFSET

SELECT name, salary FROM employees
ORDER BY salary DESC
LIMIT 3;
namesalary
Carol810000
Alice720000
Grace690000
SELECT name, salary FROM employees
ORDER BY salary DESC
LIMIT 3 OFFSET 3;
namesalary
Eve610000
Bob580000
Dave540000

This skips the first 3 rows and takes the next 3 — the basic pattern behind pagination. LIMIT/OFFSET is common to MySQL, PostgreSQL, and SQLite, but standard SQL uses FETCH FIRST 3 ROWS ONLY and SQL Server uses TOP 3 — worth remembering as a dialect difference.

4. Aggregation and Grouping — GROUP BY / HAVING

Aggregate functions

SELECT COUNT(*) AS cnt, SUM(salary) AS total,
       AVG(salary) AS avg_salary, MAX(salary) AS max_salary, MIN(salary) AS min_salary
FROM employees;
cnttotalavg_salarymax_salarymin_salary
74450000635714.29…810000500000

COUNT(*) counts rows, and AVG divides the sum by the row count (4,450,000 ÷ 7 ≈ 635,714.29). If you specify a column, e.g. COUNT(dept_id), rows where that column is NULL (Frank) get excluded from the count.

GROUP BY

SELECT dept_id, COUNT(*) AS cnt, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id;
dept_idcntavg_salary
13740000.00
22595000.00
31540000.00
NULL1500000.00

GROUP BY collects rows with the same value in the given column into one group, then computes the aggregate functions in SELECT per group. NULL forms its own group too (a “NULL group” containing just Frank).

HAVING — filtering groups

SELECT dept_id, COUNT(*) AS cnt
FROM employees
GROUP BY dept_id
HAVING COUNT(*) >= 2;
dept_idcnt
13
22

Both dept_id = 3 and the NULL group have only 1 member, so HAVING drops them.

The difference between WHERE and HAVING comes down to which stage they filter at. WHERE filters individual rows before aggregation; HAVING filters the resulting groups after GROUP BY. Using both together looks like this:

SELECT dept_id, AVG(salary) AS avg_salary
FROM employees
WHERE dept_id IS NOT NULL
GROUP BY dept_id
HAVING AVG(salary) >= 600000;
dept_idavg_salary
1740000.00

The order of operations is: WHERE first excludes Frank (NULL department), the remaining 6 rows get grouped by dept_id, and then HAVING keeps only groups whose average salary is at least 600,000. Department 2 (average 595,000) falls short and drops out. It’s also worth remembering that HAVING can reference aggregate functions like COUNT(*) or AVG(salary), while WHERE cannot — at the WHERE stage, aggregate values don’t exist yet.

5. JOIN — Combining Multiple Tables

Here we join employees and departments on dept_id. Depending on the type of join, Frank (no department) and HR (no employees) get treated differently.

SELECT e.name, d.dept_name, d.location
FROM employees AS e
INNER JOIN departments AS d ON e.dept_id = d.dept_id;

INNER JOIN (only matched rows, 6 rows)

namedept_namelocation
AliceEngineeringTokyo
BobSalesOsaka
CarolEngineeringTokyo
DaveMarketingTokyo
EveSalesOsaka
GraceEngineeringTokyo

Neither Frank (no department) nor HR (no employees) has a matching partner, so neither appears in the result at all.

SELECT e.name, d.dept_name
FROM employees AS e
LEFT JOIN departments AS d ON e.dept_id = d.dept_id;

LEFT JOIN (all of employees, plus matches, 7 rows)

namedept_name
AliceEngineering
BobSales
CarolEngineering
DaveMarketing
EveSales
FrankNULL
GraceEngineering

The left side (employees, listed in FROM) always survives in full. Frank has no matching department, so dept_name comes back NULL but Frank still appears. HR, which only ever existed on the right side, doesn’t show up at all.

SELECT e.name, d.dept_name
FROM employees AS e
RIGHT JOIN departments AS d ON e.dept_id = d.dept_id;

RIGHT JOIN (all of departments, plus matches, 7 rows)

namedept_name
AliceEngineering
CarolEngineering
GraceEngineering
BobSales
EveSales
DaveMarketing
NULLHR

This time it’s the right side (departments) that survives in full. HR has no employees, so name comes back NULL but HR still appears. Frank doesn’t, since his department doesn’t match anything.

SELECT e.name, d.dept_name
FROM employees AS e
FULL JOIN departments AS d ON e.dept_id = d.dept_id;

FULL JOIN (the union of both sides, 8 rows)

namedept_name
AliceEngineering
BobSales
CarolEngineering
DaveMarketing
EveSales
FrankNULL
GraceEngineering
NULLHR

Both Frank and HR survive. Think of FULL JOIN as adding LEFT and RIGHT together and collapsing the overlap (the 6 matched rows) down to a single copy.

A comparison of INNER, LEFT, RIGHT, and FULL JOIN results on a miniature employees/departments dataset (3 rows each). INNER JOIN keeps only the 2 matched rows. LEFT JOIN keeps all of employees plus matches (3 rows) — the unassigned Carol survives with a NULL department. RIGHT JOIN keeps all of departments plus matches (3 rows) — the employee-less HR survives with a NULL name. FULL JOIN is the union of both sides, 4 rows. Gray cells represent NULL

Why does a JOIN change the row count? Because a join is fundamentally an operation that enumerates every matching combination of rows from both tables. If a department has 3 employees, that department appears 3 times in the joined result — which is exactly why Engineering shows up 3 times. Rows with no matching partner disappear under INNER JOIN, but survive padded with NULL under LEFT, RIGHT, and FULL.

Note that MySQL doesn’t support FULL JOIN directly (PostgreSQL and SQL Server do). To get an equivalent result in MySQL, you need to combine a LEFT JOIN result with a RIGHT JOIN result via UNION.

6. Subqueries

A subquery inside WHERE

Find employees earning more than the average salary.

SELECT name, salary FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
namesalary
Alice720000
Carol810000
Grace690000

The inner query (SELECT AVG(salary) FROM employees) is evaluated first, collapsing to a single value (635714.29), which the outer query then uses like a constant. Eve (610000) falls below the average, so she’s excluded.

A subquery inside FROM (derived table)

First aggregate the average salary per department, then keep only departments averaging 600,000 or more.

SELECT dept_id, avg_salary
FROM (
    SELECT dept_id, AVG(salary) AS avg_salary
    FROM employees
    WHERE dept_id IS NOT NULL
    GROUP BY dept_id
) AS dept_avg
WHERE avg_salary >= 600000;
dept_idavg_salary
1740000.00

A subquery inside FROM is called a “derived table” — it lets you reuse an aggregation result as if it were an ordinary table. This gives the same result as HAVING in the previous section, but tends to read more naturally when you need to apply further processing on top of the aggregated result.

Correlated subqueries

Find employees earning more than the average salary within their own department. The key here is that the inner subquery (e2) references a row from the outer query (e1).

SELECT e1.name, e1.salary
FROM employees AS e1
WHERE e1.salary > (
    SELECT AVG(e2.salary)
    FROM employees AS e2
    WHERE e2.dept_id = e1.dept_id
);
namesalary
Carol810000
Eve610000

A correlated subquery re-evaluates for every single row of the outer query. Carol qualifies because 810,000 exceeds Engineering’s department average of 740,000; Alice and Grace fall short of that average and are excluded. Frank is excluded too — since his dept_id is NULL, the comparison e2.dept_id = e1.dept_id becomes NULL = NULL, which is always UNKNOWN (treated as false).

EXISTS / NOT EXISTS

Find departments that have at least one employee.

SELECT dept_name FROM departments AS d
WHERE EXISTS (
    SELECT 1 FROM employees AS e WHERE e.dept_id = d.dept_id
);
dept_name
Engineering
Sales
Marketing

To find departments with zero employees (i.e., vacant departments), use NOT EXISTS.

SELECT dept_name FROM departments AS d
WHERE NOT EXISTS (
    SELECT 1 FROM employees AS e WHERE e.dept_id = d.dept_id
);
dept_name
HR

EXISTS is a “does this exist” operator that becomes true as soon as the subquery returns even one row — it doesn’t look at the actual values. Many engines can stop searching the moment a match is found, which often makes EXISTS more efficient than an equivalent IN check.

7. Branching Logic with CASE

Classify salaries into three bands.

SELECT name, salary,
    CASE
        WHEN salary >= 700000 THEN 'High'
        WHEN salary >= 600000 THEN 'Mid'
        ELSE 'Standard'
    END AS salary_band
FROM employees
ORDER BY salary DESC;
namesalarysalary_band
Carol810000High
Alice720000High
Grace690000Mid
Eve610000Mid
Bob580000Standard
Dave540000Standard
Frank500000Standard

CASE evaluates conditions from top to bottom and returns the result of the first WHEN that’s true (falling through to ELSE if nothing matches). This form — a series of WHEN expression THEN value clauses — is called a “searched CASE expression.” There’s also a “simple CASE expression” written as CASE column WHEN value1 THEN ... WHEN value2 THEN ..., but the searched form above is better suited to range checks like this one.

CASE also pairs well with aggregate functions for “conditional counting.”

SELECT
    SUM(CASE WHEN salary >= 700000 THEN 1 ELSE 0 END) AS high_cnt,
    SUM(CASE WHEN salary >= 600000 AND salary < 700000 THEN 1 ELSE 0 END) AS mid_cnt,
    SUM(CASE WHEN salary < 600000 THEN 1 ELSE 0 END) AS standard_cnt
FROM employees;
high_cntmid_cntstandard_cnt
223

This gets you per-band headcounts side by side in a single aggregated row, without using GROUP BY at all — a pattern that comes up constantly in dashboard queries.

8. Introduction to Window Functions

Unlike GROUP BY, window functions don’t collapse rows — they attach extra information, like a rank or a running total within the same group, to every row. The syntax is OVER (PARTITION BY ... ORDER BY ...).

ROW_NUMBER — salary rank within department

SELECT name, dept_id, salary,
    ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM employees
ORDER BY dept_id, rn;
namedept_idsalaryrn
Carol18100001
Alice17200002
Grace16900003
Eve26100001
Bob25800002
Dave35400001
FrankNULL5000001

PARTITION BY dept_id creates a separate “window” per department, and numbering restarts inside each window according to ORDER BY salary DESC. The crucial difference from GROUP BY is that all 7 original rows survive.

RANK — handling ties

SELECT name, salary,
    RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
ORDER BY rnk;
namesalaryrnk
Carol8100001
Alice7200002
Grace6900003
Eve6100004
Bob5800005
Dave5400006
Frank5000007

Since every salary in this sample data is unique, RANK produces the same result as ROW_NUMBER here. The two only diverge when there are ties. For example, if two people tied for 1st place with the same salary, RANK would give both of them rank 1 and then skip straight to rank 3 for the next person (rank 2 is never used). If you don’t want that gap, use DENSE_RANK instead (both get rank 1, and the next person gets rank 2). Pick whichever of the three matches your use case.

SUM() OVER — attaching a total without collapsing rows

Attach each department’s total salary to every row, without reducing the row count.

SELECT name, dept_id, salary,
    SUM(salary) OVER (PARTITION BY dept_id) AS dept_total
FROM employees
ORDER BY dept_id;
namedept_idsalarydept_total
Alice17200002220000
Carol18100002220000
Grace16900002220000
Bob25800001190000
Eve26100001190000
Dave3540000540000
FrankNULL500000500000

This is handy when you want something like “what percentage of my department’s total salary do I earn” in a single query — much more concise than grouping and then joining back.

Adding ORDER BY inside the window also lets you build a running total.

SELECT name, salary,
    SUM(salary) OVER (ORDER BY salary DESC
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM employees
ORDER BY salary DESC;
namesalaryrunning_total
Carol810000810000
Alice7200001530000
Grace6900002220000
Eve6100002830000
Bob5800003410000
Dave5400003950000
Frank5000004450000

You can confirm that the running total on the last row (4,450,000) matches the overall SUM(salary) computed back in section 4.

9. Modifying Data — INSERT / UPDATE / DELETE

INSERT — adding a row

INSERT INTO employees (emp_id, name, dept_id, salary, hire_year)
VALUES (108, 'Henry', 2, 550000, 2026);

Henry joins the Sales team (dept_id = 2), bringing employees to 8 rows.

UPDATE — modifying existing rows

Give everyone in Engineering (dept_id = 1) a 5% raise.

UPDATE employees
SET salary = salary * 1.05
WHERE dept_id = 1;
namesalary (before)salary (after)
Alice720000756000
Carol810000850500
Grace690000724500

DELETE — removing rows

Remove employees who joined before 2019.

DELETE FROM employees WHERE hire_year < 2019;

Only Carol, with hire_year = 2018, matches — and she’s the one row that gets deleted.

Forgetting WHERE is the single most dangerous mistake you can make with UPDATE/DELETE.

-- Dangerous: no WHERE means every row is a target
DELETE FROM employees;

A DELETE FROM employees; without a WHERE clause deletes every single row in the table (7, at this point in the narrative, after adding Henry and removing Carol) with nothing left over. Likewise, an UPDATE with no WHERE rewrites the value in every row. Before running either against production, get in the habit of first running the equivalent SELECT to confirm how many rows are affected, and wrapping the statement in a transaction (BEGINCOMMIT/ROLLBACK) so you can review the result before committing.

Quick Reference Table

SyntaxPurposeExample
SELECTSelect and retrieve columnsSELECT name, salary FROM employees;
WHEREFilter rows by conditionWHERE salary >= 600000
LIKEString pattern matchingWHERE name LIKE 'A%'
INMatch against a listWHERE dept_id IN (1, 3)
BETWEENRange check (closed interval)WHERE salary BETWEEN 550000 AND 700000
IS NULLCheck for NULLWHERE dept_id IS NULL
ORDER BYSort rowsORDER BY salary DESC
LIMIT / OFFSETLimit rows / paginationLIMIT 3 OFFSET 3
COUNT/SUM/AVG/MAX/MINAggregationSELECT COUNT(*), AVG(salary) FROM employees;
GROUP BYGroup rows for aggregationGROUP BY dept_id
HAVINGFilter groups after aggregationHAVING COUNT(*) >= 2
INNER JOINKeep only matched rows... INNER JOIN departments d ON e.dept_id = d.dept_id
LEFT JOINAll of left table + matches... LEFT JOIN departments d ON ...
RIGHT JOINAll of right table + matches... RIGHT JOIN departments d ON ...
FULL JOINUnion of both tables... FULL JOIN departments d ON ...
Subquery in WHEREUse a subquery result like a constantWHERE salary > (SELECT AVG(salary) FROM employees)
Subquery in FROMReuse an aggregate as a derived tableFROM (SELECT dept_id, AVG(salary) ... ) AS t
Correlated subqueryInner subquery references the outer rowWHERE e2.dept_id = e1.dept_id
EXISTSCheck whether matching rows existWHERE EXISTS (SELECT 1 FROM employees e WHERE ...)
CASEBranching logicCASE WHEN salary >= 700000 THEN 'High' ELSE 'Standard' END
ROW_NUMBER()Sequential number within a partitionROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC)
RANK()Rank within a partition (ties leave gaps)RANK() OVER (ORDER BY salary DESC)
SUM() OVERAttach a total without collapsing rowsSUM(salary) OVER (PARTITION BY dept_id)
INSERTAdd a rowINSERT INTO employees (...) VALUES (...);
UPDATEModify existing rowsUPDATE employees SET salary = salary * 1.05 WHERE ...;
DELETERemove rowsDELETE FROM employees WHERE hire_year < 2019;

FAQ

Q. What’s the difference between WHERE and HAVING?

WHERE filters individual rows before aggregation; HAVING filters the resulting groups after GROUP BY. The order of evaluation is FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. At the WHERE stage, aggregate values like COUNT(*) don’t exist yet, so conditions involving aggregate functions have to go in HAVING.

Q. Why does a JOIN increase the row count?

Because a JOIN enumerates every combination of rows from both tables that satisfies the join condition. If Engineering has 3 employees, the joined result of employees and departments shows the Engineering row 3 times. When you join tables with a one-to-many relationship, the result row count grows with the “many” side — that’s expected behavior, not a bug. If you see more rows than you expected, check your join condition and the actual cardinality of the relationship (one-to-one vs. one-to-many).

Q. Should I use a subquery or a JOIN?

If you need columns from the joined table in your result, use a JOIN. If you only need to check for existence or compare against an aggregate value — and don’t actually need any columns from the other table — a subquery (especially EXISTS) is the better fit. In this article’s examples: if you need to display the department name, JOIN is the only real option; but if you just need to know “which departments have zero employees,” NOT EXISTS expresses the intent more clearly and tends to get optimized as an efficient semi-join. Most RDBMS optimizers can transform either form into an equivalent query plan, so in practice it often comes down to which one reads more clearly.