Section C · Technical & Communication

SQL & Technical Screen

The posting asks for SQL fluency and production-quality code. This chapter drills both on realistic, privacy-shaped tables — subscriptions, content-free usage metadata, and on-chain flows — not a generic employees-and-departments toy schema.

What the screen tests

For a first-data-hire role at a roughly 50-person company, the SQL screen is not there to see whether you can write a JOIN. It's there to answer one question the VP of Business Operations actually cares about: if I hand you a schema I built in a hurry and a fuzzy business question, do I get back a correct, fast, readable answer — or a mess I have to double-check? Concretely, a screen like this tests:

  • Timed SQL under mild pressure. Usually 30–45 minutes, 2–4 questions of increasing difficulty, live-shared or in a take-home. The clock rewards reaching for the right construct immediately rather than discovering it after two wrong attempts.
  • Window functions. This is the single highest-signal skill. MRR movement, retention cohorts, running totals, top-N-per-group, period-over-period deltas — all of them are window-function problems, and all of them are the company's daily bread. A candidate who self-joins their way through a "second-highest plan price" question is telling the interviewer they haven't done much analytics.
  • Correct and fast. Correct first. But a query that scans a billion API-request rows because you filtered inside a window instead of before it is a production incident waiting to happen. They want to see you think about the shape and size of the data.
  • Reading a schema you didn't write. They'll give you tables and expect you to notice the foreign keys, the nullable columns, the grain of each table (one row per what?), and the columns that aren't there — because at this company, the missing columns are the whole point.
  • One or two sharp clarifying questions. Not twenty. The senior signal is asking the one question that changes the query — "does churn here mean the cancel event or the end of the paid period?" — then stating your assumption and moving.
  • Evidence you write production-quality code. The posting explicitly pairs "SQL fluency" with "production-quality code." Expect either a code-review conversation, a small take-home that gets read as software (not just as an answer), or direct questions about how you'd turn a query into a tested, versioned, scheduled model. Section 6 is about exactly this.
The meta-signal

Every question in this screen is secretly a privacy question too. The tables you're handed will have usage metadata and never content. If you instinctively write GROUP BY user_id on something that should be aggregated to a cohort, or reach for a column that would de-anonymize a wallet, you've failed the part of the screen that matters most here — before you've even run the query. Keep Chapter 02's safe-vs-unsafe line in your head the entire time.

The schema you'll reason about

There is no published schema, so here is a small, realistic one this chapter uses throughout. It's the schema a first hire would actually build from Stripe + Postgres + the API gateway + PostHog + on-chain loaders (see Chapter 03). Memorize its shape — the grain of each table and, crucially, what each table deliberately does not store.

schema.sql
-- One row per registered account. No prompts, no content, ever.
CREATE TABLE users (
    user_id       BIGINT PRIMARY KEY,
    signed_up_at  TIMESTAMPTZ NOT NULL,
    signup_source TEXT,          -- 'organic','referral','api_docs', ...
    country_bucket TEXT          -- coarse region, never precise geo
);

-- One row per subscription *state change*, Stripe-sourced.
-- A user can appear multiple times (upgrade, downgrade, churn, win-back).
CREATE TABLE subscriptions (
    subscription_id BIGINT PRIMARY KEY,
    user_id         BIGINT REFERENCES users(user_id),
    plan            TEXT NOT NULL,       -- 'free','pro','pro_plus','max'
    mrr             NUMERIC(10,2) NOT NULL,  -- monthly recurring revenue, USD
    started_at      TIMESTAMPTZ NOT NULL,
    canceled_at     TIMESTAMPTZ          -- NULL = still active
);

-- One row per API inference request. METADATA ONLY.
-- No prompt, no response, no token *content* -- just counts and timings.
CREATE TABLE api_requests (
    request_id  BIGINT PRIMARY KEY,
    api_key_id  BIGINT,          -- pseudonymous key, not a user trail
    user_id     BIGINT,          -- nullable: anonymous-tier calls have none
    ts          TIMESTAMPTZ NOT NULL,
    model       TEXT NOT NULL,   -- 'uncensored-model','llama-3.3-70b', ...
    tokens_in   INTEGER NOT NULL,
    tokens_out  INTEGER NOT NULL,
    latency_ms  INTEGER NOT NULL,
    status      TEXT NOT NULL    -- 'ok','rate_limited','error'
);

