SQL for Data Analysis: The Six Query Patterns Analyst Interviews Actually Test

Quick Overview
A grain-first SQL guide for Data Analysts with six auditable PostgreSQL walkthroughs. Each pattern includes visible inputs, an operation-only query, a row-flow diagram, and exact output for metrics, mature cohorts, deterministic ranking, calendar windows, sessions, and NULL-aware profiling.
Analyst SQL gets easier when you decide the output grain before choosing syntax. Start with the business unit you need to report, such as one row per ad, cohort, device, or day. Then trace how each join, filter, aggregation, and window changes that grain.
This guide develops six reusable patterns on small PostgreSQL tables. Each example shows the input, the operation, the row flow, and the exact output so you can audit the logic instead of trusting a familiar-looking query.
Start with grain: six practical analyst patterns
Use this map before writing SQL:
| Question shape | Intended output grain | Main risk |
|---|---|---|
| Conditional metric | one row per reporting key | numerator and denominator use different populations |
| Retention | one row per mature cohort | signup-day activity or immature cohorts enter the metric |
| First or latest row | one row per entity | ties make the chosen row unstable |
| Rolling metric | one row per calendar date | missing dates shorten the window |
| Sessionization | one row per session | fixed clock buckets split or merge the wrong events |
| Data-quality profile | one summary row | NULL silently disappears from an aggregate |
Write the grain beside the question. After every query stage, ask whether the rows are still at that grain, are temporarily more detailed, or have already been collapsed too far.
Conditional metrics and cohort retention
Click-through rate is one row per ad. Clicks and impressions live in the same event column, so conditional aggregates create both measures from the same grouped population.
Input: ad_events
| event_id | ad_id | event_type | event_date |
|---|---|---|---|
| 1 | 101 | impression | 2026-08-01 |
| 2 | 101 | impression | 2026-08-01 |
| 3 | 101 | click | 2026-08-01 |
| 4 | 102 | impression | 2026-08-01 |
| 5 | 102 | impression | 2026-08-01 |
| 6 | 102 | impression | 2026-08-02 |
| 7 | 102 | click | 2026-08-02 |
| 8 | 103 | impression | 2026-08-02 |
SELECT
ad_id,
COUNT(*) FILTER (WHERE event_type = 'impression') AS impressions,
COUNT(*) FILTER (WHERE event_type = 'click') AS clicks,
ROUND(
COUNT(*) FILTER (WHERE event_type = 'click')::numeric
/ NULLIF(COUNT(*) FILTER (WHERE event_type = 'impression'), 0),
3
) AS ctr
FROM ad_events
GROUP BY ad_id
ORDER BY ad_id;
NULLIF protects a zero denominator.Output
| ad_id | impressions | clicks | ctr |
|---|---|---|---|
| 101 | 2 | 1 | 0.500 |
| 102 | 3 | 1 | 0.333 |
| 103 | 1 | 0 | 0.000 |
Retention needs a stricter contract. Here, W1 means at least one login from day 1 through day 7 after signup. Signup-day activity does not count. A cohort is reported only when its entire W1 window is on or before the last complete date, 2026-07-27.
Input: users
| user_id | signup_date |
|---|---|
| 1 | 2026-07-06 |
| 2 | 2026-07-06 |
| 3 | 2026-07-06 |
| 4 | 2026-07-13 |
| 5 | 2026-07-13 |
| 6 | 2026-07-20 |
Input: logins
| user_id | login_date |
|---|---|
| 1 | 2026-07-06 |
| 1 | 2026-07-10 |
| 2 | 2026-07-15 |
| 3 | 2026-07-08 |
| 4 | 2026-07-13 |
| 4 | 2026-07-19 |
| 5 | 2026-07-14 |
| 6 | 2026-07-20 |
WITH params AS (
SELECT DATE '2026-07-27' AS last_complete_date
),
eligible_users AS (
SELECT u.user_id, u.signup_date
FROM users AS u
CROSS JOIN params AS p
WHERE u.signup_date + 7 <= p.last_complete_date
)
SELECT
u.signup_date AS cohort_date,
COUNT(DISTINCT u.user_id) AS cohort_size,
COUNT(DISTINCT l.user_id) AS retained_users,
ROUND(
100.0 * COUNT(DISTINCT l.user_id)
/ COUNT(DISTINCT u.user_id),
1
) AS retention_pct
FROM eligible_users AS u
LEFT JOIN logins AS l
ON l.user_id = u.user_id
AND l.login_date >= u.signup_date + 1
AND l.login_date <= u.signup_date + 7
GROUP BY u.signup_date
ORDER BY u.signup_date;
Output
| cohort_date | cohort_size | retained_users | retention_pct |
|---|---|---|---|
| 2026-07-06 | 3 | 2 | 66.7 |
| 2026-07-13 | 2 | 2 | 100.0 |
| 2026-07-20 | 1 | 0 | 0.0 |
The zero-retention cohort stays visible because users are the left-side population. For more on counts after outer joins, see SQL COUNT.
One row per entity and rolling metrics
When a fact table has several rows per entity, rank first and filter in a second query block. The tie-breaker makes the choice deterministic when timestamps match.
Input: device_logins
| login_id | user_id | device_id | login_at |
|---|---|---|---|
| 1 | 7 | d01 | 2026-08-01 08:00 |
| 2 | 7 | d01 | 2026-08-01 08:00 |
| 3 | 7 | d01 | 2026-08-02 09:00 |
| 4 | 7 | d02 | 2026-08-03 10:00 |
| 5 | 8 | d03 | 2026-08-01 11:00 |
| 6 | 8 | d03 | 2026-08-04 12:00 |
WITH ranked AS (
SELECT
login_id,
user_id,
device_id,
login_at,
ROW_NUMBER() OVER (
PARTITION BY user_id, device_id
ORDER BY login_at, login_id
) AS row_num
FROM device_logins
)
SELECT
user_id,
device_id,
login_id,
TO_CHAR(login_at, 'YYYY-MM-DD HH24:MI') AS first_login_at
FROM ranked
WHERE row_num = 1
ORDER BY user_id, device_id;
Output
| user_id | device_id | login_id | first_login_at |
|---|---|---|---|
| 7 | d01 | 1 | 2026-08-01 08:00 |
| 7 | d02 | 4 | 2026-08-03 10:00 |
| 8 | d03 | 5 | 2026-08-01 11:00 |
See the window-functions guide for ranking and frame variations.
A three-day rolling average should represent three calendar dates, not the last three stored rows. A calendar spine makes the missing August 3 explicit as zero orders.
Input: daily_orders
| order_date | orders |
|---|---|
| 2026-08-01 | 40 |
| 2026-08-02 | 44 |
| 2026-08-04 | 60 |
| 2026-08-05 | 52 |
WITH bounds AS (
SELECT MIN(order_date) AS first_date, MAX(order_date) AS last_date
FROM daily_orders
),
calendar AS (
SELECT day::date AS order_date
FROM bounds
CROSS JOIN LATERAL GENERATE_SERIES(
first_date,
last_date,
INTERVAL '1 day'
) AS day
),
series AS (
SELECT c.order_date, COALESCE(d.orders, 0) AS orders
FROM calendar AS c
LEFT JOIN daily_orders AS d USING (order_date)
)
SELECT
order_date,
orders,
ROUND(
AVG(orders) OVER (
ORDER BY order_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
),
1
) AS rolling_3d_avg
FROM series
ORDER BY order_date;
Output
| order_date | orders | rolling_3d_avg |
|---|---|---|
| 2026-08-01 | 40 | 40.0 |
| 2026-08-02 | 44 | 42.0 |
| 2026-08-03 | 0 | 28.0 |
| 2026-08-04 | 60 | 34.7 |
| 2026-08-05 | 52 | 37.3 |
Sessionization and data-quality profiling
Sessions are defined here by a gap rule: a new session starts when a user's event is more than 30 minutes after their previous event. LAG exposes the prior timestamp; a running sum turns each start flag into a session number.
Input: events
| event_id | user_id | event_ts |
|---|---|---|
| 1 | 1 | 2026-08-01 09:00 |
| 2 | 1 | 2026-08-01 09:10 |
| 3 | 1 | 2026-08-01 09:45 |
| 4 | 1 | 2026-08-01 10:00 |
| 5 | 2 | 2026-08-01 12:00 |
| 6 | 2 | 2026-08-01 12:40 |
WITH ordered AS (
SELECT
event_id,
user_id,
event_ts,
LAG(event_ts) OVER (
PARTITION BY user_id
ORDER BY event_ts, event_id
) AS previous_event_ts
FROM events
),
marked AS (
SELECT
*,
CASE
WHEN previous_event_ts IS NULL
OR event_ts > previous_event_ts + INTERVAL '30 minutes'
THEN 1 ELSE 0
END AS new_session
FROM ordered
),
assigned AS (
SELECT
*,
SUM(new_session) OVER (
PARTITION BY user_id
ORDER BY event_ts, event_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_num
FROM marked
)
SELECT
user_id,
session_num,
TO_CHAR(MIN(event_ts), 'YYYY-MM-DD HH24:MI') AS session_start,
TO_CHAR(MAX(event_ts), 'YYYY-MM-DD HH24:MI') AS session_end,
COUNT(*) AS event_count
FROM assigned
GROUP BY user_id, session_num
ORDER BY user_id, session_num;
Output
| user_id | session_num | session_start | session_end | event_count |
|---|---|---|---|---|
| 1 | 1 | 2026-08-01 09:00 | 2026-08-01 09:10 | 2 |
| 1 | 2 | 2026-08-01 09:45 | 2026-08-01 10:00 | 2 |
| 2 | 1 | 2026-08-01 12:00 | 2026-08-01 12:00 | 1 |
| 2 | 2 | 2026-08-01 12:40 | 2026-08-01 12:40 | 1 |
This is a gaps-and-islands problem; the gaps-and-islands guide develops related grouping techniques.
Finally, profiling should make missingness visible. COUNT(column) counts only known values, while COUNT(*) counts rows. PostgreSQL's IS DISTINCT FROM is useful when a NULL status should be treated as not equal to cancelled.
Input: deliveries
| order_id | status | minutes_to_deliver |
|---|---|---|
| 201 | delivered | 30 |
| 202 | delivered | 35 |
| 203 | cancelled | NULL |
| 204 | NULL | 40 |
| 205 | pending | NULL |
SELECT
COUNT(*) AS all_orders,
COUNT(minutes_to_deliver) AS known_delivery_times,
ROUND(AVG(minutes_to_deliver), 1) AS avg_known_minutes,
COUNT(*) FILTER (WHERE status IS NULL) AS missing_statuses,
COUNT(*) FILTER (
WHERE status IS DISTINCT FROM 'cancelled'
) AS not_cancelled_including_unknown
FROM deliveries;
Output
| all_orders | known_delivery_times | avg_known_minutes | missing_statuses | not_cancelled_including_unknown |
|---|---|---|---|---|
| 5 | 3 | 35.0 | 1 | 4 |
A practical analyst workflow
For a new analysis, write five lines before the query:
- Population: which entities must appear, including zero-activity entities.
- Grain: what one output row represents.
- Metric: the exact numerator, denominator, and NULL policy.
- Time: timezone, interval boundaries, maturity rule, and missing-date policy.
- Determinism: tie-breakers and final output order.
Then build the query in stages and inspect the row count and key uniqueness after each stage. A query can run successfully while changing the population or grain. Small edge fixtures make that visible. Add a zero-activity entity, a duplicate timestamp, a missing date, a NULL, and an immature cohort before trusting the result. The SQL practice guide provides more exercises that reward this kind of explicit reasoning.
FAQ
Should I start a query with joins or with the final metric?
Start with the population and output grain. Choose the table that owns that population, then join the detail needed for the metric. This prevents a convenient fact table from silently excluding entities with no activity.
When should I use a window instead of GROUP BY?
Use GROUP BY when the output should collapse to one row per group. Use a window when each current row should remain visible while receiving a group-level or ordered calculation. Many analyses aggregate in a CTE and then window over that new grain.
Why include tiny tables in an analysis guide?
They make logic auditable. You can predict every result, see which edge case changes the answer, and move the same test into a larger pipeline. The point is not the volume of data; it is whether the query preserves the intended contract.
Are these patterns specific to PostgreSQL?
The analytical ideas transfer, but syntax varies. These queries target PostgreSQL 16. Check date-series, filtered-aggregate, interval, and NULL-safe comparison syntax before moving them to another engine.
Related Articles
Coderbyte SQL Assessment Guide: Query Types, Timing, and What Employers See
Learn Coderbyte SQL assessment query types, timing, grading, employer reports, common mistakes, and a practical seven-day preparation plan for candidates.
Capital One Data Analyst Internship 2027: VJT, Power Day, and Why There May Be No CodeSignal
Capital One Data Analyst Internship 2027 guide: VJT, Power Day cases, behavioral interviews, SQL prep, timelines, and why CodeSignal may be skipped.
SQL String Functions: SUBSTRING, SPLIT_PART, CONCAT, and LIKE in Interviews
Use PostgreSQL string functions for normalization, SUBSTRING and SPLIT_PART parsing, NULL-safe labels, ordered lists, LIKE, and row splitting.
SQL ORDER BY: Ascending, Descending, Multi-Column Sorting, and Where NULLs Land
Use PostgreSQL ORDER BY for deterministic multi-column sorting, explicit NULL placement, top N, keyset pagination, ties, and windows.
Comments (0)