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

Learn six practical PostgreSQL patterns for analyst work: conditional metrics, retention, deduplication, rolling averages, sessions, and data quality.

Author: PracHub

Published: 8/14/2026

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

August 14, 2026
20 min read
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.

Data AnalystFree

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 shapeIntended output grainMain risk
Conditional metricone row per reporting keynumerator and denominator use different populations
Retentionone row per mature cohortsignup-day activity or immature cohorts enter the metric
First or latest rowone row per entityties make the chosen row unstable
Rolling metricone row per calendar datemissing dates shorten the window
Sessionizationone row per sessionfixed clock buckets split or merge the wrong events
Data-quality profileone summary rowNULL 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_idad_idevent_typeevent_date
1101impression2026-08-01
2101impression2026-08-01
3101click2026-08-01
4102impression2026-08-01
5102impression2026-08-01
6102impression2026-08-02
7102click2026-08-02
8103impression2026-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;
Row flow for click-through rate by ad Eight event rows are grouped into three ads, conditionally counted as impressions and clicks, and divided to produce one rate per ad. 8 event rowsthree ad IDsGROUP BY adtwo filtered counts3 ad rowsclicks / impressions
The two conditional counts share the same ad group; NULLIF protects a zero denominator.

Output

ad_idimpressionsclicksctr
101210.500
102310.333
103100.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_idsignup_date
12026-07-06
22026-07-06
32026-07-06
42026-07-13
52026-07-13
62026-07-20

Input: logins

user_idlogin_date
12026-07-06
12026-07-10
22026-07-15
32026-07-08
42026-07-13
42026-07-19
52026-07-14
62026-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;
Row flow for bounded week-one retention Six users are checked for mature seven-day windows, matched only to day-one-through-day-seven logins, and grouped into three cohort rows. 6 signup rowsall W1 windows matureMatch days 1 to 7exclude signup day3 cohort rowsretained / cohort size
The date predicates define both eligibility and activity before the cohort ratio is calculated.

Output

cohort_datecohort_sizeretained_usersretention_pct
2026-07-063266.7
2026-07-1322100.0
2026-07-20100.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_iduser_iddevice_idlogin_at
17d012026-08-01 08:00
27d012026-08-01 08:00
37d012026-08-02 09:00
47d022026-08-03 10:00
58d032026-08-01 11:00
68d032026-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;
Row flow for the first login per user and device Six login rows are partitioned by user and device, ordered by timestamp and login ID, and filtered to three first-login rows. 6 login rowsthree entity keysROW_NUMBERtimestamp, then ID3 first rowsone per entity key
The second ordering key resolves the duplicate timestamp for user 7 on device d01.

Output

user_iddevice_idlogin_idfirst_login_at
7d0112026-08-01 08:00
7d0242026-08-03 10:00
8d0352026-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_dateorders
2026-08-0140
2026-08-0244
2026-08-0460
2026-08-0552
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;
Row flow for a calendar-day rolling average Four stored daily rows expand to five calendar dates, the missing date receives zero orders, and a three-row frame computes five rolling averages. 4 stored datesAugust 3 missingCalendar spine5 dates, zero filled3-date frame5 rolling results
A row-based window means calendar days only after the calendar spine restores missing dates.

Output

order_dateordersrolling_3d_avg
2026-08-014040.0
2026-08-024442.0
2026-08-03028.0
2026-08-046034.7
2026-08-055237.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_iduser_idevent_ts
112026-08-01 09:00
212026-08-01 09:10
312026-08-01 09:45
412026-08-01 10:00
522026-08-01 12:00
622026-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;
Row flow for sessionizing events by inactivity gap Six user events are ordered, marked when the preceding gap exceeds thirty minutes, assigned running session numbers, and grouped into four sessions. 6 timed eventstwo usersGap and running sumstart after 30 minutes4 session rowsstart, end, count
The gap is measured from the previous event, so sessions follow activity rather than fixed clock buckets.

Output

user_idsession_numsession_startsession_endevent_count
112026-08-01 09:002026-08-01 09:102
122026-08-01 09:452026-08-01 10:002
212026-08-01 12:002026-08-01 12:001
222026-08-01 12:402026-08-01 12:401

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_idstatusminutes_to_deliver
201delivered30
202delivered35
203cancelledNULL
204NULL40
205pendingNULL
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;
Row flow for a delivery data-quality profile Five delivery rows are counted as a whole, inspected for known delivery times and missing statuses, and collapsed into one audit row. 5 delivery rowsNULLs in two columnsNULL-aware countsaverage known values1 profile rowvolume plus completeness
The summary reports both the metric and how much source data was available to compute it.

Output

all_ordersknown_delivery_timesavg_known_minutesmissing_statusesnot_cancelled_including_unknown
5335.014

A practical analyst workflow

For a new analysis, write five lines before the query:

  1. Population: which entities must appear, including zero-activity entities.
  2. Grain: what one output row represents.
  3. Metric: the exact numerator, denominator, and NULL policy.
  4. Time: timezone, interval boundaries, maturity rule, and missing-date policy.
  5. 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.


Comments (0)