-- Content-free product events (PostHog-style, person_profiles: identified_only).
-- event names only -- never what the user typed or generated.
CREATE TABLE events (
    event_id   BIGINT PRIMARY KEY,
    user_id    BIGINT,          -- nullable for logged-out / anonymized events
    ts         TIMESTAMPTZ NOT NULL,
    event_name TEXT NOT NULL    -- 'signed_in','chat_created','image_generated',
                                -- 'model_switched','upgrade_clicked'
);

-- On-chain staking/credit activity. Fully PUBLIC data (Ethereum L2), pseudonymous wallets.
-- Join to users ONLY via consented_wallets (a first-party opt-in mapping).
CREATE TABLE onchain_stakes (
    tx_hash     TEXT PRIMARY KEY,
    wallet      TEXT NOT NULL,   -- pseudonymous address, never a user_id
    action      TEXT NOT NULL,   -- 'stake','unstake','mint_credit','burn'
    token_amount  NUMERIC(38,18) NOT NULL,
    block_time  TIMESTAMPTZ NOT NULL
);

-- The ONLY sanctioned wallet<->user bridge: explicit, consented, opt-in.
CREATE TABLE consented_wallets (
    user_id     BIGINT REFERENCES users(user_id),
    wallet      TEXT NOT NULL,
    consented_at TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (user_id, wallet)
);
The columns that aren't there

Notice what api_requests and events do not have: no prompt text, no response, no IP tied to a user, no cross-session device fingerprint. That's not an oversight in this schema — it's the company's design. Usage rows carry metadata (counts, timings, model, status); they never carry content. If an interviewer asks a question that would require content — "what are users asking the uncensored model about?" — the correct answer is "we don't and can't know that by design; here's the metadata proxy I'd use instead." Naming that boundary unprompted is a strong senior signal.

Window functions on subscription data

Subscriptions are the safest, richest data the company has — Stripe events are fully first-party and content-free. Nearly every finance question is a window-function problem over the subscriptions table. Here are the three that come up constantly.

1. MRR movement — new / expansion / contraction / churn

The VP wants one number decomposed: how did MRR change month over month, and why. The classic approach compares each user's MRR in consecutive months with LAG, then classifies the delta. First build a per-user monthly MRR snapshot, then window over it.

mrr_movement.sql
-- Grain: one row per user per month with their active MRR that month.
-- (Assume a helper that expands subscription state-changes into monthly
--  snapshots; in dbt this is a model, not a subquery you rewrite each time.)
WITH monthly_mrr AS (
    SELECT
        user_id,
        date_trunc('month', month_start) AS month,
        SUM(mrr) AS mrr
    FROM user_month_subscription_snapshot   -- built upstream
    GROUP BY user_id, date_trunc('month', month_start)
),
with_prev AS (
    SELECT
        user_id,
        month,
        mrr,
        LAG(mrr) OVER (
            PARTITION BY user_id ORDER BY month
        ) AS prev_mrr
    FROM monthly_mrr
)
SELECT
    month,
    SUM(CASE WHEN prev_mrr IS NULL AND mrr > 0
             THEN mrr END)                       AS new_mrr,
    SUM(CASE WHEN prev_mrr > 0 AND mrr > prev_mrr
             THEN mrr - prev_mrr END)            AS expansion_mrr,
    SUM(CASE WHEN prev_mrr > 0 AND mrr < prev_mrr AND mrr > 0
             THEN mrr - prev_mrr END)            AS contraction_mrr,  -- negative
    SUM(CASE WHEN prev_mrr > 0 AND mrr = 0
             THEN -prev_mrr END)                 AS churned_mrr       -- negative
FROM with_prev
GROUP BY month
ORDER BY month;

The senior touches here: LAG partitioned per user and ordered by month is what makes this readable — a self-join on month = month - 1 is both slower and buggier around gaps. The prev_mrr IS NULL branch is genuinely "new," and the mrr = 0 branch is churn. Note that the company's freemium shape means "downgrade to free" is contraction-to-zero, i.e. churn of paid MRR even though the account survives — call that distinction out loud.

2. Retention cohorts

Group paying users by the month they first converted, then measure how many are still paying N months later. This is a two-window pattern: find each user's cohort with a MIN() OVER (or a grouped min), then count survivors per cohort-month offset.

retention_cohorts.sql
WITH paid_months AS (
    SELECT DISTINCT
        user_id,
        date_trunc('month', month_start) AS month
    FROM user_month_subscription_snapshot
    WHERE mrr > 0
),
cohorts AS (
    SELECT
        user_id,
        month,
        MIN(month) OVER (PARTITION BY user_id) AS cohort_month
    FROM paid_months
)
SELECT
    cohort_month,
    (EXTRACT(YEAR FROM age(month, cohort_month)) * 12
       + EXTRACT(MONTH FROM age(month, cohort_month)))::INT AS months_since_first,
    COUNT(DISTINCT user_id) AS retained_users
