Practice databases

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.

users60 rows
user_idintegerPK
signup_datedate
countrytextNULL
plantextNULL
channeltextNULL
events1606 rows
event_idbigintPK
user_idโ†’ users.user_idintegerFK
event_attimestamp
eventtext
orders120 rows
order_idintegerPK
user_idโ†’ users.user_idintegerFKNULL
created_attimestamp
amountnumericNULL
statustextNULL
subscriptions16 rows
sub_idintegerPK
user_idโ†’ users.user_idintegerFKNULL
started_ondate
ended_ondateNULL
PK primary keyFK foreign keyNULL may be NULL
The DDL that creates it
What the data looks like
Note the NULLs: a user may have no 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.
How a query actually runs

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.

FROM / JOINโ†’WHEREโ†’GROUP BYโ†’HAVINGโ†’SELECTโ†’ORDER BYโ†’LIMIT
RuleWhy
WHERE cannot see aggregatesIt runs before GROUP BY, so sums and counts do not exist yet. Use HAVING.
WHERE cannot see SELECT aliasesSELECT runs later โ€” the alias has not been created yet.
Window functions cannot go in WHEREThey run after SELECT. Wrap them in a subquery โ€” see task 9.
ORDER BY can see aliasesIt runs last, so by then every alias exists. This is the one place you may reuse them.
Joins: which one when

A join answers one question: what to do with rows that have no match on the other side.

TypeReturnsWhen you need it
INNER JOINOnly rows that matched on both sidesYou want users who actually placed an order
LEFT JOINEverything 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 / FULLMirrored / everything from both sidesRare; usually rewritten as a LEFT JOIN
CROSS JOINEvery combination of both sidesA calendar of dates ร— segments so there are no gaps; dividing by a grand total
A condition in 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.
NULL: three states, not two

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.

Reading the result: NULL = NULL came back empty rather than true. That is why you compare with IS NULL, and substitute a fallback with COALESCE.
How NULL affects aggregates: 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.
Dates and time

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.

The classic trap: comparing a timestamp column directly to a date finds nothing unless the event happened at exactly midnight. Use a half-open range instead โ€” >= the day AND < the next day.
Across dialects: the ideas are identical, the syntax differs. Date difference in PostgreSQL is 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.
Window functions

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.

ROW_NUMBER, RANK and DENSE_RANK

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.

Clara's row explains it all: 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.
A useful trick: 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.
15 practice tasks

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.

1 DAU โ€” daily active users Easy

The most basic product metric. The key word is DISTINCT: a user counts once per day no matter how many events they fire.

Without DISTINCT you would count events, not people โ€” the number would be several times higher. This is the most common DAU mistake.
2 Funnel conversion rate Easy

FILTER (WHERE ...) computes several conditional metrics in a single pass, without subqueries. NULLIF guards against division by zero.

FILTER is PostgreSQL syntax. In MySQL and BigQuery the same result comes from COUNT(DISTINCT CASE WHEN ... THEN user_id END).
3 MAU, DAU and sticky factor Medium

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.

Note the 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.
4 Running total of revenue Easy

A window function accumulates the sum without collapsing rows. Note the nested SUM(SUM(...)): the inner one aggregates per day, the outer accumulates.

The nested SUM looks odd but follows the execution order: GROUP BY runs first, and the window function then operates on the already grouped result.
5 Cohort revenue in the first 7 days Medium

A classic payback question: how much a cohort brings in its first week. Subtracting dates in PostgreSQL yields an integer number of days.

The 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.
6 Subscription churn rate Medium

Churn for a period = those who left รท those active at the start. The subtlety: ended_on IS NULL means "still active", not "data missing".

Forget the ended_on IS NULL branch and active subscriptions drop out of the denominator, inflating churn several times over.
7 Retention: did they come back on day 7 Medium

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.

This is "classic" retention โ€” a return on a specific day. There is also "rolling" retention: returning at least once within a window. Always clarify which one is meant; the numbers differ substantially.
8 Session index per user Medium

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.

The first event of each user also raises the flag (prev_at IS NULL) โ€” otherwise numbering would start at zero. The same trick splits visits, consecutive orders and day streaks.
9 Top 3 customers per country Medium

The classic "top-N per group" task. Rank in a subquery and filter outside it โ€” a window function cannot be used directly in WHERE.

Why WHERE ROW_NUMBER() ... <= 3 fails: window functions run after WHERE. Hence the subquery โ€” or QUALIFY in dialects that support it.
10 Trap: COUNT after a JOIN Easy

A join multiplies rows: a user with three orders occupies three rows. After that COUNT(*) no longer counts people.

Three different numbers from one query. Always decide deliberately what you are counting: rows, non-null values, or distinct entities.
11 ARPU and ARPPU Easy

ARPU divides by all users, ARPPU only by paying ones. The gap between them shows what share of your audience pays at all.

The 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.
12 NULL: how much we do not know Easy

COUNT(*) counts rows; COUNT(column) counts only non-null values. The difference between them is the number of gaps.

Check for gaps before computing metrics: AVG silently ignores NULL, so the average is taken over fewer rows than you assume.
13 Trap: NOT IN with NULL Medium

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.

Three different answers to one question. The reason: 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.
14 Time to first order Medium

An activation-speed metric. Subtracting dates in PostgreSQL gives an integer number of days; subtracting timestamps gives an interval.

The filter sits in HAVING because it relies on the aggregate MIN(...) โ€” it cannot go in WHERE, where aggregates do not yet exist.
15 WAU by week Easy

date_trunc rounds a date down to the start of a week, month or quarter. It is the main tool for any periodic reporting.

In PostgreSQL a week starts on Monday. Note the partial first and last weeks โ€” they are usually dropped when comparing trends.
Common mistakes
MistakeWhat it costsDo this instead
COUNT(*) after a joinA join multiplies rows, so you count orders instead of peopleCOUNT(DISTINCT user_id)
NOT IN with a NULL in the listReturns zero rows silently โ€” no error, no warningNOT EXISTS, or filter NULLs out of the subquery
Filtering the right table in WHERE after a LEFT JOINTurns the LEFT JOIN into an INNER one and drops the rows you meant to keepPut the condition in ON
Comparing a timestamp to a dateMatches only events at exactly midnight; the rest of the day is lostA half-open range covering the whole day
Aggregates in WHERESyntax error โ€” at that point the aggregate does not exist yetMove the condition to HAVING
A window function in WHERESame reason: windows are evaluated after WHERERank in a subquery and filter outside it
Forgetting DISTINCT in DAU / MAUYou count events, not users โ€” the metric inflates several times overCOUNT(DISTINCT user_id)
Dividing without NULLIFA zero denominator kills the whole query with a division errorNULLIF on the denominator returns NULL instead of failing
โ† Python for Analysts A/B Testing Calculator Home