SQL
Topic 3 of 14 · In Data Analysis · September 8, 2026
Query relational databases: SELECT, filters, aggregations, JOINs, and window functions — the language of every analyst job.
Overview
SQL (Structured Query Language, pronounced "ess-cue-ell" or "sequel") is how analysts talk to the database where the company's real records live. No other skill gets you hired faster: almost every Data Analyst job expects at least intermediate SQL.
The good news: SQL is simple on purpose. You describe what rows you want, and the database figures out how to get them. If you mastered Excel's SUMIFS and lookups, you already know the concepts — you are just expressing them in a different language. This topic takes you from SELECT * to window functions with progressively harder, business-realistic queries.
Before You Start
- Have the sales_2019-2021 tables loaded — the ones you practised joins on — plus a customers table with customer_id.
- Make sure you can already run a grouped query with SELECT, FROM, WHERE and GROUP BY in your sandbox before starting this lesson.
- Recall the Excel joins you just finished: XLOOKUP finds one value, while a JOIN attaches a whole table on a key — same idea, new syntax.
- Confirm the grain of your tables before writing anything: one row in orders should mean one order line, not one customer.
- Open a scratch query file where you can draft and re-run every snippet from the worked examples below.
Learning Objectives
- Explain databases, tables, rows/columns, and SQL's job
- Understand primary keys, foreign keys and table relationships
- Write SELECT queries filtering with WHERE, ORDER BY, DISTINCT and LIMIT
- Aggregate with COUNT, SUM, AVG, MIN, MAX and GROUP BY/HAVING
- Join tables with INNER/LEFT/RIGHT/FULL JOIN on the right keys
- Branch logic with CASE WHEN and handle NULLs with COALESCE
- Nest queries with subqueries and clean up with CTEs
- Use window functions: ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, PARTITION BY
- Work with dates and strings the way analysts do
- Spot slow queries and common SQL mistakes
Prerequisites
Complete Basics (tables, rows, metrics, dimensions). Excel lookups/aggregates help a lot but are optional. You need a free SQL playground (e.g. SQLite/DB Browser, or a hosted sandbox) — no install required.
Databases, Tables, and Keys
A relational database stores data in tables (spreadsheet-like grids of rows = records and columns = fields). Customers, Orders, Products and Payments are separate tables connected by keys.
- Primary key: the column (or combo) that uniquely identifies each row — e.g.
customer_id - Foreign key: a column in one table that points to a primary key in another — e.g.
customer_idon Orders - Relationships: one-to-many (one customer has many orders), many-to-many (products ↔ orders via an order_items table)
This design avoids duplication: you don't repeat the customer's name on every order — you store the id and join when needed.
The Basic SELECT
Every query follows the same shape. This is the one syntax block you must memorize:
SELECT column1, column2 FROM orders WHERE status = 'shipped' ORDER BY order_date DESC LIMIT 10;
SELECT— which columns to return (*= all, fine for exploration)FROM— which tableWHERE— filter rows (before aggregation!)ORDER BY— sort (ASC/DESC)LIMIT— cap the number of rows (tired hands:LIMITbefore you run!)
DISTINCT returns unique values of a column: SELECT DISTINCT category FROM products; — the SQL cousin of Remove Duplicates.
Aggregations and GROUP BY
Aggregates collapse many rows into one summary number: COUNT, SUM, AVG, MIN, MAX. Add GROUP BY to aggregate per category (this is a PivotTable in SQL):
SELECT category,
COUNT(*) AS order_count,
SUM(amount) AS revenue,
AVG(amount) AS avg_order
FROM orders
GROUP BY category
ORDER BY revenue DESC;HAVING filters after aggregation (WHERE filters before):
SELECT category, SUM(amount) AS revenue FROM orders GROUP BY category HAVING SUM(amount) > 10000;
JOINs: Bringing Tables Together
JOIN attaches rows from a second table by a key — Excel's XLOOKUP, but for whole tables. Standard pattern:
SELECT o.id, c.name, o.amount FROM orders o JOIN customers c ON c.id = o.customer_id;
| Join | Rows returned when a match is missing |
|---|---|
INNER JOIN | Only matched rows (orders with a known customer) |
LEFT JOIN | All rows of the left table + NULLs where no match (all customers, orders or none) |
RIGHT JOIN | All rows of the right table + NULLs where no match |
FULL JOIN | All rows from both, NULLs where either side misses |
Rarely in analyst work use RIGHT/FULL — INNER and LEFT cover ~95% of the job. Remember: JOIN BEFORE you filter, and never join on non-unique keys unless you understand why duplicates multiply.
CASE WHEN and NULLs
CASE WHEN is the SQL version of Excel's IF/IFS — build new categories:
SELECT id, amount,
CASE WHEN amount >= 100 THEN 'Large'
WHEN amount >= 30 THEN 'Medium'
ELSE 'Small' END AS order_size
FROM orders;NULL means missing/unknown, not zero. A NULL in a SUM is skipped; a NULL in a comparison is never equal. Handle carefully:
WHERE col IS NULL(notcol = NULL!)COALESCE(col, 0)— use a fallback value (COALESCEtakes many args, first non-NULL)COUNT(col)skips NULLs;COUNT(*)counts all rows
Subqueries and CTEs
When one query needs the result of another, nest it — subquery (inline) and CTE (named, using WITH). CTEs are the analyst favorite because they make queries read like a story:
WITH monthly AS (
SELECT DATE_TRUNC('month', order_date) AS month,
SUM(amount) AS revenue
FROM orders
GROUP BY 1
)
SELECT * FROM monthly
WHERE revenue < 10000;Break a hard problem into small CTE steps (filter → aggregate → compare) and the query becomes both correct and explainable to reviewers.
Window Functions
Window functions calculate over "windows" of rows without collapsing them — the row stays visible while you add a rank, running total, or the previous period's value.
SELECT customer_id, order_date, amount,
ROW_NUMBER() OVER (PARTITION BY customer_id
ORDER BY order_date) AS order_seq,
RANK() OVER (ORDER BY amount DESC) AS amount_rank,
LAG(amount) OVER (PARTITION BY customer_id
ORDER BY order_date) AS prev_order
FROM orders;PARTITION BY= reset the window per group (per customer)ORDER BYinside OVER = ordering within the windowROW_NUMBER= unique sequence (1,2,3)RANKallows ties with gaps (1,1,3);DENSE_RANKno gaps (1,1,2)LAG/LEAD= previous / next row (period-over-period change!)
Window functions power "top 3 per region", churn analysis, and month-over-month growth — all classic analyst asks.
Dates, Strings, and Analytical Thinking
Date hygiene: filter by range with >= and < (never = '2026-03' on a timestamp), group with DATE_TRUNC('month', …) or EXTRACT(YEAR FROM …), compute age/tenure with date differences.
String hygiene: LOWER()/UPPER() to normalize, TRIM(), LEFT()/RIGHT(), LIKE '%pattern%', SPLIT_PART().
Thinking like an analyst: every business question maps to a query shape — a rate is COUNT(DISTINCT …) over a base; a trend is a group-by-month: a comparison is two queries joined by a common dimension. Before writing SQL, ask: what is a row here? What is the grain? (order-level, customer-level, day-level). The grain — the meaning of one row — is the definition that makes or breaks the query.
Hands-On Practice
Use any SQL sandbox with a tiny shop schema (customers, orders, order_items, products). Write and run:
- List orders from last month with status, sorted newest first.
- Revenue and order count per category, top 3 only (HAVING + ORDER BY + LIMIT).
- Which customers have never ordered? (LEFT JOIN + IS NULL)
- Monthly revenue with the previous month column (CTE + LAG).
- Top 2 products per category by revenue (ROW_NUMBER + PARTITION BY inside a CTE).
- Average order value per region with NULL-safe COALESCE and a CASE size bucket.
Deeper Worked Examples
Monthly revenue and average order value by region
Step 1
Step 2
Step 3
Step 4
Step 5
Step 6
Top 3 products per category with a window function
Step 1
Step 2
Step 3
Step 4
Step 5
Step 6
Common Mistakes
- SELECT * in production queries — return only columns you need
- WHERE on an aggregate (use HAVING) — the classic silent wrong-result
- JOINing wrong keys or duplicate keys and getting row multiplication without noticing
- Comparing to NULL with '=' — use IS NULL
- Filtering a LEFT JOIN in WHERE and accidentally turning it into an INNER JOIN
- Mixed data types / string dates — always know the column's true type
- Forgetting GROUP BY columns must appear consistently (grain confusion)
- Running unbounded queries against production — always LIMIT during exploration
Real-World Usage
SQL is the interview gauntlet for analyst roles and the daily workhorse afterwards: scheduled reports, ad-hoc "can you tell me…" questions, and building the clean datasets that Power BI and Python consume. Analysts spend a large share of their week inside CTEs and GROUP BYs, so interviewers probe SQL specifically — expect round-trips of join-based, window-based and aggregation-based questions. The queries you learn here become the source of the dashboards you assemble later in this path.
Mini Project
Business problem: The e-commerce leadership wants a repeatable "Sales Health" query pack — not a one-off — covering revenue, customers and catalog performance.
Dataset: any public retail schema with customers/orders/order_items/products (samples are bundled with most SQL learning tools).
Objective: produce clean, reusable, commented queries ready to hand to a manager.
Task 1
Task 2
Task 3
Task 4
Task 5
| Deliverable | Proves |
|---|---|
| Monthly top-line pack | Aggregations + date trunks |
| New/repeat/churn logic | Window + DISTINCT thinking |
| Top-2-per-category | PARTITION BY + RANK |
| Month-over-month | LAG/LEAD patterns |
| Commented reusable file | Production-readiness |
Key Takeaways
- SELECT->FROM->WHERE->GROUP BY->HAVING->ORDER BY->LIMIT is the template
- Primary keys identify rows; foreign keys link tables; joins fetch the related data
- INNER/LEFT JOIN cover most needs — mind the grain and duplicate keys
- CASE WHEN builds categories; COALESCE and IS NULL tame NULLs
- Subqueries and CTEs (WITH) make complex logic readable and correct
- Window functions add order, rank and lag without collapsing rows
- Always ask: what is the grain (meaning of one row)?
- Never explore against production without LIMIT
Key Terms & Glossary
| Term | Meaning |
|---|---|
| primary key | A column (or combination of columns) that uniquely identifies every row in a table, such as customer_id. |
| foreign key | A column in one table that points to a primary key in another, like customer_id on an orders table, and so links the two. |
| grain | The meaning of a single row in a query result — order-level, customer-level or day-level — which defines which aggregates are valid. |
| JOIN | An operation that attaches rows from a second table by matching a key, the whole-table version of Excel's XLOOKUP. |
| CTE | A Common Table Expression: a named, WITH-prefixed subquery that lets you build a complex query from readable steps. |
| window function | A calculation over a window of rows — a rank, row number, running total or the previous row — that keeps every input row visible instead of collapsing it. |
Learning Resources
SQLBolt
The best interactive SQL introduction — learn by typing real queries in your browser.
Mode SQL Tutorial
Analyst-focused SQL lessons with a live query editor and business examples.
pgexercises
Hundreds of graded PostgreSQL exercises on a real club database — perfect daily reps.
LeetCode Top SQL 50
Interview-style SQL problems ranked by difficulty — the classic hiring-prep grind.
PostgreSQL Tutorial (Official Docs)
The authoritative reference — also reads as a free SQL course from chapter one.
SQLZoo
Another friendly interactive playground with exercises on multiple SQL dialects.
Chapter Quiz
Chapter Quiz — SQL
Five questions on the SQL every analyst interview probes: WHERE vs HAVING, JOINs, NULLs, windows and grain.
Question 1 of 5
You need categories whose summed revenue is above $10,000. Where does that filter go?
Knowledge Check
End-of-Topic Exam
Hint: Group first, then filter the aggregate — WHERE cannot see SUM.
Model answer: The query is SELECT category, SUM(amount) AS revenue, COUNT(*) AS orders FROM sales_2021 GROUP BY category ORDER BY revenue DESC, with the revenue limit applied as a HAVING clause on the summed revenue, keeping only values greater than 10,000. HAVING is required because WHERE runs before grouping and cannot reference an aggregate such as SUM.
Hint: The left row survives with NULLs; then filter on IS NULL.
Model answer: A LEFT JOIN keeps every row from the left table and fills the right-hand columns with NULL where no match exists. The query is SELECT c.customer_id, c.customer_name FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id WHERE o.order_id IS NULL. The IS NULL filter must stay in the WHERE clause, not in the JOIN condition, or the query silently collapses back into an inner join.
Hint: Row multiplication — the classic duplicate-key join.
Model answer: A key with duplicates on one side has caused a many-to-many join, so rows multiplied out. Check each table for duplicate keys: SELECT product_id, COUNT(*) FROM products GROUP BY product_id HAVING COUNT(*) IN (2) — or any count above 1 — and the same check on the order product key, then decide which side holds the true grain before re-running the join.
Hint: Partition per customer, order chronologically inside the window.
Model answer: The query is SELECT customer_id, order_date, amount, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) AS order_seq, LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_amount FROM sales_2021. The PARTITION BY customer_id restarts both the numbering and the lag for each customer, and the ORDER BY order_date inside the OVER makes 'previous' mean that customer's earlier order rather than an arbitrary neighbour row.