FROM cohorts
GROUP BY cohort_month, months_since_first
HAVING COUNT(DISTINCT user_id) >= 20   -- k-anonymity: suppress tiny cohorts
ORDER BY cohort_month, months_since_first;

The HAVING COUNT(DISTINCT user_id) >= 20 is not decoration — it's the k-anonymity habit from Chapter 02 applied reflexively. A cohort of three people is both statistically meaningless and a small re-identification risk. Building suppression into the query by default, rather than as an afterthought, is exactly the instinct this role screens for.

3. Second-highest / top-N per group — and the tie/NULL traps

The canonical "rank" question. It's really a test of whether you know the difference between the three ranking windows and can reason about ties and NULLs. Suppose they ask: "For each plan, return the account with the second-highest lifetime spend."

top_n_per_group.sql
-- ROW_NUMBER : arbitrary tiebreak, exactly one row per rank -> use for "the Nth".
-- RANK       : ties share a rank, then it SKIPS (1,1,3) -> "2nd" may not exist.
-- DENSE_RANK : ties share a rank, no skip (1,1,2)       -> "2nd distinct value".
WITH spend AS (
    SELECT
        s.plan,
        s.user_id,
        SUM(s.mrr) AS lifetime_spend
    FROM subscriptions s
    GROUP BY s.plan, s.user_id
),
ranked AS (
    SELECT
        plan,
        user_id,
        lifetime_spend,
        ROW_NUMBER() OVER (
            PARTITION BY plan
            ORDER BY lifetime_spend DESC, user_id   -- deterministic tiebreak
        ) AS rn
    FROM spend
)
SELECT plan, user_id, lifetime_spend
FROM ranked
WHERE rn = 2;
Say this out loud during the screen

"I'm using ROW_NUMBER because you asked for a single account; if you meant the second-highest spend value and want everyone tied at it, I'd switch to DENSE_RANK = 2. And I added user_id as a tiebreak so the result is deterministic across runs." That one sentence demonstrates you understand all three functions, ties, and reproducibility — more signal than a correct-but-silent query. On NULLs: ORDER BY ... DESC puts NULLs first in Postgres unless you add NULLS LAST, which will silently rank a user with no spend as #1. If lifetime spend can be NULL, handle it.

Usage funnels & retention on metadata

