SQL for Growth Analytics Questions
Domain specific SQL patterns used in growth analytics: cohort analysis, retention and churn calculations, funnel analysis for multi step user journeys, acquisition cohort queries, lifetime value computations, customer segmentation, and event aggregation for time series. Emphasis on USING GROUP BY, window functions, CTEs, date bucketing, cohort windows, and efficient joins against event tables to compute retention curves, conversion rates, and growth metrics at scale.
MediumTechnical
71 practiced
For a funnel from signup to purchase, produce SQL that returns percentiles (25th, 50th, 75th) of time from signup to first purchase for each acquisition cohort. The database supports percentile_disc/approx_percentile functions—show both methods and explain performance implications on large purchase event tables.
Sample Answer
Approach: compute each user’s time delta from signup to their first purchase, assign users to acquisition cohorts (e.g., signup_week), then aggregate percentiles per cohort. Two methods: exact percentiles using percentile_disc (or percentile_cont) and approximate using approx_percentile. For correctness, derive first_purchase_ts with a window or a MIN aggregation to avoid counting multiple purchases.Exact (percentile_disc) — uses ordered-set aggregate, returns exact discrete value:Approximate (approx_percentile) — faster on big tables, uses sketches:Performance implications and best practices:- percentile_disc is exact but can be expensive: it may sort large per-cohort value sets or use heavy memory; not great for very large purchases tables.- approx_percentile (or TDigest/Quantile sketches) is much faster and more memory-efficient; single-pass sketches work well for streaming/large-scale data with acceptable error bounds.- Optimize joins: push filters (p.purchase_ts >= signup_ts) into join, use MIN(purchase_ts) with proper indexes on purchases(user_id, purchase_ts).- Consider precomputing first_purchase per user in a nightly job or materialized view to avoid scanning the entire purchases table each query.- If cohort cardinality is large, shard by cohort or aggregate in map-reduce stages; validate approximation error on a sample against exact percentiles.
sql
WITH first_purchase AS (
SELECT
u.user_id,
DATE_TRUNC('week', u.signup_ts) AS cohort_week,
MIN(p.purchase_ts) AS first_purchase_ts
FROM users u
LEFT JOIN purchases p
ON p.user_id = u.user_id
AND p.purchase_ts >= u.signup_ts
GROUP BY 1,2
),
deltas AS (
SELECT
cohort_week,
EXTRACT(EPOCH FROM (first_purchase_ts - signup_ts))/3600.0 AS hours_to_first_purchase
FROM first_purchase f
JOIN users u ON u.user_id = f.user_id
WHERE first_purchase_ts IS NOT NULL
)
SELECT
cohort_week,
percentile_disc(0.25) WITHIN GROUP (ORDER BY hours_to_first_purchase) AS p25_hours,
percentile_disc(0.50) WITHIN GROUP (ORDER BY hours_to_first_purchase) AS p50_hours,
percentile_disc(0.75) WITHIN GROUP (ORDER BY hours_to_first_purchase) AS p75_hours
FROM deltas
GROUP BY cohort_week
ORDER BY cohort_week;sql
WITH first_purchase AS (
SELECT user_id, DATE_TRUNC('week', signup_ts) AS cohort_week, MIN(purchase_ts) AS first_purchase_ts
FROM users u
LEFT JOIN purchases p USING (user_id)
WHERE p.purchase_ts >= u.signup_ts
GROUP BY user_id, cohort_week
),
deltas AS (
SELECT cohort_week,
EXTRACT(EPOCH FROM (first_purchase_ts - signup_ts))/3600.0 AS hours_to_first_purchase
FROM first_purchase f JOIN users u USING (user_id)
WHERE first_purchase_ts IS NOT NULL
)
SELECT
cohort_week,
approx_percentile(hours_to_first_purchase, ARRAY[0.25,0.5,0.75]) AS percentiles_hours
FROM deltas
GROUP BY cohort_week
ORDER BY cohort_week;MediumTechnical
71 practiced
Compute cohort LTV with monthly discounting. Given purchases(user_id, amount, occurred_at) and users(user_id, created_at), write SQL to compute, per monthly acquisition cohort, the discounted cumulative revenue per user for months 0..11 using a monthly discount rate of 5% (i.e., PV = rev / (1 + 0.05)^month_index). Return cohort_month, cohort_size, avg_discounted_ltv_12m.
Sample Answer
Approach: assign each user to a monthly acquisition cohort (month of users.created_at), compute each purchase's month index relative to acquisition (0 for same month), discount each month's revenue by (1+0.05)^month_index, sum discounted revenue per cohort and divide by cohort size to get average discounted LTV over months 0..11. Count users with no purchases in period as zero revenue.SQL (works on Postgres / Redshift / BigQuery-like dialect; adjust date_diff function names if needed):Key points:- month_index uses integer month difference so purchases in the same calendar month are month_index = 0.- Discounting uses POWER(1.05, month_index).- Users with no purchases contribute zero to numerator but count in cohort_size.Edge cases:- Timezones / timestamps: ensure created_at and occurred_at are in same timezone or normalized.- Purchases before created_at (negative month_index) are excluded.- If you want cohort_month as first-of-month string, format accordingly.Complexity: single-pass aggregations over purchases and users; performance depends on table sizes and indexes on user_id and occurred_at. Consider pre-aggregating purchases by month for large datasets.
sql
WITH user_cohorts AS (
SELECT
user_id,
DATE_TRUNC('month', created_at)::date AS cohort_month
FROM users
),
purchases_enriched AS (
SELECT
u.cohort_month,
p.user_id,
p.amount,
-- month_index: 0 for purchases in same calendar month as created_at
DATE_PART('year', p.occurred_at)::int * 12 + DATE_PART('month', p.occurred_at)::int
- (DATE_PART('year', u.cohort_month)::int * 12 + DATE_PART('month', u.cohort_month)::int)
AS month_index
FROM purchases p
JOIN user_cohorts u ON p.user_id = u.user_id
),
discounted_by_month AS (
-- keep only months 0..11 and compute PV
SELECT
cohort_month,
user_id,
month_index,
amount / POWER(1.05, month_index) AS discounted_amount
FROM purchases_enriched
WHERE month_index BETWEEN 0 AND 11
),
cohort_revenue AS (
-- sum discounted revenue per cohort
SELECT
cohort_month,
SUM(discounted_amount) AS total_discounted_revenue
FROM discounted_by_month
GROUP BY cohort_month
),
cohort_size AS (
-- count users per cohort (include users with no purchases)
SELECT
cohort_month,
COUNT(*)::numeric AS cohort_size
FROM user_cohorts
GROUP BY cohort_month
)
SELECT
c.cohort_month,
cs.cohort_size,
COALESCE(cr.total_discounted_revenue, 0) / cs.cohort_size AS avg_discounted_ltv_12m
FROM cohort_size cs
LEFT JOIN cohort_revenue cr ON cs.cohort_month = cr.cohort_month
ORDER BY c.cohort_month;HardTechnical
121 practiced
Design SQL patterns to stitch sessions across devices where user identifiers are inconsistent. Describe deterministic matching (email hash, login) vs probabilistic matching (device fingerprint, IP patterns). Provide example SQL snippets that perform deterministic joins first and then a probabilistic scoring join for unmatched events, and discuss privacy, collision, and false-positive trade-offs.
Sample Answer
Goal: stitch sessions across devices when user IDs differ by applying deterministic matches first (high precision) then probabilistic scoring for remaining events (higher recall). Outline, SQL patterns, and trade-offs follow.Deterministic matching (high precision)- Exact matches: email hash, account_id, login event, SSO id.- Use as first-pass canonicalization.Example: mark events with deterministic user_idProbabilistic matching (scoring) for unmatched events- Features: device_fingerprint, IP subnet, user_agent family, time proximity, same billing address, behavioral vectors.- Assign weights, compute similarity score, join only above threshold.Example: score candidates and pick best matchIntegration: prefer deterministic_id; fallback to probabilistic_user when available; otherwise anonymous session.Trade-offs and considerations- Privacy: hash emails only with salt, limit retention, comply with GDPR/CCPA; avoid storing PII in cleartext; use differential privacy or aggregate only for analytics where possible.- Collisions: hashed identifiers can collide if salt reused; choose strong hash (SHA-256) and per-environment salt; watch IP subnet heuristics causing false merges.- False positives vs false negatives: deterministic reduces false positives but misses cross-device users; probabilistic increases recall but risks incorrect merges—set conservative thresholds and log confidence.- Auditability: store match reason and score to allow rollbacks and manual review.- Operational: reprocess when deterministic data appears (e.g., later login), maintain lineage for dashboards.This pattern yields deterministic-first, explainable stitching with a scored fallback that balances precision and recall while preserving privacy and auditability.
sql
-- deterministic: email_hash or login_user_id
WITH events AS (
SELECT event_id, device_id, email_hash, login_user_id, ts
FROM raw_events
),
det_matches AS (
SELECT e.*, COALESCE(login_user_id, email_hash) AS deterministic_id
FROM events e
WHERE login_user_id IS NOT NULL OR email_hash IS NOT NULL
)
SELECT * FROM det_matches;sql
WITH unmatched AS (
SELECT e.* FROM raw_events e
LEFT JOIN det_matches d ON e.event_id = d.event_id
WHERE d.event_id IS NULL
),
candidates AS (
SELECT u.event_id, a.user_id AS candidate_user,
(CASE WHEN u.device_fingerprint = a.device_fingerprint THEN 0.5 ELSE 0 END) +
(CASE WHEN inet_nton(u.ip) >> 16 = inet_nton(a.ip) >> 16 THEN 0.2 ELSE 0 END) +
(CASE WHEN regexp_replace(u.user_agent,'[^a-z]','','i') = regexp_replace(a.user_agent,'[^a-z]','','i') THEN 0.2 ELSE 0 END) +
(CASE WHEN ABS(EXTRACT(EPOCH FROM u.ts - a.ts)) < 3600 THEN 0.1 ELSE 0 END) AS score
FROM unmatched u
JOIN known_id_events a ON TRUE -- restrict by time window, geo, etc.
),
best AS (
SELECT event_id, candidate_user, score,
ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY score DESC) rn
FROM candidates
)
SELECT event_id, candidate_user AS probabilistic_user
FROM best
WHERE rn = 1 AND score >= 0.6;MediumTechnical
78 practiced
Write SQL that computes a 7-day rolling conversion rate from 'trial_start' to 'pay' events per country, where rolling is computed by calendar day (i.e., for each day D compute conversions among trials that started in [D-6, D]). Use events(user_id, event_name, occurred_at, country). Explain how to handle users who change country between trial_start and pay.
Sample Answer
Approach: build daily cohorts by trial_start date and country (country at trial_start), count trials per cohort for a 7-day trailing window, count pays whose trial_start was in that window and whose pay occurred after trial_start. For users who change country between trial and pay, attribute the conversion to the country at trial_start (cohort-based attribution). Optionally report pay_country if you want to analyze country-migration impact.SQL (Postgres-style, adjust functions for your dialect):Key points:- Attribution: use country at trial_start to measure where acquisition/conversion occurred.- Handle users who change country by attributing conversion to trial_country; optionally surface pay country for migration analysis.- Edge cases: multiple trials per user (choose first or define policy), pay before trial (exclude), timezone normalization, sparse days (use calendar).- Window: using ROWS BETWEEN 6 PRECEDING computes a 7-day calendar-window by date ordering per country. If your SQL lacks RANGE support for dates, you can aggregate by date then use window on ordered rows as shown.
sql
WITH trials AS (
-- extract each user's trial_start with the country at that time
SELECT
user_id,
DATE(occurred_at) AS trial_date,
country AS trial_country,
MIN(occurred_at) FILTER (WHERE event_name = 'trial_start') OVER (PARTITION BY user_id) AS first_trial_ts
FROM events
WHERE event_name = 'trial_start'
),
distinct_trials AS (
-- one row per user per trial (use the first trial_start if multiple)
SELECT DISTINCT ON (user_id) user_id, first_trial_ts AS trial_ts, DATE(first_trial_ts) AS trial_date, trial_country
FROM trials
ORDER BY user_id, first_trial_ts
),
pays AS (
-- find first pay after trial for each user (if any)
SELECT
t.user_id,
t.trial_date,
t.trial_country,
MIN(e.occurred_at) AS pay_ts
FROM distinct_trials t
LEFT JOIN events e
ON e.user_id = t.user_id
AND e.event_name = 'pay'
AND e.occurred_at >= t.trial_ts
GROUP BY t.user_id, t.trial_date, t.trial_country
),
daily_calendar AS (
-- generate calendar of days for reporting
SELECT generate_series(min(trial_date), max(trial_date), interval '1 day')::date AS day
FROM distinct_trials
),
cohort_counts AS (
-- trials per day+country
SELECT trial_date AS day, trial_country AS country, COUNT(*) AS trials
FROM distinct_trials
GROUP BY 1,2
),
cohort_pays AS (
-- pays attributed to trial_date+country
SELECT trial_date AS day, trial_country AS country, COUNT(*) AS pays
FROM pays
WHERE pay_ts IS NOT NULL
GROUP BY 1,2
)
SELECT
d.day,
c.country,
COALESCE(SUM(c.trials) OVER (PARTITION BY c.country ORDER BY d.day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW),0) AS trials_7d,
COALESCE(SUM(cp.pays) OVER (PARTITION BY c.country ORDER BY d.day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW),0) AS pays_7d,
CASE WHEN COALESCE(SUM(c.trials) OVER (PARTITION BY c.country ORDER BY d.day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW),0) = 0
THEN NULL
ELSE ROUND(
COALESCE(SUM(cp.pays) OVER (PARTITION BY c.country ORDER BY d.day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)::numeric
/ SUM(c.trials) OVER (PARTITION BY c.country ORDER BY d.day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 4)
END AS conversion_rate_7d
FROM
(SELECT day FROM daily_calendar) d
CROSS JOIN
(SELECT DISTINCT trial_country AS country FROM distinct_trials) countries
LEFT JOIN cohort_counts c ON c.day = d.day AND c.country = countries.country
LEFT JOIN cohort_pays cp ON cp.day = d.day AND cp.country = countries.country
ORDER BY d.day, countries.country;HardSystem Design
74 practiced
Design an incremental ETL SQL pattern to maintain a daily pre-aggregated cohort table used by dashboards. Requirements: idempotent runs, handle late-arriving events up to 7 days, and ensure correctness if a historical row is reprocessed. Provide pseudo-SQL for MERGE/UPSERT logic and explain how you schedule/partition updates to minimize compute and avoid duplication.
Sample Answer
Requirements clarified:- Build a daily pre-aggregated cohort table (one row per cohort_date, user_id, metric...) for dashboards.- Idempotent runs, accept late events up to 7 days, and be correct if historical rows are reprocessed.Design overview:- Ingest raw events into a staging table (stg_events) with event_time, user_id, attrs and an ingestion_ts.- Compute daily aggregates in a staging-aggregate table (stg_cohort_agg) for a sliding window of interest (run_date and previous 6 days).- Use deterministic dedupe (ROW_NUMBER over a stable key or hash) and a checksum/hash of raw input to ensure idempotency.- Upsert into final table cohort_agg partitioned by cohort_date. Each run reprocesses only partitions in [run_date - 6, run_date] so late arrivals are handled and compute minimized.- Use MERGE/UPSERT with equality on primary key (cohort_date, user_id, metric_id) and compare checksums to avoid unnecessary writes.Pseudo-SQL (generic ANSI-like):Key correctness points:- Reprocessing same inputs yields same aggregate and checksum → MERGE is idempotent (no spurious duplicates).- Comparing checksums avoids unnecessary updates and downstream invalidations.- Partitioning cohort_agg by cohort_date makes targeted reads/writes efficient and allows TTL/retention.Scheduling & partition strategy to minimize compute:- Daily job triggered at fixed time T processes only partitions: run_date and previous 6 days (7-day late-arrival window).- For each partition, compute aggregates from stg_events filtered to that date range. This lets recent partitions be updated multiple times if late data arrives; older partitions outside window are immutable.- Maintain a watermark table job_metadata(run_date, processed_at). Job writes metadata atomically after successful MERGE.- For large tables, leverage micro-batch: first compute deltas by joining stg_events to last-processed ingestion_ts per partition to process only new/changed raw events.- Use compaction/merge-on-read periodically to reduce small files and optimize query performance.Handling reprocessing & failures:- Use transactional MERGE (or single atomic job) so partial failures don't corrupt state. If job fails, re-run same run_date window; idempotency ensures safe retry.- For full historical reprocess (e.g., schema change), compute new checksum and overwrite partitions by replacing partition atomically (CREATE OR REPLACE PARTITION).Operational notes:- Monitor the number of updates per run (if many updates, consider extending late-arrival window or recruiting upstream fixes).- Keep retention and partition pruning aligned with dashboard SLAs.- Document checksum logic so BI consumers understand why metrics can change within 7 days but are stable after that.
sql
-- 1. Build staging aggregates for window [run_date-6 .. run_date]
WITH events_window AS (
SELECT *
FROM stg_events
WHERE event_date BETWEEN DATE_SUB(@run_date, INTERVAL 6 DAY) AND @run_date
),
-- deterministic dedupe: keep latest ingestion or canonical row per raw_event_id
deduped AS (
SELECT *
FROM (
SELECT e.*,
ROW_NUMBER() OVER (PARTITION BY raw_event_id ORDER BY ingestion_ts DESC) rn
FROM events_window e
) t
WHERE rn = 1
),
-- compute per-cohort aggregates and checksum
stg_cohort_agg AS (
SELECT
DATE(event_time) AS cohort_date,
user_id,
metric_id,
COUNT(*) AS events_count,
SUM(value) AS value_sum,
-- deterministic checksum of inputs for idempotency
MD5(CONCAT(user_id, '|', metric_id, '|', DATE(event_time), '|', CAST(COUNT(*) AS STRING), '|', CAST(SUM(value) AS STRING))) AS agg_checksum
FROM deduped
GROUP BY 1,2,3
)
-- 2. MERGE into partitioned target table
MERGE INTO analytics.cohort_agg T
USING stg_cohort_agg S
ON T.cohort_date = S.cohort_date
AND T.user_id = S.user_id
AND T.metric_id = S.metric_id
WHEN MATCHED AND T.agg_checksum != S.agg_checksum THEN
UPDATE SET
events_count = S.events_count,
value_sum = S.value_sum,
agg_checksum = S.agg_checksum,
updated_at = CURRENT_TIMESTAMP()
WHEN NOT MATCHED THEN
INSERT (cohort_date, user_id, metric_id, events_count, value_sum, agg_checksum, created_at, updated_at)
VALUES (S.cohort_date, S.user_id, S.metric_id, S.events_count, S.value_sum, S.agg_checksum, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP());Unlock Full Question Bank
Get access to hundreds of SQL for Growth Analytics interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.