Every task below runs on these four linked tables โ a typical product-analytics schema. The data deliberately contains NULLs, cancelled orders and ended subscriptions, so the traps are real rather than hypothetical.
| user_id | integer | PK |
| signup_date | date | |
| country | text | NULL |
| plan | text | NULL |
| channel | text | NULL |
| event_id | bigint | PK |
| user_idโ users.user_id | integer | FK |
| event_at | timestamp | |
| event | text |
| order_id | integer | PK |
| user_idโ users.user_id | integer | FKNULL |
| created_at | timestamp | |
| amount | numeric | NULL |
| status | text | NULL |
| sub_id | integer | PK |
| user_idโ users.user_id | integer | FKNULL |
| started_on | date | |
| ended_on | date | NULL |
CREATE TABLE users ( user_id INT PRIMARY KEY, signup_date DATE NOT NULL, country TEXT, -- may be NULL: geo not resolved plan TEXT, -- free / pro, may be NULL channel TEXT ); CREATE TABLE events ( event_id BIGSERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES users(user_id), event_at TIMESTAMP NOT NULL, event TEXT NOT NULL -- open / view / add_to_cart / purchase ); CREATE TABLE orders ( order_id INT PRIMARY KEY, user_id INT REFERENCES users(user_id), created_at TIMESTAMP NOT NULL, amount NUMERIC(10,2), -- NULL when the order is not paid status TEXT -- paid / cancelled / pending ); CREATE TABLE subscriptions ( sub_id INT PRIMARY KEY, user_id INT REFERENCES users(user_id), started_on DATE NOT NULL, ended_on DATE -- NULL = still active );
SELECT * FROM users ORDER BY user_id LIMIT 5;
country and no plan. That is not a generation glitch โ it is the reality that breaks NOT IN, COUNT and AVG. Tasks 12 and 13 rely on exactly these rows.The single most useful thing to understand before anything else: SQL does not execute in the order it is written. Almost every "why does this not work" question traces back to this.
| Rule | Why |
|---|---|
WHERE cannot see aggregates | It runs before GROUP BY, so sums and counts do not exist yet. Use HAVING. |
WHERE cannot see SELECT aliases | SELECT runs later โ the alias has not been created yet. |
Window functions cannot go in WHERE | They run after SELECT. Wrap them in a subquery โ see task 9. |
ORDER BY can see aliases | It runs last, so by then every alias exists. This is the one place you may reuse them. |
A join answers one question: what to do with rows that have no match on the other side.
| Type | Returns | When you need it |
|---|---|---|
INNER JOIN | Only rows that matched on both sides | You want users who actually placed an order |
LEFT JOIN | Everything on the left, plus matches on the right (NULL otherwise) | All users, including those with no orders โ by far the most common in analytics |
RIGHT / FULL | Mirrored / everything from both sides | Rare; usually rewritten as a LEFT JOIN |
CROSS JOIN | Every combination of both sides | A calendar of dates ร segments so there are no gaps; dividing by a grand total |
ON is not the same as in WHERE. For a LEFT JOIN, filtering the right table in WHERE turns it into an INNER JOIN: rows whose right side is NULL fail the condition and disappear. To keep the left side, the condition belongs in ON. Task 11 shows this in action.SQL logic is three-valued: true, false and unknown. NULL means "unknown", any comparison with it yields "unknown" again โ and "unknown" does not pass a filter.
SELECT (NULL = NULL) AS eq, (NULL IS NULL) AS is_null, (5 > NULL) AS cmp, COALESCE(NULL, 'fallback') AS coalesced
NULL = NULL came back empty rather than true. That is why you compare with IS NULL, and substitute a fallback with COALESCE.COUNT(*) counts every row, COUNT(column) only non-null ones, and AVG / SUM quietly skip NULLs. The gap between the first two is exactly your number of missing values โ see task 12.Three operations cover almost everything: the difference between two dates, truncating to a period, and shifting by an interval. In PostgreSQL subtracting two date values gives an integer number of days, while subtracting two timestamps gives an interval.
SELECT DATE '2026-03-15' - DATE '2026-03-01' AS days_between, date_trunc('week', TIMESTAMP '2026-03-15 12:00')::date AS week_start, date_trunc('month', TIMESTAMP '2026-03-15 12:00')::date AS month_start, DATE '2026-03-15' - INTERVAL '7 days' AS week_ago, to_char(DATE '2026-03-15', 'Day') AS weekday
>= the day AND < the next day.a - b; in BigQuery DATE_DIFF(a, b, DAY); in MySQL DATEDIFF(a, b). Truncation is date_trunc in PostgreSQL and DATE_TRUNC with a different argument order in BigQuery.A window computes an aggregate without collapsing rows: the rows stay where they are and a calculated column appears beside them. That is the essential difference from GROUP BY.
People confuse these constantly, because with no ties they behave identically. The difference only appears on a tie โ so in the example Anna and Boris both have 500.
WITH sales(rep, region, amount) AS (VALUES ('Anna','RU',500),('Boris','RU',500),('Clara','RU',300), ('Dmitry','KZ',700),('Elena','KZ',400)) SELECT rep, region, amount, ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS row_num, RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS rnk, DENSE_RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS dense FROM sales ORDER BY region, amount DESC
ROW_NUMBER gave her 3 (just a sequence number), RANK also 3 (places 1 and 1 are taken, so 2 is skipped), and DENSE_RANK gave 2 (no gaps). Use ROW_NUMBER for "top-N per group" and RANK for an honest leaderboard position.WITH sales(rep, amount) AS (VALUES ('Anna',500),('Boris',500),('Clara',300),('Dmitry',700),('Elena',400)) SELECT rep, amount, SUM(amount) OVER (ORDER BY amount DESC ROWS UNBOUNDED PRECEDING) AS running_total, ROUND(100.0 * amount / SUM(amount) OVER (), 1) AS pct_of_total FROM sales ORDER BY amount DESC
OVER () with no arguments means "over the whole result set" โ that is how you compute a share of the grand total inside the same query, without a second pass.The tasks go from basic metrics to composite ones. All of them run on the databases above, and the output under each query is the real thing.
The most basic product metric. The key word is DISTINCT: a user counts once per day no matter how many events they fire.
SELECT event_at::date AS day, COUNT(DISTINCT user_id) AS dau FROM events WHERE event_at >= '2026-03-01' AND event_at < '2026-03-08' GROUP BY 1 ORDER BY 1;
DISTINCT you would count events, not people โ the number would be several times higher. This is the most common DAU mistake.FILTER (WHERE ...) computes several conditional metrics in a single pass, without subqueries. NULLIF guards against division by zero.
SELECT COUNT(DISTINCT user_id) AS visitors, COUNT(DISTINCT user_id) FILTER (WHERE event='add_to_cart') AS carted, COUNT(DISTINCT user_id) FILTER (WHERE event='purchase') AS purchased, ROUND(100.0 * COUNT(DISTINCT user_id) FILTER (WHERE event='purchase') / NULLIF(COUNT(DISTINCT user_id), 0), 2) AS cr_pct FROM events;
FILTER is PostgreSQL syntax. In MySQL and BigQuery the same result comes from COUNT(DISTINCT CASE WHEN ... THEN user_id END).Sticky factor = average DAU รท MAU. It shows what share of your monthly audience you see on a typical day. Around 20% or higher is considered good for most products.
WITH daily AS ( SELECT event_at::date AS day, COUNT(DISTINCT user_id) AS dau FROM events WHERE event_at >= '2026-03-01' AND event_at < '2026-04-01' GROUP BY 1 ), monthly AS ( SELECT COUNT(DISTINCT user_id) AS mau FROM events WHERE event_at >= '2026-03-01' AND event_at < '2026-04-01' ) SELECT ROUND(AVG(daily.dau), 1) AS avg_dau, monthly.mau, ROUND(100.0 * AVG(daily.dau) / monthly.mau, 1) AS sticky_pct FROM daily CROSS JOIN monthly GROUP BY monthly.mau;
CROSS JOIN: it attaches the single MAU row to every daily row. That is the standard trick when you need to divide by a grand total.A window function accumulates the sum without collapsing rows. Note the nested SUM(SUM(...)): the inner one aggregates per day, the outer accumulates.
SELECT created_at::date AS day, SUM(amount) AS daily, SUM(SUM(amount)) OVER (ORDER BY created_at::date) AS running_total FROM orders WHERE status = 'paid' AND created_at < '2026-03-08' GROUP BY 1 ORDER BY 1;
GROUP BY runs first, and the window function then operates on the already grouped result.A classic payback question: how much a cohort brings in its first week. Subtracting dates in PostgreSQL yields an integer number of days.
WITH spend AS ( SELECT date_trunc('month', u.signup_date)::date AS cohort_month, o.created_at::date - u.signup_date AS day_no, o.amount FROM users u JOIN orders o ON o.user_id = u.user_id AND o.status = 'paid' WHERE o.created_at::date >= u.signup_date ) SELECT cohort_month, SUM(amount) FILTER (WHERE day_no <= 7) AS revenue_day_7, SUM(amount) AS revenue_total FROM spend GROUP BY 1 ORDER BY 1;
o.created_at::date >= u.signup_date filter matters: without it, orders placed before signup (which happens after data migrations) leak into the cohort and skew the metric.Churn for a period = those who left รท those active at the start. The subtlety: ended_on IS NULL means "still active", not "data missing".
SELECT COUNT(*) FILTER ( WHERE started_on < '2026-03-01' AND (ended_on IS NULL OR ended_on >= '2026-03-01') ) AS active_at_start, COUNT(*) FILTER ( WHERE ended_on >= '2026-03-01' AND ended_on < '2026-04-01' ) AS churned, ROUND(100.0 * COUNT(*) FILTER (WHERE ended_on >= '2026-03-01' AND ended_on < '2026-04-01') / NULLIF(COUNT(*) FILTER ( WHERE started_on < '2026-03-01' AND (ended_on IS NULL OR ended_on >= '2026-03-01')), 0) , 1) AS churn_pct FROM subscriptions;
ended_on IS NULL branch and active subscriptions drop out of the denominator, inflating churn several times over.The share of users who returned exactly seven days after their first visit. EXISTS beats a join here: we only need existence, not every matching row.
WITH first_day AS ( SELECT user_id, MIN(event_at::date) AS d0 FROM events GROUP BY 1 ) SELECT COUNT(*) AS users, COUNT(*) FILTER (WHERE returned) AS retained_d7, ROUND(100.0 * COUNT(*) FILTER (WHERE returned) / COUNT(*), 1) AS retention_pct FROM ( SELECT f.user_id, EXISTS ( SELECT 1 FROM events e WHERE e.user_id = f.user_id AND e.event_at::date = f.d0 + 7 ) AS returned FROM first_day f ) t;
A session is a chain of events with no gap longer than 30 minutes. Three steps: LAG fetches the previous event, a gap raises a flag, and the running sum of flags becomes the session number.
WITH gaps AS ( SELECT user_id, event_at, event, LAG(event_at) OVER (PARTITION BY user_id ORDER BY event_at) AS prev_at FROM events WHERE user_id IN (1, 2) ), flags AS ( SELECT *, CASE WHEN prev_at IS NULL OR event_at - prev_at > INTERVAL '30 minutes' THEN 1 ELSE 0 END AS is_new FROM gaps ) SELECT user_id, event_at, event, SUM(is_new) OVER (PARTITION BY user_id ORDER BY event_at ROWS UNBOUNDED PRECEDING) AS session_no FROM flags ORDER BY user_id, event_at LIMIT 8;
prev_at IS NULL) โ otherwise numbering would start at zero. The same trick splits visits, consecutive orders and day streaks.The classic "top-N per group" task. Rank in a subquery and filter outside it โ a window function cannot be used directly in WHERE.
WITH revenue AS ( SELECT u.country, u.user_id, SUM(o.amount) AS revenue FROM users u JOIN orders o ON o.user_id = u.user_id AND o.status = 'paid' WHERE u.country IS NOT NULL GROUP BY 1, 2 ) SELECT country, user_id, revenue, rn FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY country ORDER BY revenue DESC) AS rn FROM revenue ) ranked WHERE rn <= 3 ORDER BY country, rn;
WHERE ROW_NUMBER() ... <= 3 fails: window functions run after WHERE. Hence the subquery โ or QUALIFY in dialects that support it.A join multiplies rows: a user with three orders occupies three rows. After that COUNT(*) no longer counts people.
SELECT COUNT(*) AS rows_after_join, COUNT(o.order_id) AS orders, COUNT(DISTINCT u.user_id) AS users FROM users u LEFT JOIN orders o ON o.user_id = u.user_id;
ARPU divides by all users, ARPPU only by paying ones. The gap between them shows what share of your audience pays at all.
SELECT ROUND(SUM(o.amount) / COUNT(DISTINCT u.user_id), 2) AS arpu, ROUND(SUM(o.amount) / COUNT(DISTINCT o.user_id), 2) AS arppu, COUNT(DISTINCT u.user_id) AS all_users, COUNT(DISTINCT o.user_id) AS paying_users FROM users u LEFT JOIN orders o ON o.user_id = u.user_id AND o.status = 'paid';
status = "paid" condition sits in ON, not WHERE. That matters: in WHERE it would turn the LEFT JOIN into an INNER one and drop non-payers from the ARPU denominator.COUNT(*) counts rows; COUNT(column) counts only non-null values. The difference between them is the number of gaps.
SELECT COUNT(*) AS total, COUNT(country) AS with_country, COUNT(*) - COUNT(country) AS null_country, COUNT(*) FILTER (WHERE plan IS NULL) AS null_plan FROM users;
AVG silently ignores NULL, so the average is taken over fewer rows than you assume.The nastiest trap in SQL. If the NOT IN list contains even one NULL, the query returns zero rows โ silently, with no error or warning.
SELECT (SELECT COUNT(*) FROM users WHERE country NOT IN (SELECT country FROM users WHERE country IS NULL OR country = 'KZ')) AS with_null_in_list, (SELECT COUNT(*) FROM users WHERE country NOT IN (SELECT country FROM users WHERE country = 'KZ')) AS without_null, (SELECT COUNT(*) FROM users u WHERE NOT EXISTS (SELECT 1 FROM users x WHERE x.country = 'KZ' AND x.country = u.country)) AS not_exists_ok;
country NOT IN (..., NULL) evaluates to "unknown" for every row, and "unknown" does not pass the filter. Use NOT EXISTS, which is immune and also keeps rows whose own value is NULL.An activation-speed metric. Subtracting dates in PostgreSQL gives an integer number of days; subtracting timestamps gives an interval.
SELECT u.user_id, u.signup_date, MIN(o.created_at)::date AS first_order, MIN(o.created_at)::date - u.signup_date AS days_to_first_order FROM users u JOIN orders o ON o.user_id = u.user_id AND o.status = 'paid' GROUP BY 1, 2 HAVING MIN(o.created_at)::date >= u.signup_date ORDER BY days_to_first_order LIMIT 5;
HAVING because it relies on the aggregate MIN(...) โ it cannot go in WHERE, where aggregates do not yet exist.date_trunc rounds a date down to the start of a week, month or quarter. It is the main tool for any periodic reporting.
SELECT date_trunc('week', event_at)::date AS week_start, COUNT(DISTINCT user_id) AS wau FROM events GROUP BY 1 ORDER BY 1;
| Mistake | What it costs | Do this instead |
|---|---|---|
COUNT(*) after a join | A join multiplies rows, so you count orders instead of people | COUNT(DISTINCT user_id) |
NOT IN with a NULL in the list | Returns zero rows silently โ no error, no warning | NOT EXISTS, or filter NULLs out of the subquery |
Filtering the right table in WHERE after a LEFT JOIN | Turns the LEFT JOIN into an INNER one and drops the rows you meant to keep | Put the condition in ON |
| Comparing a timestamp to a date | Matches only events at exactly midnight; the rest of the day is lost | A half-open range covering the whole day |
Aggregates in WHERE | Syntax error โ at that point the aggregate does not exist yet | Move the condition to HAVING |
A window function in WHERE | Same reason: windows are evaluated after WHERE | Rank in a subquery and filter outside it |
Forgetting DISTINCT in DAU / MAU | You count events, not users โ the metric inflates several times over | COUNT(DISTINCT user_id) |
Dividing without NULLIF | A zero denominator kills the whole query with a division error | NULLIF on the denominator returns NULL instead of failing |