Now the harder, company-specific half: measuring behavior when all you have is content-free events and metadata. You cannot build a per-user behavioral trail across sessions (that's off-limits — see Chapter 02). But you can build activation funnels and retention on the events that are logged, and you must do it k-anonymously.

Activation funnel: signup → first chat → first paid inference

Activation is the highest-leverage growth metric for a freemium product. Here it's a funnel over content-free milestones. The clean pattern is one CTE per milestone timestamp, joined on user_id, then counted.

activation_funnel.sql
WITH signups AS (
    SELECT user_id, signed_up_at
    FROM users
    WHERE signed_up_at >= DATE '2026-07-01'
),
first_chat AS (
    SELECT user_id, MIN(ts) AS first_chat_at
    FROM events
    WHERE event_name = 'chat_created'
    GROUP BY user_id
),
first_paid_inference AS (
    -- first API request made by a user on a paid subscription
    SELECT r.user_id, MIN(r.ts) AS first_paid_at
    FROM api_requests r
    WHERE r.user_id IS NOT NULL
      AND EXISTS (
          SELECT 1 FROM subscriptions s
          WHERE s.user_id = r.user_id
            AND s.mrr > 0
            AND s.started_at <= r.ts
            AND (s.canceled_at IS NULL OR s.canceled_at > r.ts)
      )
    GROUP BY r.user_id
)
SELECT
    COUNT(*)                                              AS signed_up,
    COUNT(fc.first_chat_at)                               AS reached_first_chat,
    COUNT(fp.first_paid_at)                               AS reached_first_paid,
    ROUND(100.0 * COUNT(fc.first_chat_at) / COUNT(*), 1)  AS pct_to_chat,
    ROUND(100.0 * COUNT(fp.first_paid_at)
          / NULLIF(COUNT(fc.first_chat_at), 0), 1)        AS pct_chat_to_paid
FROM signups su
LEFT JOIN first_chat fc            USING (user_id)
LEFT JOIN first_paid_inference fp  USING (user_id)
WHERE su.user_id IN (SELECT user_id FROM signups);

LEFT JOIN from the signup cohort is what keeps the denominator honest — every signup counts whether or not they progressed, and COUNT(col) ignores the NULLs the outer join produces. NULLIF(..., 0) guards the divide-by-zero when a cohort has no chatters yet. The EXISTS correlated to an active paid subscription is more precise here than a join, because a user can have several subscription rows.

N-day retention on content-free events

"Of users active on day 0, what fraction were active again on day N?" — computed purely from event timestamps, no content required.

n_day_retention.sql
WITH activity AS (
    SELECT DISTINCT user_id, date_trunc('day', ts)::date AS active_day
    FROM events
    WHERE user_id IS NOT NULL
),
first_seen AS (
    SELECT user_id, MIN(active_day) AS day0
    FROM activity
    GROUP BY user_id
)
SELECT
    a.active_day - f.day0                       AS day_n,
    COUNT(DISTINCT a.user_id)                   AS retained
FROM activity a
JOIN first_seen f USING (user_id)
WHERE a.active_day - f.day0 BETWEEN 0 AND 30
GROUP BY a.active_day - f.day0
HAVING COUNT(DISTINCT a.user_id) >= 20          -- suppress small cells
ORDER BY day_n;

A k-anonymity-respecting aggregate

Any breakdown you might export, embed in a dashboard, or hand to a growth PM should refuse to emit a row backed by fewer than k distinct users. Bake it into the query, not the reviewer's memory.

k_anon_model_mix.sql
-- Per-model usage, but only report a model if >= k distinct users touched it.
SELECT
    model,
    COUNT(*)                    AS requests,
    SUM(tokens_in + tokens_out) AS total_tokens,
    ROUND(AVG(latency_ms))      AS avg_latency_ms
FROM api_requests
WHERE ts >= DATE '2026-08-01'
GROUP BY model
HAVING COUNT(DISTINCT user_id) >= 20   -- k = 20; drop long-tail single-user models
ORDER BY requests DESC;
Why this reads as senior

Anyone can write the GROUP BY model. The HAVING COUNT(DISTINCT user_id) >= 20 line is what tells the interviewer you've internalized that at this company, a query is also a privacy decision. You're not anonymizing after the fact — you're refusing to emit re-identifiable small cells in the first place. Do this reflexively and mention it once; don't lecture.

The SQL gotchas

These are the traps that separate someone who writes SQL daily from someone who learned it for interviews. Each one shows up in a question shaped by this company's data.

Window functions can't live in WHERE

A window function is computed after WHERE/GROUP BY, so you cannot filter on it in the same query level. "Give me each user's most recent request" is the classic case — wrap the window in a CTE (or subquery) and filter in the outer query.

window_in_where.sql
-- WRONG: window functions are not allowed in WHERE.
-- SELECT * FROM api_requests
-- WHERE ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) = 1;  -- error

-- RIGHT: compute in a CTE, filter outside.
WITH ranked AS (
    SELECT
        r.*,
        ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) AS rn
    FROM api_requests r
    WHERE user_id IS NOT NULL
)
SELECT * FROM ranked WHERE rn = 1;   -- (QUALIFY rn = 1 in BigQuery/Snowflake)

NULLs in aggregates and NOT IN

Two separate landmines. First, COUNT(col) skips NULLs while COUNT(*) counts every row — mixing them up silently corrupts a conversion rate. Second, NOT IN against a subquery that returns even one NULL returns zero rows, because x NOT IN (1, NULL) evaluates to UNKNOWN, never TRUE. Here this bites constantly because api_requests.user_id is nullable (anonymous-tier calls).

not_in_null_trap.sql
-- DANGER: if ANY api_requests.user_id is NULL, this returns NOTHING.
-- SELECT * FROM users
-- WHERE user_id NOT IN (SELECT user_id FROM api_requests);

-- SAFE: NOT EXISTS is null-proof and usually faster.
SELECT u.*
FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM api_requests r WHERE r.user_id = u.user_id
);

Dedupe deliberately

Event streams and on-chain loaders both produce duplicate rows — retries, at-least-once delivery, chain reorgs. Never let SELECT DISTINCT * be your dedupe strategy; it hides the question "duplicate on what key?" Decide the natural key and keep one row per key on purpose.

