Mohamed A. Adel.

SQL

Topic 3 of 14 · In Data Analysis · September 8, 2026

⏱ 2–3 WeeksBeginner

Query relational databases: SELECT, filters, aggregations, JOINs, and window functions — the language of every analyst job.

++++FREE LEARNING PATHDATA ANALYSISDATA ANALYSIS · PHASE 1 · FUNDAMENTALSSQLQuery relational databases: SELECT, filters, aggreg…· 1 / 3BEGINNER2–3 WEEKSmotif / table5991 · 91 · MODULE 4.6

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_id on 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 table
  • WHERE — filter rows (before aggregation!)
  • ORDER BY — sort (ASC/DESC)
  • LIMIT — cap the number of rows (tired hands: LIMIT before 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;
JoinRows returned when a match is missing
INNER JOINOnly matched rows (orders with a known customer)
LEFT JOINAll rows of the left table + NULLs where no match (all customers, orders or none)
RIGHT JOINAll rows of the right table + NULLs where no match
FULL JOINAll 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 (not col = NULL!)
  • COALESCE(col, 0) — use a fallback value (COALESCE takes 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 BY inside OVER = ordering within the window
  • ROW_NUMBER = unique sequence (1,2,3)
  • RANK allows ties with gaps (1,1,3); DENSE_RANK no 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:

  1. List orders from last month with status, sorted newest first.
  2. Revenue and order count per category, top 3 only (HAVING + ORDER BY + LIMIT).
  3. Which customers have never ordered? (LEFT JOIN + IS NULL)
  4. Monthly revenue with the previous month column (CTE + LAG).
  5. Top 2 products per category by revenue (ROW_NUMBER + PARTITION BY inside a CTE).
  6. Average order value per region with NULL-safe COALESCE and a CASE size bucket.

Deeper Worked Examples

These examples push further than the core content — each one is a complete, step-by-step solution you can follow and then reproduce on your own.

Monthly revenue and average order value by region

Problem. Sales leadership wants a repeatable monthly view: for each region, the month's revenue and the average order value, keeping only the five strongest regions by revenue. The sales_2020 table has order_id, region, amount and order_date, with one row per order.
1

Step 1

Define the grain first: one row equals one order, so COUNT(*) counts orders, SUM(amount) is revenue and the average order value is revenue divided by the order count.
2

Step 2

Predict the shape of the answer before running: for one month you expect a one-row-per-region table, for example North 48,200 from 410 orders giving an average order value of 117.6.
3

Step 3

Write the grouped query as SELECT region, DATE_TRUNC('month', order_date) AS month, COUNT(*) AS orders, SUM(amount) AS revenue, SUM(amount) / COUNT(*) AS aov FROM sales_2020 GROUP BY region, 2.
4

Step 4

Rank rather than guess a threshold: the business asked for the five strongest regions, so append ORDER BY revenue DESC LIMIT 5, which stays truthful without a magic figure to re-tune.
5

Step 5

Confirm the output has exactly five rows, then verify one average by hand: 48,200 divided by 410 is 117.56, which matches the query result.
6

Step 6

Save the query as a view or a commented file stating the grain and the date range, so next month's report reuses the same logic.

Top 3 products per category with a window function

Problem. The catalogue team wants, for each category, the three best-selling products by revenue, each product's revenue, its rank inside its category, and the revenue of the previous best product for context. You have products(product_id, category, product_name) and order_lines(order_id, product_id, quantity, price).
1

Step 1

Build revenue per product first in a CTE named product_revenue: join order_lines to products on product_id, group by category and product, and sum quantity times price as the revenue.
2

Step 2

Prove the CTE on one slice before ranking: Electronics should show Headphones 12,000, Camera 9,400 and Monitor 6,100 — three rows you can verify by hand.
3

Step 3

Add the ranking layer in a second CTE using ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn and LAG(revenue) OVER (PARTITION BY category ORDER BY revenue DESC) AS prev_top_revenue.
4

Step 4

Keep exactly three rows per category by filtering the ranking CTE with WHERE rn IN (1, 2, 3), and round revenue to whole pounds for the report.
5

Step 5

Order the final result by category then rn, so the report reads top-to-bottom within each category.
6

Step 6

Present the whole query as one readable statement — product_revenue, then ranked, then filtered — and note the two golden rules: partitions reset per category, and window functions never collapse rows.

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.

1

Task 1

Query 1 — Top line: monthly revenue, orders, and AOV (revenue/orders) with a rolling 12-month window.
2

Task 2

Query 2 — Customers: count of first-time, repeat, and churned customers per month (define churn as no order in 60 days).
3

Task 3

Query 3 — Catalog: revenue and rank per product category using window functions (top 2 per category).
4

Task 4

Query 4 — Geography: region performance compared to the previous month using LAG.
5

Task 5

Wrap the four queries in a single file with CTEs where useful, comments in plain language, and grain documented.
DeliverableProves
Monthly top-line packAggregations + date trunks
New/repeat/churn logicWindow + DISTINCT thinking
Top-2-per-categoryPARTITION BY + RANK
Month-over-monthLAG/LEAD patterns
Commented reusable fileProduction-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

The vocabulary this topic uses, defined the way a working professional would.
TermMeaning
primary keyA column (or combination of columns) that uniquely identifies every row in a table, such as customer_id.
foreign keyA column in one table that points to a primary key in another, like customer_id on an orders table, and so links the two.
grainThe meaning of a single row in a query result — order-level, customer-level or day-level — which defines which aggregates are valid.
JOINAn operation that attaches rows from a second table by matching a key, the whole-table version of Excel's XLOOKUP.
CTEA Common Table Expression: a named, WITH-prefixed subquery that lets you build a complex query from readable steps.
window functionA 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

These curated sources go deeper than this module: official docs, books, interactive practice, and full courses. Mix them into your routine to go from comfortable to professional.

SQLBolt

The best interactive SQL introduction — learn by typing real queries in your browser.

Open resource ↗

Mode SQL Tutorial

Analyst-focused SQL lessons with a live query editor and business examples.

Open resource ↗

pgexercises

Hundreds of graded PostgreSQL exercises on a real club database — perfect daily reps.

Open resource ↗

LeetCode Top SQL 50

Interview-style SQL problems ranked by difficulty — the classic hiring-prep grind.

Open resource ↗

PostgreSQL Tutorial (Official Docs)

The authoritative reference — also reads as a free SQL course from chapter one.

Open resource ↗

SQLZoo

Another friendly interactive playground with exercises on multiple SQL dialects.

Open resource ↗

Chapter Quiz

Put this lesson to work: answer the questions below, get instant feedback and a pass/fail score. Score 70% or higher before you move on — that is your gate to the next topic.

Chapter Quiz — SQL

Five questions on the SQL every analyst interview probes: WHERE vs HAVING, JOINs, NULLs, windows and grain.

0 / 5

Question 1 of 5

You need categories whose summed revenue is above $10,000. Where does that filter go?

Knowledge Check

SELECT DATE_TRUNC('month', order_date) AS month, SUM(amount) AS revenue FROM orders GROUP BY 1 HAVING SUM(amount) > 10000 ORDER BY month; — HAVING applies the aggregate condition.
The customer row still appears, with the order columns filled with NULL — which is how you find 'customers who never ordered' (WHERE order_id IS NULL).
RANK leaves gaps after ties (1,1,3); DENSE_RANK doesn't (1,1,2).
Use 'col IS NULL' (or IS NOT NULL). Never 'col = NULL' — NULL is not equal to anything, so that comparison is never true.
Aggregates don't exist yet when WHERE runs — the filter happens before grouping. Use HAVING for filters on aggregate results.

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.

Next Step

Congratulations — you just finished SQL. The next topic is Statistics for Data Analysis. Press on when you are ready: it will build directly on what you practiced here.