There's a very widespread and very false idea: that SQL is for pulling the data out and that the real analysis happens somewhere else. In practice, most of a company's analytical work —the month's metrics, the time series, the breakdown by country, the customer Pareto, the cohorts— fits entirely in SQL, runs where the data lives and doesn't need a single file moved. This lesson turns 10-03's window functions into complete reports. But before the first query there's something more important, and it's why an analyst with judgement is worth far more than a fast one: half of analysis errors aren't about SQL, they're about definitions. Does "sales" include shipping? What about the cancelled order? Is an "active customer" one who bought at some point or one who bought this year? Every one of those questions changes the figure, and the engine answers none of them.
Contents
- The analyst's workflow
- GreenStore's definitions
- Fundamental metrics
- Time analysis
- Segmentation
- ABC / Pareto analysis
- Cohorts and retention
- Presentation: pivoting with
CASEand withcrosstab - Reproducibility and where SQL fits
- Frequent analysis errors
- Common Mistakes and Tips
- Exercises
- Conclusion
- The analyst's workflow
flowchart LR
A["<b>Business question</b><br/>'are we selling more than last year?'"] --> B["<b>Defined metric</b><br/>what's summed, what's excluded,<br/>what period, what granularity"] --> C["<b>Query</b>"]
C --> D["<b>Validation</b><br/>does it tally with a known total?"] --> E["<b>Presentation</b><br/>table, chart, dashboard"]
D -.->|"doesn't tally"| B
The two steps everybody skips are the second and the fourth, and they're the ones that separate a correct number from a plausible one. Defining forces you to talk to whoever asked the question —and very often you discover the question was a different one. Validating means checking the result against something you already knew: a total, a count, last year's figure. If a breakdown by country doesn't add up to the same as the overall total, the breakdown is wrong, however elegant the query.
- GreenStore's definitions
These are the definitions the course uses. They aren't "the right ones": they're the ones we've chosen, and what matters is that they're written down.
| Metric | Exact definition | Value |
|---|---|---|
| Product revenue ("sales") | SUM(quantity * unit_price * (1 - discount)) over order_lines, excluding shipping, all statuses |
€727.95 |
| Total income | Product revenue + shipping costs | €846.20 |
| Sales net of cancellations | Product revenue excluding cancelled orders |
€701.20 |
| Orders | Rows in orders, all statuses |
20 |
| Average order value | Product revenue / number of orders | €36.40 |
| Units per order | SUM(quantity) / number of orders |
5.65 |
| Buying / repeat customer | With ≥ 1 order / with ≥ 2 orders | 12 of 15 / 7 |
| New / repeat order | That customer's first / the ones after it | 12 / 8 |
| Cohort | Month of the customer's signup_date |
8 cohorts |
| Return rate (orders) | Orders with a return / orders | 15.00 % |
| Return rate (amount) | Amount returned / product revenue | 11.07 % |
And the three decisions behind them:
- Shipping isn't sales. It's a service passed through, not commercial margin. If you included it, revenue would be €846.20 and the average order value €42.31: figures just as "true" that answer a different question. The serious mistake isn't choosing wrong, it's mixing the two in the same report.
- The cancelled order (number 6) counts in gross revenue and not in net. Its €26.75 really were ordered and were fully refunded: a demand report should include it, an income one shouldn't. And "all statuses" means orders not yet delivered (2
paid, 1pending, 2shipped) count; for income collected you'd have to filter by status and the number would be different.
- Fundamental metrics
The first seven, in one query with FILTER (04-04):
SELECT COUNT(DISTINCT o.id) AS orders,
COUNT(DISTINCT o.customer_id) AS buyers,
SUM(ol.quantity) AS units,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
FILTER (WHERE o.status <> 'cancelled'), 2) AS net_revenue,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
/ COUNT(DISTINCT o.id), 2) AS avg_order_value,
ROUND(SUM(ol.quantity)::numeric / COUNT(DISTINCT o.id), 2) AS units_per_order
FROM orders AS o JOIN order_lines AS ol ON ol.order_id = o.id;| orders | buyers | units | revenue | net_revenue | avg_order_value | units_per_order |
|---|---|---|---|---|---|---|
| 20 | 12 | 113 | 727.95 | 701.20 | 36.40 | 5.65 |
The immediate validation: 20 orders and 113 units are the figures the course has been carrying since 01-06, and 727.95 − 26.75 = 701.20 tallies with the cancelled order's refund. If any of the three didn't come out, you'd have to stop.
Return rate
SELECT COUNT(*) AS returns_count, ROUND(SUM(rt.amount), 2) AS amount_returned,
ROUND(100.0 * COUNT(DISTINCT rt.order_id) / (SELECT COUNT(*) FROM orders), 2) AS order_rate_pct,
ROUND(100 * SUM(rt.amount) / 727.95, 2) AS amount_rate_pct
FROM returns AS rt;| returns_count | amount_returned | order_rate_pct | amount_rate_pct |
|---|---|---|---|
| 3 | 80.57 | 15.00 | 11.07 |
The two rates say different things and you have to publish which one it is: 15 % of orders had some return, but only 11 % of the money came back, because two of the three are partial —from order 10 the cream (€34.02) of €48.27 was returned, and from order 13 the bags (€19.80) of €30.30. Only order 6, cancelled, was returned in full.
Active products with no sales, and new versus repeat customers
SELECT p.id, p.name, p.stock FROM products AS p
WHERE p.active AND NOT EXISTS (SELECT 1 FROM order_lines AS ol WHERE ol.product_id = p.id)
ORDER BY p.id;| id | name | stock |
|---|---|---|
| 13 | Soy wax candles (pack of 2) | 0 |
| 19 | Natural stick deodorant 50 g | 75 |
Two active products that have never been sold, with opposite business readings: the candles have stock 0 —perhaps they were never actually available— and the deodorant has 75 units waiting. The third with no sales, the spirulina capsules, doesn't show up because it's discontinued: that WHERE p.active is a definitional decision, not a detail.
WITH first_order AS (SELECT customer_id, MIN(order_date) AS first_date FROM orders GROUP BY customer_id)
SELECT to_char(o.order_date, 'YYYY-MM') AS month,
COUNT(*) FILTER (WHERE o.order_date = f.first_date) AS new_orders,
COUNT(*) FILTER (WHERE o.order_date > f.first_date) AS repeat_orders
FROM orders AS o JOIN first_order AS f ON f.customer_id = o.customer_id
GROUP BY 1 ORDER BY 1;| month | new_orders | repeat_orders |
|---|---|---|
| 2025-03 | 2 | 0 |
| 2025-10 | 2 | 0 |
| 2025-12 | 0 | 2 |
| 2026-02 | 0 | 2 |
(4 of 12 rows; the total is 12 new and 8 repeat.) The reading is the one any young business would expect: until November almost every order is from a new customer, and from December on they're all from returning customers. That's one good signal —there is retention— and one worrying one: acquisition has stopped. Neither is visible from total revenue alone.
- Time analysis
The three columns every dashboard asks for, over 10-03's monthly series:
WITH monthly AS (
SELECT date_trunc('month', o.order_date)::date AS month,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM orders AS o JOIN order_lines AS ol ON ol.order_id = o.id GROUP BY 1)
SELECT to_char(month, 'YYYY-MM') AS month, revenue,
SUM(revenue) OVER w AS running_total,
ROUND(AVG(revenue) OVER (ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS moving_avg_3,
ROUND(100 * (revenue - LAG(revenue) OVER w)
/ LAG(revenue) OVER w, 2) AS change_pct
FROM monthly WINDOW w AS (ORDER BY month) ORDER BY month;| month | revenue | running_total | moving_avg_3 | change_pct |
|---|---|---|---|---|
| 2025-03 | 68.80 | 68.80 | 68.80 | (null) |
| 2025-09 | 32.76 | 410.04 | 41.88 | -32.13 |
| 2025-10 | 97.20 | 507.24 | 59.41 | 196.70 |
| 2025-12 | 64.58 | 603.52 | 64.49 | 103.72 |
| 2026-02 | 49.33 | 727.95 | 63.00 | -34.31 |
(5 of 12 rows; the full series is in 10-03.) Three things a report should say that the table alone doesn't:
- The running total closes at €727.95 and passes through €603.52 in December: those are the two canonical figures, and their matching validates the whole series.
- The 3-month moving average is what you should show on the chart, not the raw series: actual revenue swings between €31.70 and €97.20 and the moving average between €41.88 and €71.87. With small volumes, the raw series is mostly noise.
- October's
+196.70 %isn't news: it's that September had a single order, and publishing that change without the base it's computed on is misleading in good faith. Rule: don't publish a percentage change if the denominator is small; publish the absolute figure and the number of orders alongside it.
The year-on-year comparison you can't make
The question "are we selling more than this time last year?" is the most frequent one in the world, and in GreenStore it has no answer: the series starts in March 2025 and ends in February 2026, so January and February 2026 have nothing to compare against. The right thing is to say so, not to compute a NULL and let somebody interpret it. And what you can do, with the same honesty: compare the two partial years, making it clear they aren't comparable in length.
| year | months with data | orders | revenue |
|---|---|---|---|
| 2025 | 10 (Mar-Dec) | 16 | 603.52 |
| 2026 | 2 (Jan-Feb) | 4 | 124.43 |
2026's €124.43 is not "a 79 % drop": it's two months against ten. What's comparable is the monthly average —€60.35 in 2025 against €62.22 in 2026— or the same calendar months, which here don't exist.
- Segmentation
One and the same total, cut four different ways. The pattern is always the same GROUP BY, and what matters is that all four breakdowns add up to €727.95:
SELECT cat.name AS category, COUNT(DISTINCT o.id) AS orders,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue,
ROUND(100 * SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
/ SUM(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))) OVER (), 2) AS pct
FROM order_lines AS ol
JOIN products AS p ON p.id = ol.product_id
JOIN categories AS cat ON cat.id = p.category_id
JOIN orders AS o ON o.id = ol.order_id
GROUP BY cat.name ORDER BY revenue DESC;| category | orders | revenue | pct |
|---|---|---|---|
| Food | 10 | 256.27 | 35.20 |
| Drinks | 8 | 195.28 | 26.83 |
| Natural cosmetics | 6 | 156.32 | 21.47 |
| Sustainable home | 4 | 88.58 | 12.17 |
| Personal hygiene | 3 | 31.50 | 4.33 |
Supplements doesn't appear, and that's a result too: its only product is discontinued. An honest report says so; one that shows five rows lets people believe there are five categories. The other three cuts:
| Customer's country | Orders | Revenue | Average order value |
|---|---|---|---|
| Spain | 14 | 433.70 | 30.98 |
| Portugal | 3 | 156.48 | 52.16 |
| France | 3 | 137.77 | 45.92 |
| Payment method | Orders | Revenue | Channel | Orders | Revenue |
|---|---|---|---|---|---|
| card | 11 | 398.00 | Phone (with sales rep) | 10 | 378.83 |
| paypal | 4 | 155.05 | Web (no sales rep) | 10 | 349.12 |
| transfer | 3 | 121.70 | — | — | — |
| cash_on_delivery | 2 | 53.20 | — | — | — |
Three readings no overall total gave you. Spain contributes 60 % of revenue but has the lowest average order value (€30.98 against Portugal's €52.16): many small orders against a few big ones, which completely changes the shipping strategy. Card concentrates 55 % of revenue in 11 of the 20 orders. And the two channels are level in number, with phone slightly ahead on amount (€378.83 against €349.12): the attended channel billing more per order is what would justify having sales reps, but it's a testable hypothesis, not a conclusion.
- ABC / Pareto analysis
The Pareto principle —"a few items account for most of the total"— is computed with a window running total (10-03) and classified with a CASE:
WITH sales AS (
SELECT c.id, c.name || ' ' || c.last_name AS customer, c.country,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY c.id, c.name, c.last_name, c.country),
cum AS (SELECT *, ROUND(100 * SUM(revenue) OVER (ORDER BY revenue DESC, id
ROWS UNBOUNDED PRECEDING)
/ SUM(revenue) OVER (), 2) AS pct_cumulative FROM sales)
SELECT customer, country, revenue, pct_cumulative,
CASE WHEN pct_cumulative <= 80 THEN 'A' WHEN pct_cumulative <= 95 THEN 'B' ELSE 'C' END AS class_
FROM cum ORDER BY revenue DESC;| customer | country | revenue | pct_cumulative | class_ |
|---|---|---|---|---|
| Sofia Moreira Costa | Portugal | 111.88 | 15.37 | A |
| Lucía Martínez Soler | Spain | 107.60 | 30.15 | A |
| Camille Dubois | France | 70.87 | 39.89 | A |
| Carlos Ferrer Ibáñez | Spain | 59.46 | 65.89 | A |
| Pau Llorens Vidal | Spain | 57.33 | 73.76 | A |
(5 of the 7 class-A rows —missing are Julien Moreau, 4th with €66.90, and Javier Ortega Ruiz, 5th with €62.93. Then come Ana with €54.85, Tiago with €44.60 and Diego with €31.70 in class B, and Elena with €30.30 and Marta with €29.53 in class C.) The summary by class, for customers and for products:
| Class | Customers | % of revenue | Products | % of revenue |
|---|---|---|---|---|
| A (up to 80 % cumulative) | 7 | 73.76 % | 10 | 77.41 % |
| B (up to 95 %) | 3 | 18.02 % | 4 | 14.66 % |
| C (the rest) | 2 | 8.22 % | 3 | 7.93 % |
GreenStore's Pareto is a gentle one: the top 6 customers account for 65.89 % and it takes 7 to reach 73.76 %; in the classic 80/20 it would take 2 or 3 out of 12. That's a business fact: the shop doesn't depend on one big customer, which lowers the risk and at the same time suggests there are no key accounts to cultivate. And a methodological warning: the 80/95 cut-off is a convention, and you have to write down which one you use —including or excluding the row that crosses the threshold— because it changes who lands in each group.
- Cohorts and retention
A cohort groups customers by the moment they came in and follows them over time. On GreenStore, using the month of signup_date:
WITH s AS (SELECT o.customer_id, COUNT(DISTINCT o.id) AS orders,
SUM(ol.quantity * ol.unit_price * (1 - ol.discount)) AS revenue
FROM orders AS o JOIN order_lines AS ol ON ol.order_id = o.id GROUP BY 1)
SELECT to_char(c.signup_date, 'YYYY-MM') AS cohort,
COUNT(*) AS customers,
COUNT(s.customer_id) AS buyers,
ROUND(100.0 * COUNT(s.customer_id) / COUNT(*), 1) AS conversion_pct,
COALESCE(SUM(s.orders), 0) AS orders,
COALESCE(ROUND(SUM(s.revenue), 2), 0.00) AS revenue
FROM customers AS c LEFT JOIN s ON s.customer_id = c.id
GROUP BY 1 ORDER BY 1;| cohort | customers | buyers | conversion_pct | orders | revenue |
|---|---|---|---|---|---|
| 2025-01 | 2 | 2 | 100.0 | 5 | 167.06 |
| 2025-02 | 3 | 3 | 100.0 | 5 | 147.31 |
| 2025-03 | 2 | 2 | 100.0 | 4 | 169.21 |
| 2025-04 | 2 | 2 | 100.0 | 3 | 115.47 |
| 2025-05 | 2 | 2 | 100.0 | 2 | 97.20 |
| 2025-06 | 2 | 1 | 50.0 | 1 | 31.70 |
| 2025-09 | 1 | 0 | 0.0 | 0 | 0.00 |
| 2026-01 | 1 | 0 | 0.0 | 0 | 0.00 |
The query is correct and the analysis would be nonsense. The cohorts have one, two or three customers: a single customer who doesn't buy turns the 2025-09 cohort into a "0 % conversion" that means nothing. And the older cohorts win by definition, because they've been buying longer: comparing January's 5 orders with June's 1 is comparing ten months with eight. And that's the analysis lesson, not the SQL one. A result from samples of size 1 or 2 isn't a result: it's an anecdote in table format. What you do is (a) say so in the report, (b) group into larger cohorts —by quarter instead of by month— and (c) always compare at the same age: "orders within 90 days of signing up", which puts every cohort on equal footing and is the standard way to do retention. With 15 customers not even that would save the analysis; with 15,000, it's exactly the report management will ask for.
When a breakdown stops making sense: once some group drops below a few dozen observations, the percentage you compute will swing more than the signal you're looking for. Before splitting a total into twenty pieces, look at how many rows are left in the smallest piece.
- Presentation: pivoting with
CASE and with crosstab
CASE and with crosstabA management report almost never wants rows: it wants a matrix, with categories down the rows and years across the columns. The portable way is 06-05's, a conditional aggregate per column (SUM(CASE …), or its modern form with FILTER):
SELECT cat.name AS category,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
FILTER (WHERE o.order_date < '2026-01-01'), 2) AS y2025,
ROUND(COALESCE(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
FILTER (WHERE o.order_date >= '2026-01-01'), 0), 2) AS y2026,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total
FROM order_lines AS ol
JOIN products AS p ON p.id = ol.product_id
JOIN categories AS cat ON cat.id = p.category_id
JOIN orders AS o ON o.id = ol.order_id
GROUP BY cat.name ORDER BY total DESC;| category | y2025 | y2026 | total |
|---|---|---|---|
| Food | 215.67 | 40.60 | 256.27 |
| Drinks | 146.55 | 48.73 | 195.28 |
| Natural cosmetics | 128.22 | 28.10 | 156.32 |
| Sustainable home | 88.58 | 0.00 | 88.58 |
| Personal hygiene | 24.50 | 7.00 | 31.50 |
The columns add up to €603.52 and €124.43: 2025's and 2026's canonical figures. And Sustainable home comes out at €0.00 in 2026 thanks to the COALESCE, which is informative: it stopped selling.
crosstab from the tablefunc extension
This is where 06-05's promise gets kept. PostgreSQL ships the tablefunc extension, with a crosstab() function that pivots from a three-column query: row, column and value.
CREATE EXTENSION IF NOT EXISTS tablefunc;
SELECT * FROM crosstab(
$$SELECT cat.name, to_char(o.order_date, 'YYYY'),
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
FROM order_lines AS ol
JOIN products AS p ON p.id = ol.product_id
JOIN categories AS cat ON cat.id = p.category_id
JOIN orders AS o ON o.id = ol.order_id
GROUP BY 1, 2 ORDER BY 1, 2$$,
$$SELECT unnest(ARRAY['2025','2026'])$$ -- 2nd query: the columns, in order
) AS t(category text, y2025 numeric, y2026 numeric); -- ⬅️ you have to declare them by hand| category | y2025 | y2026 |
|---|---|---|
| Drinks | 146.55 | 48.73 |
| Food | 215.67 | 40.60 |
| Natural cosmetics | 128.22 | 28.10 |
| Personal hygiene | 24.50 | 7.00 |
| Sustainable home | 88.58 | (null) |
The same figures, with two differences that matter. The order is alphabetical by the row column, because crosstab requires ORDER BY 1, 2 in the source query and accepts no other. And Sustainable home comes out NULL in 2026, not 0.00, because to crosstab "there is no row" and "there's a row with a zero" are different things — and in a chart, NULL is a hole. You fix it by wrapping the outside in COALESCE(y2026, 0).
SUM(CASE …) (06-05) |
crosstab |
|
|---|---|---|
| Portability | Total: standard SQL | PostgreSQL only, and you have to install the extension |
| Writing 12 columns | 12 CASEs: tedious |
One short query |
| Dynamic columns | You have to know them in advance | Same: the output list is declared by hand |
| Missing data | 0 (with the ELSE 0) |
NULL |
| Readability | Verbose but obvious | Compact and cryptic: two queries nested inside $$ |
The criterion: for two, three or four columns, SUM(CASE …) wins on clarity and portability; crosstab pays off from eight or ten fixed, known columns on. And for genuinely dynamic columns —"one per month, however many there are"— neither works: SQL returns a fixed number of columns decided at planning time. That pivot belongs in the presentation layer (BI, spreadsheet, pandas.pivot_table), and that's the natural division of labour.
- Reproducibility and where SQL fits
An analysis you can't repeat three months from now isn't an analysis, it's a screenshot:
- The queries live in the repository, in
.sqlfiles with a comment saying which question they answer and which definition they use ("sales = product, excluding shipping, all statuses"). A dashboard with the SQL hidden inside the tool is a dashboard nobody can audit. - Views and materialized views as a semantic layer (10-01):
v_sales_detaildefines once what a line's amount is, and from then on nobody writesquantity * unit_price * (1 - discount)again — which is exactly where somebody would forget the discount.mv_monthly_salesdoes the same for the series, and saves recomputing it on every query. - Numbers that validate themselves. If every report includes a total you already know, a botched breakdown gives itself away instantly. And the division of labour with the other tools, which is the question every analyst asks:
| Tool | Good at | Bad at |
|---|---|---|
| SQL | Filtering, joining, aggregating, sorting, windows; working where the data is without moving it; volumes that don't fit in memory | Advanced statistics, models, charts, loops, complex free text |
| Python / pandas / R | Models, series, complex cleaning, charts, reproducibility in notebooks | Scale: if you have to pull 50 million rows in to group them, group them in SQL |
| BI (Power BI, Metabase, Looker, Superset) | Publishing, exploring, interactive filtering, distributing | Defining metrics: if every panel defines "sales" its own way, you'll have five different figures |
The course's criterion: aggregate in SQL, model and draw outside. The operating rule: whatever reduces rows, do it as close to the database as possible. Pulling 2 million rows into pandas for a groupby that returns 12 wastes network, memory and time, and it's the analytical version of 08-04's antipattern.
- Frequent analysis errors
| Error | How it shows up | How to avoid it |
|---|---|---|
Counting rows duplicated by a JOIN |
20 orders become 47; the average order value gets divided by 2.35 | COUNT(DISTINCT o.id); check the count after each JOIN |
| An average of averages | Averaging the three countries' average order values gives €43.02, not €36.40 | Sum numerators and denominators |
Ignoring NULLs |
AVG skips them; COUNT(column) doesn't count them; a NOT IN with nulls returns 0 rows |
Decide explicitly: COALESCE, FILTER, NOT EXISTS (04-03) |
| Comparing incomplete periods | "February is down 34 %" when February isn't over yet | Compare closed periods, or the same number of days |
| Publishing a percentage over few cases | October's +196.70 % over a single order in September |
Publish the absolute figure and the sample size alongside |
| Confusing correlation with causation | "Orders with a sales rep bill more → let's hire more sales reps" | Sales reps take phone calls, which already tend to be larger orders. To claim causation you need an experiment |
| Changing the definition halfway through a report | One table with shipping and the next one without | Write the definition once and encapsulate it in a view |
The second-to-last row is the most dangerous because of how reasonable it sounds: the phone channel bills €378.83 against the web's €349.12, but that doesn't prove the sales rep generates more sales — with 10 orders per channel the difference is €3 per order. The way to find out is an experiment, not a query.
Common Mistakes and Tips
- Starting with the query and not with the definition. "Give me this month's sales" has at least four correct answers. Ask before you write. And not validating against a known total: it's the cheapest check there is and it catches 90 % of
JOINerrors. - Rounding at every step. Round only when presenting: rounding an intermediate value and then summing accumulates the error. And using
AVGon an already averaged column: an average of averages only matches the overall one if every group is the same size. - Presenting a chart without the empty months. The calendar with
generate_series(11-01) isn't decoration: without it the trend is a different one. And treating a percentage over 1 or 2 cases as information: with small samples, percentages mislead more than they inform. - Tip: write the definition in a comment inside the query itself. The report and its definition travel together, and whoever inherits it will know what they're looking at.
- Tip: keep a control figure. Every recurring report should carry a row or column that gives away when something has broken — the analytical equivalent of an automated test. And if a result surprises you, suspect the SQL before the business: nine surprises out of ten are a
JOINthat multiplies or a filter that was missing.
Exercises
Exercise 1
Management asks for "margin by category". (1) List three definitional decisions you have to make before writing anything. (2) Write the query using products.cost and the course's line amount. (3) Why can the margin computed this way be wrong even if the query is correct?
Exercise 2
Compute, for each month, the revenue, the number of distinct customers who bought and the average revenue per customer, sorted by month. (1) Write it. (2) Why doesn't the sum of "distinct customers per month" come to 12? (3) Which month has the highest average revenue per customer and what caution do you need before highlighting it?
Exercise 3
A colleague presents this conclusion: "Portugal is our best market: its average order value is 68 % higher than Spain's". (1) Is the figure correct? (2) Give three reasons why the conclusion doesn't hold. (3) What analysis would you propose instead?
Solutions
Solution 1
1. (a) Margin on the actual sale price (with the discount) or on the list price? The discount comes out of the margin, so on the line amount. (b) Is the cost the current one (products.cost) or the one at the time of sale? The schema only stores the current one: you have to say so. (c) Are cancelled orders included? A margin on sales that were refunded isn't margin.
-- 2
SELECT cat.name AS category,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue,
ROUND(SUM(ol.quantity * p.cost), 2) AS cost,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
- SUM(ol.quantity * p.cost), 2) AS margin
FROM order_lines AS ol
JOIN products AS p ON p.id = ol.product_id
JOIN categories AS cat ON cat.id = p.category_id
JOIN orders AS o ON o.id = ol.order_id
WHERE o.status <> 'cancelled'
GROUP BY cat.name ORDER BY margin DESC;3. Because cost is the current cost, not the one at the time of sale: it's the problem unit_price does solve for the price (01-06) and the schema does not solve for the cost, so if costs have risen the historical margin will come out understated. The design fix would be to store unit_cost in order_lines; until that exists, the number is published as an estimate and you say why.
Solution 2
SELECT to_char(o.order_date, 'YYYY-MM') AS month,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue,
COUNT(DISTINCT o.customer_id) AS customers,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
/ COUNT(DISTINCT o.customer_id), 2) AS avg_per_customer
FROM orders AS o JOIN order_lines AS ol ON ol.order_id = o.id GROUP BY 1 ORDER BY 1;2. Because a customer can buy in several months and gets counted in each one: COUNT(DISTINCT customer_id) is computed within each group, and the groups aren't disjoint by customer — summing that column counts Lucía three times. The total of distinct buyers, 12, only comes from a query with no monthly GROUP BY; it's the same error as summing "daily active users" to get the monthly figure. 3. The highest is 2025-10, with €48.60 per customer (€97.20 across 2 customers), and the caution is section 7's: that's two customers, so the "record" is explained by a single large order.
Solution 3
1. The figure is correct: €52.16 against €30.98 is 68.4 % more; the arithmetic is fine. 2. (a) Sample size: Portugal is 3 orders from 2 customers. A single large order moves the average order value by tens of euros; there's no basis for a conclusion. (b) "Best market" isn't "average order value": Spain contributes €433.70, nearly three times Portugal's (€156.48), with 14 orders and 8 customers. If "best" means volume, the conclusion flips. (c) The costs are missing: shipping to Portugal costs €9.90 against the €4.95 or €0 domestic, and that shipping eats part of the advantage; the margin could be lower. And a fourth, methodological one: the higher average order value could be because international shipping pushes the customer to bundle more items per order —free delivery above a certain amount— which is an effect of the pricing scheme itself and not a property of the market.
3. Compare margin per customer and per period, not average order value: revenue minus product cost minus real shipping cost, divided by active customers, with the number of observations published next to every figure. And if the real question is "where do we invest in acquisition?", the answer needs the acquisition cost and the repeat purchase rate by country, not an average over three orders.
Conclusion
SQL is an analytical tool in its own right, and the craft is less about syntax than about judgement:
- The flow is question → definition → query → validation → presentation, and the steps everybody skips are the second and the fourth. Defining forces you to decide whether "sales" includes shipping (€727.95 against €846.20) and whether the cancelled order counts (€727.95 against €701.20); validating means checking against a total you already knew. GreenStore's fundamental metrics: 20 orders, 12 buyers, 113 units, an average order value of €36.40, 5.65 units per order, a 15.00 % return rate by orders and 11.07 % by amount —two different numbers answering different questions—, 2 active products with no sales and a split of 12 new orders against 8 repeat that reveals acquisition has stopped.
- Time analysis with a running total (closing at €727.95), a 3-month moving average (the one to show) and monthly change (October's
+196.70 %that isn't news). And the year-on-year comparison you can't make, because saying so is part of the job. Segmentation by category, country, payment method and channel, with all four breakdowns adding up to €727.95, and the finding no total gave you: Spain bills more but with the lowest average order value. The Pareto is gentle —6 customers account for 65.89 %— with A/B/C classes of 7/3/2 customers and 10/4/3 products. - The cohorts come out well written and poorly founded: with one or two customers per cohort, the result is an anecdote in table format. Say so, group more coarsely and compare at the same age. The pivot with
SUM(CASE …)—portable, with zeros— and withtablefunc'scrosstab—compact, withNULLwhere there's no data and with the columns declared by hand—, closing 06-05's promise. Neither does genuinely dynamic columns: that belongs to the presentation layer. - Reproducibility: queries in the repository with their definition written down, views and materialized views as a semantic layer, and the division of labour: aggregate in SQL, model and draw outside.
All of this runs in psql or in a BI tool. But the SQL that really runs most times a day isn't written by an analyst: it's fired off by an application, hundreds of times a second, from a web process that opens connections, runs queries and closes them. In the next lesson, SQL in web development, you close the module: the connection and the pool; how a parameterized query is run from code and how the transaction is handled; ORM versus hand-written SQL with the course's criterion; the antipatterns that kill a website, starting with the N+1 that 08-04 left pending; the useful patterns —cursor pagination, a defensive LIMIT, queues with SKIP LOCKED, JSON straight from PostgreSQL—; and the checklist for when somebody says "the website is slow".
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
