The previous lesson closed module 10 with a sentence: "what's missing isn't more functions, but context". This module is that context, and it starts with the most concrete thing there is: the problems you'll be asked to solve. Because the toolbox is full now —JOINs, aggregates, subqueries, CTEs, window functions, JSON— but on a real project nobody asks you for "a LEFT JOIN": they ask you for "a product listing with filters the user picks", "the management dashboard", "the customers who've stopped buying" or "export this for the accountant".
The good news is that those requests repeat. The business changes, the table names change, and the pattern is the same. This lesson is the catalogue of those patterns: eleven use cases, each with its business framing, its solution on GreenStore and a note on which tool from the course solves it. There's no new syntax. There's pattern recognition, which is a different thing and a far more useful one.
Contents
- Paginated listing with optional filters
- Search: prefix, substring and full-text
- A KPI report in a single row
- Top N and ranking
- Finding gaps: the anti-join
- Time series with no gaps
- Customer cohorts
- Detecting and cleaning up duplicates
- Auditing: who changed what and when
- Exporting to another system
- Loading from a file with a staging table
- Summary table: use case → tool → lesson
- Common Mistakes and Tips
- Exercises
- Conclusion
- Paginated listing with optional filters
The request. "The catalogue screen has four filters —category, maximum price, text and in-stock only— and the user can fill in whichever they like. It also has pagination." It's the number-one use case in the world, and the one most often written badly. The problem isn't the WHERE: it's that you don't know what the WHERE will be until the user hits "search". There are two approaches, and it's worth knowing both.
The (:param IS NULL OR column = :param) pattern
A single query, with one parameter per filter that neutralizes itself when it arrives null:
SELECT p.id, p.name, cat.name AS category, p.price, p.stock
FROM products AS p
JOIN categories AS cat ON cat.id = p.category_id
WHERE p.active
AND (:category_id IS NULL OR p.category_id = :category_id)
AND (:max_price IS NULL OR p.price <= :max_price)
AND (:text IS NULL OR p.name ILIKE '%' || :text || '%')
AND (NOT :in_stock_only OR p.stock > 0)
ORDER BY p.price DESC, p.id
LIMIT 5;With all four parameters at NULL/false (the catalogue's first page, unfiltered):
| id | name | category | price | stock |
|---|---|---|---|---|
| 15 | Ceremonial matcha green tea 30 g | Drinks | 22.00 | 40 |
| 6 | Aloe vera face cream 50 ml | Natural cosmetics | 18.90 | 60 |
| 8 | Almond body oil 200 ml | Natural cosmetics | 14.25 | 45 |
| 13 | Soy wax candles (pack of 2) | Sustainable home | 13.75 | 0 |
| 1 | Extra virgin olive oil 500 ml | Food | 12.50 | 120 |
(First 5 of 19 rows: the active products; number 20, discontinued, doesn't appear.) With :category_id = 2 and :max_price = 15.00 the same query returns 3 rows —the body oil (€14.25), the solid shampoo (€8.40) and the lip balm (€4.60)— and with :in_stock_only = true the wax candles, the only ones with stock 0, would disappear. Its advantages are real: a single query to maintain, zero injection risk (11-03) and a cacheable plan. And so is its cost: (:p IS NULL OR col = :p) is non-sargable (08-04) —the engine doesn't know in advance whether the column will be filtered— so the plan has to serve all sixteen possible cases and isn't optimal for any of them. With 19 products it doesn't matter; with 19 million and a very selective filter, it's a Seq Scan where there was an Index Scan.
Mitigation in PostgreSQL: a prepared statement switches to a generic plan from the sixth execution on. With
SET plan_cache_mode = force_custom_planthe engine replans with the actual values and discards the neutralized branches. It's the way out when the pattern works fine except for one specific filter.
The alternative: building the SQL in the application
The other approach is to compose the WHERE in code, adding only the conditions the user has filled in:
sql, params = ["SELECT p.id, p.name, p.price FROM products AS p WHERE p.active"], {}
if category_id is not None:
sql.append("AND p.category_id = %(category_id)s"); params["category_id"] = category_id
if max_price is not None:
sql.append("AND p.price <= %(max_price)s"); params["max_price"] = max_price
cur.execute(" ".join(sql), params) # ✅ the VALUES are still parametersThe red line is on that last line: you build the text of the conditions, never the values, which still travel as parameters. Concatenating f"AND p.price <= {max_price}" is exactly 11-03's vulnerability, and it doesn't stop being one just because the data "looks like" a number.
The :param IS NULL OR … pattern |
SQL built in the application | |
|---|---|---|
| Queries to maintain | One | One template plus the composition logic |
| Plan quality | Generic: mediocre for all | Optimal for each combination |
| Injection risk | None | Low if you only compose conditions; high if you compose values |
| Readability / debugging | High / always logs the same SQL | Lower / you have to log the final SQL |
| When to pick it | Few filters, medium tables, teams that want fixed SQL | Many filters, large tables, performance critical |
The course's criterion: start with the single-query pattern; move to composition only when you measure (08-05) that the generic plan is costing you. And for pagination, 08-04's rule: LIMIT/OFFSET for small numbered paginators, keyset for infinite scroll and APIs.
- Search: prefix, substring and full-text
The request. "Make the shop's search box find the product even if the customer types half a word." There are three levels, in order of increasing cost, and the decision is choosing the cheapest one that solves the problem:
-- Level 1: by PREFIX. Uses a normal B-tree if the collation is right.
SELECT id, name FROM products WHERE name ILIKE 'almond%' ORDER BY id;
-- Level 2: by SUBSTRING. Doesn't use a B-tree: it needs pg_trgm + GIN (08-03).
SELECT id, name FROM products WHERE name ILIKE '%oil%' ORDER BY id;| Query | Rows | Result |
|---|---|---|
ILIKE 'almond%' |
1 | Almond body oil 200 ml |
ILIKE '%oil%' |
2 | Extra virgin olive oil 500 ml · Almond body oil 200 ml |
ILIKE 'oil%' |
0 | — |
There's the whole problem in a nutshell: searching for "oil" by prefix finds neither the olive oil nor the body oil, because the word sits in the middle of the name. Searching by substring does find them, but a LIKE '%…%' can't use a B-tree index (08-04): the only way to speed it up is a GIN index with pg_trgm, from 08-03. Level 3 appears when the user types phrases, expects "infusions" to find "infusion", or wants results sorted by relevance: that's no longer LIKE, it's full-text search with to_tsvector/to_tsquery and a GIN index over the vector.
| Need | Tool | Index |
|---|---|---|
| Autocomplete, codes, prefixes | LIKE 'x%' |
B-tree (with text_pattern_ops if the collation isn't C) |
| Substring in a small or medium catalogue | ILIKE '%x%' |
GIN with pg_trgm |
| Tolerance for typos ("olve oil") | pg_trgm's similarity() |
GIN with pg_trgm |
| Phrases, word stems, relevance | to_tsvector @@ to_tsquery |
GIN over the tsvector |
| Huge catalogues, synonyms, facets, spelling correction | An external engine (Elasticsearch, OpenSearch, Meilisearch) | — |
The criterion: don't set up full-text for 19 products, and don't solve a million-item search box with ILIKE '%…%'. And when you do use ILIKE, always escape % and _ in the user's input: if somebody searches for 100%, that % is a wildcard.
- A KPI report in a single row
The request. "Management wants a strip of numbers at the top of the dashboard: this month's sales, this month's orders, orders awaiting shipment, total revenue and average order value." The temptation is to fire off five queries; the solution is one, with 04-04's FILTER clause, which applies a different condition to each aggregate:
SELECT COUNT(DISTINCT o.id) AS total_orders,
COUNT(DISTINCT o.id) FILTER (WHERE o.order_date >= DATE '2026-02-01') AS orders_month,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total_revenue,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
FILTER (WHERE o.order_date >= DATE '2026-02-01'), 2) AS revenue_month,
COUNT(DISTINCT o.id) FILTER (WHERE o.status IN ('pending','paid')) AS to_ship,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
/ COUNT(DISTINCT o.id), 2) AS avg_order_value
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id;| total_orders | orders_month | total_revenue | revenue_month | to_ship | avg_order_value |
|---|---|---|---|---|---|
| 20 | 2 | 727.95 | 49.33 | 3 | 36.40 |
One query, one row, six indicators, and they're all canonical figures from the course: the 20 orders, the €727.95 of product revenue, the €49.33 of February 2026 and the average order value of €36.40. The 3 "to ship" are orders 18 and 19 (paid) and 20 (pending).
The COUNT(DISTINCT o.id) is compulsory: the JOIN with order_lines multiplies each order by its lines, and a COUNT(*) would return 47. And FILTER is the SQL standard for this; the portable equivalent is 06-05's SUM(CASE WHEN … THEN … END), more verbose and with the trap that COUNT(CASE …) also counts NULLs if you don't write it carefully.
- Top N and ranking
The request. "The five highest-billing products, with their position." It's 10-03's pattern in its simplest form, ROW_NUMBER computed in a CTE and filtered outside:
WITH sales AS (
SELECT p.id AS product_id, p.name AS product,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM order_lines AS ol
JOIN products AS p ON p.id = ol.product_id
GROUP BY p.id, p.name)
SELECT rank_, product, revenue FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY revenue DESC, product_id) AS rank_
FROM sales) AS r
WHERE rank_ <= 5 ORDER BY rank_;| rank_ | product | revenue |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 109.53 |
| 2 | Ceremonial matcha green tea 30 g | 88.00 |
| 3 | Aloe vera face cream 50 ml | 70.42 |
| 4 | Ginger kombucha 750 ml | 56.43 |
| 5 | Organic brown rice 1 kg | 54.60 |
Remember 10-03's three decisions, because in production they matter: ROW_NUMBER to cut off at exactly N rows, RANK if everyone tied in the last position should come out, and an explicit tie-breaker in the window's ORDER BY —here product_id— so the result is reproducible. If you only need the top 5 and not the position column, an ORDER BY … LIMIT 5 over an index is cheaper (08-04's rule 4).
- Finding gaps: the anti-join
The request. "Which customers have never bought? Which products don't sell? How many orders come in without a sales rep?" Every business question starting with "which ones don't" is the same pattern: an anti-join, which in PostgreSQL you write with NOT EXISTS (07-03). GreenStore's three deliberate gaps, in one query:
SELECT (SELECT COUNT(*) FROM customers AS c
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id)) AS customers_no_orders,
(SELECT COUNT(*) FROM products AS p
WHERE NOT EXISTS (SELECT 1 FROM order_lines AS ol WHERE ol.product_id = p.id)) AS never_sold,
(SELECT COUNT(*) FROM products AS p
WHERE NOT EXISTS (SELECT 1 FROM reviews AS r WHERE r.product_id = p.id)) AS no_reviews,
(SELECT COUNT(*) FROM employees AS e
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.employee_id = e.id)) AS employees_no_orders,
(SELECT COUNT(*) FROM orders WHERE employee_id IS NULL) AS web_orders;| customers_no_orders | never_sold | no_reviews | employees_no_orders | web_orders |
|---|---|---|---|---|
| 3 | 3 | 11 | 5 | 10 |
The names behind the numbers: Núria, Hugo and Inés have never bought; the wax candles (stock 0), the deodorant and the spirulina capsules (discontinued) have never been sold; and the five employees with no orders are the whole of management, logistics, warehouse and analysis. One variant that gets asked for constantly: the inactive customer, who isn't the one who never bought but the one who stopped buying — an anti-join with a time window:
SELECT c.id, c.name || ' ' || c.last_name AS customer, MAX(o.order_date) AS last_order
FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name, c.last_name
HAVING MAX(o.order_date) < DATE '2026-03-01' - INTERVAL '6 months'
ORDER BY last_order;| id | customer | last_order |
|---|---|---|
| 3 | Marta Sanchis Gil | 2025-04-02 |
| 8 | Tiago Almeida Nunes | 2025-07-15 |
Two customers have gone more than six months without buying as of 1 March 2026. Notice the business difference: Núria, Hugo and Inés (who never bought) are an activation problem; Marta and Tiago are a retention problem. Same table, two different campaigns.
Careful with
NOT IN.WHERE id NOT IN (SELECT employee_id FROM orders)returns zero rows here, becauseemployee_idhas nulls andNOT INwith aNULLin the list is never true (04-03, 07-03). It's the classic mistake in this family of queries: useNOT EXISTS.
- Time series with no gaps
The request. "The monthly sales chart skips the months with no orders and the axis comes out crooked." A GROUP BY only produces rows for the data that exists. If a month had no orders, that month doesn't appear — and a chart that joins the previous point straight to the next one lies. The solution is to generate the calendar and join the data against it with generate_series (06-03) and a LEFT JOIN:
WITH calendar AS (
SELECT generate_series(DATE '2025-01-01', DATE '2026-02-01', INTERVAL '1 month')::date AS month),
sales AS (
SELECT date_trunc('month', o.order_date)::date AS month,
COUNT(DISTINCT o.id) AS orders,
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(cal.month, 'YYYY-MM') AS month,
COALESCE(s.orders, 0) AS orders,
COALESCE(s.revenue, 0.00) AS revenue
FROM calendar AS cal
LEFT JOIN sales AS s ON s.month = cal.month
ORDER BY cal.month;| month | orders | revenue |
|---|---|---|
| 2025-01 | 0 | 0.00 |
| 2025-02 | 0 | 0.00 |
| 2025-03 | 2 | 68.80 |
| 2025-04 | 2 | 61.28 |
| 2026-01 | 2 | 75.10 |
| 2026-02 | 2 | 49.33 |
(6 of 14 rows; between April 2025 and January 2026 the series is the course's canonical one: 58.85 · 95.48 · 44.60 · 48.27 · 32.76 · 97.20 · 31.70 · 64.58.) January and February 2025 show up with €0.00 even though not a single order exists: the shop already had a catalogue and customers, but its first order is dated 4 March. Without the calendar, the series would start in March and nobody would see the two months with no sales.
The two compulsory pieces are the LEFT JOIN in that direction (calendar on the left, data on the right) and the COALESCE, because the LEFT JOIN produces NULL, not zero — and a NULL in a chart is a hole, not a low value.
- Customer cohorts
The request. "Do the customers we acquire in spring buy more than the autumn ones?" A cohort is a group of customers sharing the moment they came in; the analysis consists of following each group over time. The minimal form —how many from each signup month went on to buy— is an aggregated LEFT JOIN:
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 o.customer_id)
SELECT to_char(c.signup_date, 'YYYY-MM') AS cohort,
COUNT(*) AS customers,
COUNT(s.customer_id) AS buyers,
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 | orders | revenue |
|---|---|---|---|---|
| 2025-01 | 2 | 2 | 5 | 167.06 |
| 2025-02 | 3 | 3 | 5 | 147.31 |
| 2025-03 | 2 | 2 | 4 | 169.21 |
| 2025-04 | 2 | 2 | 3 | 115.47 |
| 2025-05 | 2 | 2 | 2 | 97.20 |
| 2025-06 | 2 | 1 | 1 | 31.70 |
| 2025-09 | 1 | 0 | 0 | 0.00 |
| 2026-01 | 1 | 0 | 0 | 0.00 |
Eight cohorts, 15 customers, 20 orders and €727.95: the columns tally with the course's total, which is the first check you should always run. And the reading is the expected one: the older cohorts have more orders per customer (the January-to-March 2025 ones run between 2 and 2.5 orders per person) and the recent ones haven't bought yet — simply because they've had less time. That bias is why cohorts are always compared at the same age: "orders within 90 days of signing up", not "total orders". 11-04 picks this up again with the honest warning about sample size.
- Detecting and cleaning up duplicates
The request. "Marketing says there are duplicate customers." In GreenStore there aren't any, and the reason is in 01-06's CREATE TABLE: customers.email is UNIQUE. That's the first lesson of this use case — a duplicate is prevented with a constraint, not cleaned up with a query:
SELECT lower(trim(c.email)) AS email, COUNT(*) AS times, string_agg(c.id::text, ', ') AS ids
FROM customers AS c GROUP BY 1 HAVING COUNT(*) > 1;Duplicates show up when there's no constraint: in an import table, in an unvalidated form, or when the natural key is "the same customer" and not "the same email" —Lucía signed up twice with two different addresses. For that, two techniques: the normalized key, GROUP BY lower(unaccent(trim(name || ' ' || last_name))) with its HAVING COUNT(*) > 1, and similarity instead of equality, with pg_trgm's similarity() (08-03) over a self-join a.id < b.id and a starting threshold of 0.6. Both return 0 rows over the course's 15 customers, which is what should happen in a healthy table.
For the cleanup, the canonical pattern is ROW_NUMBER (10-03): number the rows within each duplicate group by some "which one is the good one" criterion —the oldest, the one with orders— and delete the rest. Run the SELECT first, review the rows and only then turn it into a DELETE (05-04):
WITH d AS (SELECT id, ROW_NUMBER() OVER (PARTITION BY lower(trim(email)) ORDER BY id) AS n FROM customers)
SELECT * FROM d WHERE n > 1;Before deleting, you have to repoint the references: if the duplicate customer has orders, you have to move them to the survivor with an UPDATE, or the ON DELETE RESTRICT FK will stop you — and thank goodness.
- Auditing: who changed what and when
The request. "The oil's price changed on Tuesday and nobody knows who touched it." A database, by default, doesn't remember: an UPDATE replaces the previous value and leaves no trace. Recording changes is an explicit design decision, and there are three ways:
| Approach | How | Advantage | Cost |
|---|---|---|---|
| Audit table with a trigger | AFTER INSERT/UPDATE/DELETE writing to *_audit (10-05) |
Nobody can bypass it: it also captures changes made by hand in psql |
An extra write on every change |
| Trail columns | created_at, created_by, updated_at, updated_by |
Dirt cheap | Only stores the last change |
| Temporal versioning | One row per version with valid_from/valid_to |
Complete history, queryable "as of" a date | Complicates every query |
10-05's is the first one, with price_audit capturing the old value, the new one, current_user and now(). Two warnings you only learn in production. current_user is the database user, not the application's: if the website connects with a single gs_app role (11-03), every row will say gs_app; to know which person it was you have to propagate the user with SET LOCAL app.username = '...' when opening the transaction and read it with current_setting('app.username', true). And an audit table grows without end: plan its purge or its archiving from day one.
- Exporting to another system
The request. "Send me the year's sales in a CSV for the accountant."
-- \copy in psql: reads and writes on YOUR machine, with no special permissions (05-02)
\copy (SELECT o.id AS order_id, o.order_date, c.name || ' ' || c.last_name AS customer, c.country, o.status, ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS amount FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_lines ol ON ol.order_id = o.id WHERE o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01' GROUP BY o.id, o.order_date, c.name, c.last_name, c.country, o.status ORDER BY o.id) TO 'sales_2025.csv' WITH (FORMAT csv, HEADER true, DELIMITER ';', ENCODING 'UTF8')The 16 orders from 2025. And now the traps, all of them from the real world and none of them about SQL:
- Decimal and field separators. PostgreSQL writes
109.53with a point; a Spanish-locale Excel expects a comma and will read it as a hundred and nine thousand five hundred and fifty-three. You fix it at the destination, or at source withreplace(amount::text, '.', ',')— and then theDELIMITERcan't be a comma. Hence the';', which is what Excel expects in continental Europe. - Encoding.
UTF8is the right choice, but Excel for Windows may need the BOM or evenWIN1252so it doesn't mangle the accents in "Castellón". Test it with a row that has accents, never with clean data. - Date format and nulls.
2025-03-04is ISO 8601 and it's the right choice between systems; if the destination demands04/03/2025, convert it withto_char(date, 'DD/MM/YYYY')and don't rely onDateStyle. ANULLcomes out as an empty field: if the destination doesn't distinguish empty from null, useNULL 'NULL_'orCOALESCE. - Personal data. A CSV with names and email addresses is no longer protected by the database's permissions. Export the bare minimum and read 11-03 before sending anything with customer data in it.
COPY ... TO (without the backslash) runs on the server and requires the pg_write_server_files role; \copy is psql's, the one you'll use almost every time. And for consumption by another program, returning JSON from the database (10-06) is usually better than a CSV.
- Loading from a file with a staging table
The request. "They've sent us a CSV with 600 customers from a trade fair. Load it." Never load straight into the good table: load into a staging table with no constraints, validate there and move only what passes.
flowchart LR
A["file.csv"] -->|"\copy"| B["stg_customers<br/>no constraints"] --> C{"validate: format · duplicates<br/>catalogues · already existing"}
C -->|"valid"| D["INSERT ... SELECT<br/>into customers"]
C -->|"rejected"| E["error report"]
CREATE TEMP TABLE stg_customers (id INTEGER, name TEXT, last_name TEXT, email TEXT, country TEXT);
-- \copy stg_customers FROM 'trade_fair.csv' WITH (FORMAT csv, HEADER true, DELIMITER ';')
SELECT s.id, s.email, s.country,
CASE WHEN s.email NOT LIKE '%_@_%.%' THEN 'invalid email format'
WHEN s.country NOT IN ('Spain','Portugal','France') THEN 'country not in catalogue'
WHEN EXISTS (SELECT 1 FROM customers AS c
WHERE lower(c.email) = lower(s.email)) THEN 'already exists in customers'
WHEN COUNT(*) OVER (PARTITION BY lower(trim(s.email))) > 1 THEN 'duplicate within the file'
END AS rejection_reason
FROM stg_customers AS s ORDER BY s.id;Over a six-row test file —two of them the same Lucía written two different ways, a customer who already exists, an email with no @, a country not in the catalogue and one good signup—:
| id | country | rejection_reason | |
|---|---|---|---|
| 1 | [email protected] | Spain | already exists in customers |
| 2 | [email protected] | Spain | already exists in customers |
| 3 | [email protected] | Spain | already exists in customers |
| 4 | [email protected] | France | (null) |
| 5 | bruno.silva-example.pt | Portugal | invalid email format |
| 6 | [email protected] | Italy | country not in catalogue |
Out of six rows, one gets loaded. That report is the deliverable: whoever sent you the file needs to know what didn't get in and why, and a load that fails with ERROR: duplicate key value on row 400 tells nobody anything. The final insertion is an INSERT ... SELECT with WHERE rejection_reason IS NULL, or an INSERT ... ON CONFLICT DO NOTHING from 05-05 if you'd rather have the UNIQUE act as a last safety net — all inside a transaction (09-03) so the load is all or nothing.
- Summary table: use case → tool → lesson
| Use case | Tool from the course | Lesson |
|---|---|---|
| Listing with optional filters and pagination | (:p IS NULL OR col = :p); keyset instead of OFFSET |
02-03, 02-06, 08-04 |
| Substring search | ILIKE + GIN with pg_trgm; full-text if there are phrases |
04-01, 08-03 |
| Single-row dashboard | COUNT/SUM with FILTER, COUNT(DISTINCT …) |
04-04, 06-05 |
| Top N and ranking | ROW_NUMBER/RANK in a CTE, filtered outside |
10-02, 10-03 |
| Inactive customers, unsold products | NOT EXISTS (anti-join), LEFT JOIN … IS NULL |
03-03, 07-03 |
| Gap-free time series / cohorts | generate_series + LEFT JOIN + COALESCE; date_trunc |
04-05, 06-03, 10-02 |
| Duplicates: detection and cleanup | GROUP BY … HAVING COUNT(*) > 1, ROW_NUMBER, UNIQUE |
04-06, 05-01, 10-03 |
| Change auditing / CSV export | AFTER trigger + *_audit table / \copy … TO |
05-02, 06-03, 10-05 |
| Loading from a file | Staging table + validation + INSERT … SELECT |
05-02, 05-05, 09-03 |
| Expensive repeated report / nested API response | Materialized view / jsonb_agg |
10-01, 10-06 |
Common Mistakes and Tips
- Using
NOT INwith a subquery that can returnNULL. It returns zero rows and looks like "there are no gaps": useNOT EXISTS. And forgettingCOUNT(DISTINCT …)in a report thatJOINs to the lines: theJOINmultiplies, 20 orders become 47 rows and the average order value ends up divided by 2.35. - Plotting a time series with no calendar. Months with no data aren't skipped: they're worth zero, and a chart that leaves them out draws a trend that doesn't exist. And comparing cohorts of different ages: January's has been buying for a year and December's for a month.
- Loading a CSV straight into the production table. One bad row aborts the whole load or, worse, gets halfway in. Staging, validation and a rejection report. And concatenating user values when building a dynamic
WHERE: compose conditions if you must, the values are always parameters (11-03). - Tip: check every report's totals against a figure you already know. The eight cohorts in this text add up to €727.95 because they have to; if they don't tally, the bug is in the
JOIN, not in the data. And keep every report query in the repository with a comment saying which question it answers. That's the seed of 11-04's semantic layer. And when a request sounds new, look it up in section 12's table: it's nearly always one of these twelve under another name.
Exercises
Exercise 1
The order administration screen needs a listing with three optional filters —status, customer's country and date range— sorted by date descending and paginated 10 at a time. (1) Write it with the single-query pattern. (2) How many rows does it return with no filters and how many with status = 'delivered'? (3) Rewrite the pagination in keyset mode and explain which column you need for it to be stable.
Exercise 2
Management wants a catalogue alert: the active products that have never been sold or that have no reviews, with a column saying which of the two problems they have (or both). (1) Write it. (2) How many rows come out? (3) Why does the result change if you use an INNER JOIN instead of NOT EXISTS?
Exercise 3
You're given a supplier signup file with these columns: name, country, email. (1) Design the staging table and say why it must have no constraints. (2) Write the four validations you'd apply before inserting into suppliers. (3) What would you do with a row whose name already exists but with a different email?
Solutions
Solution 1
-- 1
SELECT o.id, o.order_date, c.name || ' ' || c.last_name AS customer, c.country, o.status
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE (:status IS NULL OR o.status = :status)
AND (:country IS NULL OR c.country = :country)
AND (:from IS NULL OR o.order_date >= :from)
AND (:to IS NULL OR o.order_date < :to)
ORDER BY o.order_date DESC, o.id DESC
LIMIT 10;2. With no filters, 10 rows (the first page of the 20 orders); with status = 'delivered', also 10, because there are 14 delivered. The total without the LIMIT would be 20 and 14. Notice < :to and not <=: 08-04's range pattern. 3. Keyset: WHERE (o.order_date, o.id) < (:last_date, :last_id) ORDER BY o.order_date DESC, o.id DESC LIMIT 10. You need o.id as a tie-breaker, because order_date isn't unique: without it, two orders from the same day could repeat or get lost between pages. That's why a keyset's ORDER BY must always end in a unique column.
Solution 2
SELECT p.id, p.name,
CASE WHEN never_sold AND no_reviews THEN 'no sales and no reviews'
WHEN never_sold THEN 'never sold'
ELSE 'no reviews' END AS problem
FROM (SELECT p.*,
NOT EXISTS (SELECT 1 FROM order_lines AS ol WHERE ol.product_id = p.id) AS never_sold,
NOT EXISTS (SELECT 1 FROM reviews AS r WHERE r.product_id = p.id) AS no_reviews
FROM products AS p WHERE p.active) AS p
WHERE never_sold OR no_reviews
ORDER BY p.id;2. Ten rows. The active products with no review number 10 (the 11 with no review minus the spirulina capsules, which are discontinued and fall out via WHERE p.active), and the two active never-sold ones —the wax candles (id 13) and the deodorant (id 19)— are among those ten, because they have no review either. That's why the answer isn't 12: it's the union of the two sets, not their sum. 3. An INNER JOIN with order_lines or with reviews answers the opposite question: it returns the products that do have sales or reviews. And a LEFT JOIN … WHERE ol.id IS NULL would work, but it produces intermediate rows you then have to discard and forces a DISTINCT; NOT EXISTS expresses the question directly and the engine resolves it as an anti-join (07-03, 07-05).
Solution 3
1. CREATE TEMP TABLE stg_suppliers (name TEXT, country TEXT, email TEXT); — all text and no constraints, no NOT NULL, no UNIQUE, no CHECK. The reason: if the staging table rejects rows, the load fails and you lose exactly the information you need (which rows came in wrong and why). Staging accepts the junk so it can be inventoried; the good table is the one that rejects it. 2. (a) Required fields: name and country not null and not empty after a trim. (b) Format: email LIKE '%_@_%.%' or NULL —in suppliers the email is optional. (c) Catalogue: country IN ('Spain','Portugal','France','Germany'), or better against a countries table. (d) Duplicates: within the file with COUNT(*) OVER (PARTITION BY lower(trim(name))), and against the real table with EXISTS. 3. It's a business decision, not a technical one: it could be the same supplier who changed address (→ 05-05's upsert) or two different companies with similar names (→ INSERT). What the program must not do is choose silently: flag the row as "manual review" and let a person decide. And if the name really must be unique, that rule goes in a UNIQUE on suppliers, not in the loading script.
Conclusion
This was the catalogue of requests, and now you have it in full:
- The listing with optional filters is solved with
(:param IS NULL OR column = :param)at the cost of a generic plan, or by building the SQL in the application composing conditions, never values. Search has three levels: prefix with a B-tree, substring withpg_trgmand GIN, and full-text when there are phrases and relevance — searching for "oil" by prefix finds neither the olive oil nor the body oil. - The dashboard fits in one row and one query with
FILTER: 20 orders, €727.95, €36.40 average order value and 3 orders to ship; top N isROW_NUMBERin a CTE filtered outside. Gaps are always aNOT EXISTS—3 customers with no purchase, 3 products never sold, 11 with no review, 10 web orders— and never aNOT INif nulls are possible. Time series are generated withgenerate_seriesand joined withLEFT JOIN+COALESCE, so January 2025 shows up with €0.00 instead of vanishing. Cohorts group by signup month and are only compared at the same age. - Duplicates are prevented with
UNIQUEand detected withGROUP BY … HAVING; auditing lives in a trigger, with the warning thatcurrent_userisn't your application's user. Exporting fails on the decimal separator, the encoding and the date format, not on the SQL; and loading always goes through a staging table that accepts the junk so it can be inventoried: of six example rows, only one was loadable.
All these queries work. The question that comes next is a different one: will somebody else be able to maintain them a year from now? In the next lesson, Best practices, we deal with the craft: naming tables, columns, keys and indexes, and why what matters is being consistent; the formatting that makes a forty-line query readable; the design decisions —normalizing, restrictive types, NOT NULL by default, named constraints— that separate a database you can work with from one you're scared to touch; the process, with migrations, code reviews and tested backups; and the catalogue of antipatterns, from SELECT * in production to "I'll just fix it directly in production".
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