dedupe.sql
-- Keep one row per logical event, preferring the earliest ingest.
WITH deduped AS (
    SELECT
        e.*,
        ROW_NUMBER() OVER (
            PARTITION BY event_id          -- the true natural key
            ORDER BY ts                    -- keep the first-seen copy
        ) AS rn
    FROM events e
)
SELECT * FROM deduped WHERE rn = 1;

Date-boundary and timezone bugs

The platform's staking-token allocation resets daily at 00:00 UTC — a detail worth memorizing. If you bucket usage by date_trunc('day', ts) in a session timezone that isn't UTC, your "daily" numbers won't line up with the entitlement window, and you'll report phantom over/under-utilization. Always truncate on-chain and entitlement-linked timestamps in UTC explicitly.

utc_day_boundary.sql
-- Align usage to the SAME 00:00 UTC boundary the staking allocation resets on.
SELECT
    date_trunc('day', ts AT TIME ZONE 'UTC') AS utc_day,
    COUNT(*)                                 AS requests,
    SUM(tokens_out)                          AS tokens_served
FROM api_requests
GROUP BY date_trunc('day', ts AT TIME ZONE 'UTC')
ORDER BY utc_day;
-- Half-open intervals [start, end) beat BETWEEN for timestamps:
--   ts >= '2026-08-01' AND ts < '2026-09-01'
-- avoids the "misses/double-counts the last millisecond" BETWEEN bug.

Gaps-and-islands for sessionization on metadata

You can't track a user across sessions, but you can group one identified user's own events into sessions (e.g. a 30-minute inactivity gap starts a new session) — the classic gaps-and-islands pattern. It's a favorite advanced screen question because it forces two stacked windows: LAG to find gaps, a running SUM to assign island IDs.

sessionize.sql
WITH flagged AS (
    SELECT
        user_id,
        ts,
        CASE WHEN ts - LAG(ts) OVER (PARTITION BY user_id ORDER BY ts)
                  > INTERVAL '30 minutes'
             OR LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) IS NULL
             THEN 1 ELSE 0 END AS is_new_session
    FROM events
    WHERE user_id IS NOT NULL
),
sessioned AS (
    SELECT
        user_id,
        ts,
        SUM(is_new_session) OVER (
            PARTITION BY user_id ORDER BY ts
            ROWS UNBOUNDED PRECEDING
        ) AS session_seq
    FROM flagged
)
SELECT
    user_id, session_seq,
    MIN(ts) AS session_start,
    MAX(ts) AS session_end,
    COUNT(*) AS events_in_session
FROM sessioned
GROUP BY user_id, session_seq;

Production-quality code

The posting pairs product-mindedness with engineering capability, and pairs SQL fluency with "production-quality code." For a first data hire, "production-quality" doesn't mean microservices — it means the analytics you ship behaves like software the rest of the eng org (Rust/Go/TS people) will respect. Concretely:

  • Idempotent, tested transformations. Re-running a model produces the same result; you don't accumulate duplicates or drift. In dbt that means deterministic materializations plus unique, not_null, and relationship tests on keys — so a broken upstream load fails loudly instead of silently poisoning the MRR chart.
  • Version control, always. Every model, metric definition, and dashboard-backing query lives in git and ships through PRs. No "final query" pasted into a BI tool that no one can find or review later.
  • Readable CTEs over nested subqueries. A five-CTE model that reads top-to-bottom like a paragraph beats a three-deep nested subquery that's technically shorter. You're optimizing for the next person (often future-you) to change it safely.
  • Parameterization, no hardcoded secrets. Date ranges, thresholds (like k), and connection details are variables/env config — never literals sprinkled through the code, and never a Stripe key or DB password in the repo.
  • Code review. Even as the only data person, you invite review — from an eng peer on the SQL logic, from finance on the metric definitions. It's how a metric becomes the definition instead of your private opinion.

Have one dbt example ready to describe: a staging model that's tested and idempotent. And be ready to show plain engineering capability — the posting is explicit about it — with a small, tidy Python transform. Something like this: a pure, typed, documented, testable function that turns raw usage rows into per-model daily rollups, deliberately dropping content and suppressing small cells.

rollup.py
"""Aggregate content-free API-usage rows into k-anonymous daily rollups.

Pure function, no I/O -- easy to unit-test and safe to reuse in a dbt
Python model, an Airflow task, or an ad-hoc notebook.
"""
from __future__ import annotations

import pandas as pd


def daily_model_rollup(
    requests: pd.DataFrame,
    *,
    k: int = 20,
    tz: str = "UTC",  # align to the 00:00 UTC staking-reset boundary
) -> pd.DataFrame:
    """Roll usage metadata up to (utc_day, model), suppressing small cells.

    Args:
        requests: rows with columns
            [ts, user_id, model, tokens_in, tokens_out, latency_ms].
            Carries METADATA ONLY -- never prompt/response content.
        k: minimum distinct users required to emit a (day, model) cell.
        tz: timezone to bucket ``ts`` into before truncating to the day.

    Returns:
        One row per (utc_day, model) with >= k distinct users.
    """
    required = {"ts", "user_id", "model", "tokens_in", "tokens_out", "latency_ms"}
    missing = required - set(requests.columns)
    if missing:
        raise ValueError(f"missing required columns: {sorted(missing)}")

    df = requests.copy()
    df["utc_day"] = (
        pd.to_datetime(df["ts"], utc=True).dt.tz_convert(tz).dt.floor("D")
    )
    df["tokens_total"] = df["tokens_in"] + df["tokens_out"]

    grouped = df.groupby(["utc_day", "model"], as_index=False).agg(
        requests=("ts", "size"),
        distinct_users=("user_id", "nunique"),
        tokens_total=("tokens_total", "sum"),
        avg_latency_ms=("latency_ms", "mean"),
    )
    # k-anonymity: never emit a cell backed by fewer than k users.
    return grouped[grouped["distinct_users"] >= k].reset_index(drop=True)
test_rollup.py
import pandas as pd
import pytest

from rollup import daily_model_rollup


def _rows(n_users: int, model: str = "uncensored-model") -> pd.DataFrame:
    return pd.DataFrame({
        "ts": ["2026-08-01T12:00:00Z"] * n_users,
        "user_id": range(n_users),
        "model": [model] * n_users,
        "tokens_in": [10] * n_users,
        "tokens_out": [20] * n_users,
        "latency_ms": [100] * n_users,
    })


def test_suppresses_cells_below_k():
    out = daily_model_rollup(_rows(5), k=20)
    assert out.empty  # 5 users < k -> nothing emitted


def test_emits_cell_at_or_above_k():
    out = daily_model_rollup(_rows(25), k=20)
    assert len(out) == 1
    assert out.loc[0, "distinct_users"] == 25
    assert out.loc[0, "tokens_total"] == 25 * 30


def test_rejects_missing_columns():
    with pytest.raises(ValueError, match="missing required columns"):
        daily_model_rollup(pd.DataFrame({"ts": []}), k=1)
Bring the tests, not just the function

If a take-home lets you, submit the function with its tests. Most candidates submit a script; a senior one submits a tested, documented, reusable unit and a one-line note on how it slots into the pipeline. The k-suppression baked into the function — and the test that proves it — quietly demonstrates that you build privacy in at the code level, which is the whole differentiator at a privacy-first company.

How to run the screen

Same principles whether it's live-shared or a take-home. The screen is graded on judgment as much as syntax.

  1. Read the schema first, out loud. "So subscriptions is one row per state change, not one per user — a user can appear several times. api_requests.user_id is nullable for anonymous calls. And there's no content anywhere, which I'll assume is deliberate." You've just shown you read a schema like an analyst and internalized the company's privacy shape in ten seconds.
  2. Ask the one clarifying question that changes the query. Usually the metric definition: does churn mean the cancel event or the end of the paid period? Is "active" any event or a successful inference? Ask that; don't ask five.
  3. State your assumptions, then commit. "I'll treat churn as MRR dropping to zero and count downgrade-to-free as paid churn. Flag me if you meant otherwise." This is the interview version of the job's "transparent about uncertainty" — see Chapter 08.
  4. Narrate your thinking as you build. "This is period-over-period, so I want LAG per user rather than a self-join." The interviewer is buying your reasoning, not just your final SELECT.
  5. Reach for windows before self-joins. If you catch yourself joining a table to a shifted copy of itself, stop — it's almost always a window function (LAG/LEAD/ROW_NUMBER/running SUM) that's faster, shorter, and correct around gaps.
  6. Sanity-check the result. Eyeball row counts and totals. "Retention above 100% at day 0 would mean a bug" — catching your own error before they point it out is a strong close.
Calibration

You will not be graded on perfect syntax recall — everyone forgets exact date_trunc arguments under a clock, and that's fine to look up. You are graded on: reaching for the right construct immediately, handling NULL/tie/timezone edge cases without being prompted, and treating every aggregate as a privacy decision. Get those three right and a missed comma is noise. Miss them and flawless syntax won't save you. Next: turning the answer into a recommendation an executive can act on →