Analytics SQL skill for Claude Code
Stated grain, no fan-out, numbers that reconcile. Claude writes the metric definition first, comments the grain of every CTE, and checks row counts after each join so duplicate keys cannot inflate totals. It writes explicit window frames, zero-fills missing days with a date spine, adapts date functions to Postgres, Snowflake, BigQuery or DuckDB, and reconciles the result with a direct total and one hand-checked entity.
- name
analytics-sql - size171 lines · ≈1,800 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes correct analytical SQL — CTE pipelines with a stated grain, window functions with explicit frames, deduplication with ROW_NUMBER or QUALIFY, date spines for gap-free time series, cohort retention, conversion funnels, sessionization, and growth metrics — across Postgres, Snowflake, BigQuery, DuckDB, and Redshift dialects, with fan-out and reconciliation checks. Use when writing reporting or metrics queries, computing retention, cohorts, funnels, or running totals, or when a query's numbers look too high or too low. Try asking
- Write a monthly cohort retention query for our events table in Snowflake
- Build a signup to purchase funnel with a 7-day window in BigQuery
- This revenue query returns double the expected amount. Find the problem
Use it when
- You are writing reporting, KPI, or metrics queries
- You need retention, cohorts, funnels, sessions, or running totals
- Query results look inflated, deflated, or have missing days
- A query must work in a specific warehouse dialect
Skip it when
- The query is correct but slow — use sql-query-tuning instead
- You are building reusable warehouse models — use dbt-models instead
How to check the result
Row counts are checked after each join, the final total matches a direct query on the cleaned source, and one entity's metric computed by hand matches the query output.
The common mistake it prevents
Joining orders to a table with several rows per order before aggregating, then hiding the inflated totals with count distinct while sums stay wrong.
An analytical query is correct when every CTE has a known grain, joins cannot fan out unnoticed, metric definitions (numerator, denominator, time zone, exclusions) are written down, missing periods show as zero instead of disappearing, and the headline number reconciles with an independent source.
Ground rules
- Write the grain of each CTE as a comment:
-- one row per user per day. - Define the metric before writing SQL: what counts, what is excluded (test accounts, refunds, internal traffic), which time zone, which date (event vs. processed).
- Aggregate before joining when the join key is not unique on one side.
- Ratios: sum numerators and denominators separately, divide at the end; never average per-group ratios unless that is the stated metric.
- Use
count(distinct ...)deliberately; it is the most common silent inflation fix and also the most common mask for a bad join. - Confirm the dialect before writing date functions.
- Read-only: analytics queries never modify data. Run heavy queries on a sample or a narrow date range first; warehouse queries cost money.
Workflow
- [ ] 1. Write the metric definition in plain words
- [ ] 2. Inspect source tables: grain, keys, nulls, time zone of timestamps
- [ ] 3. Build CTEs step by step, checking row counts at each
- [ ] 4. Add date spine / zero-fill where periods may be missing
- [ ] 5. Reconcile totals and spot-check one entity by hand
- [ ] 6. Format result + definition + caveats
2. Inspect sources
select count(*) as rows, count(distinct order_id) as orders,
min(created_at), max(created_at),
sum(case when customer_id is null then 1 else 0 end) as null_customers
from orders;
If rows != orders, the table is not one row per order; find out why before joining.
3. CTE pipeline shape
with orders_clean as ( -- one row per order
select order_id, customer_id, amount,
date_trunc('day', created_at at time zone 'UTC') as order_date
from orders
where status <> 'test' and amount > 0
),
first_order as ( -- one row per customer
select customer_id, min(order_date) as first_order_date
from orders_clean
group by customer_id
),
daily as ( -- one row per day
select o.order_date,
count(*) as orders,
sum(o.amount) as revenue,
count(distinct case when o.order_date = f.first_order_date then o.customer_id end) as new_customers
from orders_clean o
join first_order f using (customer_id)
group by o.order_date
)
select * from daily order by order_date;
Fan-out check after any join: select count(*) of the join result must equal the
row count of the side whose grain you intend to keep.
Window functions
| Need | Pattern |
|---|---|
| Latest row per key (dedupe) | row_number() over (partition by id order by updated_at desc) = 1 |
| Previous value / gap | lag(x) over (partition by user_id order by ts) |
| Running total | sum(x) over (order by d rows between unbounded preceding and current row) |
| 7-day moving average (dense days) | avg(x) over (order by d rows between 6 preceding and current row) |
| Share of total | x / sum(x) over () |
| Rank within group | dense_rank() over (partition by g order by x desc) |
| Percentiles | percentile_cont(0.5) within group (order by x) (Postgres/Snowflake); approx_quantiles (BigQuery) |
Always write the frame clause for running/moving calculations. The default frame with
order by is range between unbounded preceding and current row, which groups ties
and gives surprising results on duplicate dates.
Filter on window results with qualify where supported (Snowflake, BigQuery,
DuckDB, Databricks); elsewhere wrap in a subquery:
select * from (
select *, row_number() over (partition by customer_id order by updated_at desc) as rn
from customers_raw
) t where rn = 1;
4. Date spine (zero-fill)
Days with no events vanish from group by results, which breaks moving averages and
charts.
-- Postgres
with days as (
select generate_series(date '2026-09-01', date '2026-09-30', interval '1 day')::date as d
)
select days.d, coalesce(daily.revenue, 0) as revenue
from days left join daily on daily.order_date = days.d
order by days.d;
BigQuery: unnest(generate_date_array('2026-09-01', '2026-09-30')); Snowflake: a
generator with dateadd or a calendar table; DuckDB: generate_series / range on dates.
Dialect quick reference
| Operation | Postgres | Snowflake | BigQuery | DuckDB |
|---|---|---|---|---|
| Truncate to month | date_trunc('month', ts) | date_trunc('month', ts) | date_trunc(ts, month) / timestamp_trunc | date_trunc('month', ts) |
| Difference in days | d2 - d1 (dates) | datediff('day', d1, d2) | date_diff(d2, d1, day) | date_diff('day', d1, d2) |
| Add interval | ts + interval '7 days' | dateadd('day', 7, ts) | timestamp_add(ts, interval 7 day) | ts + interval 7 day |
| Convert time zone | ts at time zone 'Europe/Berlin' | convert_timezone('Europe/Berlin', ts) | datetime(ts, 'Europe/Berlin') | timezone('Europe/Berlin', ts) |
| Safe divide | x / nullif(y, 0) | div0(x, y) or nullif | safe_divide(x, y) | x / nullif(y, 0) |
| Integer division trap | 1/2 = 0 | decimal | float | decimal (// for integer) |
Common analyses
For complete, tested templates of cohort retention, funnels with ordered steps, sessionization, gaps-and-islands, month-over-month growth, first/last-touch attribution, and active-user (DAU/WAU/MAU) counts, read references/query-patterns.md.
5. Reconcile
- Total of the final result equals the total from a simple direct query on the clean CTE.
- Pick one customer/day and compute the metric by hand from raw rows.
- Compare one headline number to an existing dashboard or finance figure; explain gaps (time zone, refunds, test accounts, late data).
Gotchas
count(col)skips nulls;count(*)does not.avgignores nulls (which may or may not be the definition you want).whereon the right table of aleft jointurns it into an inner join; put the condition in theonclause.betweenon timestamps includes the end instant; use>= start and < end.- Timestamps stored in UTC but reported by local day shift revenue across midnight; convert before truncating.
not in (subquery)returns nothing if the subquery has a null; usenot exists.- Integer division in Postgres truncates (
1/2 = 0); cast to numeric. - Distinct counts are not additive: weekly actives are not the sum of daily actives.
- Late-arriving data makes the last 1–2 days look low; flag incomplete periods.
Output
**Metric:** Monthly revenue, net of refunds, UTC calendar months, excluding test accounts
**Grain of result:** one row per month (Jan–Sep 2026)
**Query:** (SQL with grain comments)
**Checks:** sum matches direct total (4,812,331.20); Sept within 0.3% of finance report
(difference: 3 refunds booked in October)
**Caveats:** September 30 data incomplete until 06:00 UTC load Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/query-patterns.md
# Analytical query patterns
Read this when the task is one of the analyses below. SQL is written for Postgres
unless noted; swap date functions with the dialect table in SKILL.md. Every pattern
lists its output grain.
## Cohort retention (monthly)
Output: one row per cohort month per months-since-first-activity.
```sql
with activity as ( -- one row per user per active month
select distinct user_id, date_trunc('month', event_at)::date as month
from events
where event_name in ('login', 'purchase')
),
cohorts as ( -- one row per user
select user_id, min(month) as cohort_month
from activity
group by user_id
),
cohort_size as ( -- one row per cohort
select cohort_month, count(*) as users
from cohorts
group by cohort_month
),
retention as ( -- one row per cohort per offset
select c.cohort_month,
(extract(year from age(a.month, c.cohort_month)) * 12
+ extract(month from age(a.month, c.cohort_month)))::int as month_offset,
count(distinct a.user_id) as active_users
from activity a
join cohorts c using (user_id)
group by 1, 2
)
select r.cohort_month, r.month_offset, s.users as cohort_users, r.active_users,
round(100.0 * r.active_users / s.users, 1) as retention_pct
from retention r
join cohort_size s using (cohort_month)
order by 1, 2;
```
Caveats: the newest cohorts have fewer observable offsets; do not average incomplete
offsets into "average retention".
## Funnel with ordered steps and a time limit
Output: one row per step. Counts users who completed each step after the previous one
within 7 days of entering.
```sql
with step1 as (
select user_id, min(event_at) as t1
from events where event_name = 'view_product'
and event_at >= '2026-09-01' and event_at < '2026-10-01'
group by user_id
),
step2 as (
select s.user_id, s.t1, min(e.event_at) as t2
from step1 s
join events e on e.user_id = s.user_id and e.event_name = 'add_to_cart'
and e.event_at >= s.t1 and e.event_at < s.t1 + interval '7 days'
group by s.user_id, s.t1
),
step3 as (
select s.user_id, min(e.event_at) as t3
from step2 s
join events e on e.user_id = s.user_id and e.event_name = 'purchase'
and e.event_at >= s.t2 and e.event_at < s.t1 + interval '7 days'
group by s.user_id
)
select 'view_product' as step, 1 as step_no, count(*) as users from step1
union all select 'add_to_cart', 2, count(*) from step2
union all select 'purchase', 3, count(*) from step3
order by step_no;
```
Add conversion: `users / first_value(users) over (order by step_no)` and step-over-step
`users / lag(users) over (order by step_no)` (cast to numeric).
## Sessionization (30-minute inactivity gap)
Output: one row per session.
```sql
with ordered as (
select user_id, event_at,
case when event_at - lag(event_at) over (partition by user_id order by event_at)
> interval '30 minutes'
or lag(event_at) over (partition by user_id order by event_at) is null
then 1 else 0 end as is_new_session
from events
),
numbered as (
select *, sum(is_new_session) over (partition by user_id order by event_at
rows between unbounded preceding and current row) as session_no
from ordered
)
select user_id, session_no, min(event_at) as session_start, max(event_at) as session_end,
count(*) as events, max(event_at) - min(event_at) as duration
from numbered
group by user_id, session_no;
```
## Gaps and islands (consecutive active days / streaks)
Output: one row per streak.
```sql
with days as (
select distinct user_id, event_at::date as d from events
),
grp as (
select user_id, d,
d - (row_number() over (partition by user_id order by d))::int as island
from days
)
select user_id, min(d) as streak_start, max(d) as streak_end, count(*) as streak_days
from grp
group by user_id, island
order by streak_days desc;
```
## Month-over-month growth
Output: one row per month.
```sql
with monthly as (
select date_trunc('month', created_at)::date as month, sum(amount) as revenue
from orders
group by 1
)
select month, revenue,
lag(revenue) over (order by month) as prev_revenue,
round(100.0 * (revenue - lag(revenue) over (order by month))
/ nullif(lag(revenue) over (order by month), 0), 1) as mom_pct
from monthly
order by month;
```
Use a month spine if any month can have zero revenue, otherwise `lag` compares against
the wrong month.
## DAU / WAU / MAU with rolling windows
Output: one row per day. Distinct counts over rolling windows need a self-join or
per-day user sets; summing DAU is wrong.
```sql
with active as (
select distinct user_id, event_at::date as d from events
),
days as (
select generate_series(date '2026-09-01', date '2026-09-30', interval '1 day')::date as d
)
select days.d,
count(distinct a.user_id) filter (where a.d = days.d) as dau,
count(distinct a.user_id) filter (where a.d > days.d - 7) as wau,
count(distinct a.user_id) as mau
from days
join active a on a.d > days.d - 28 and a.d <= days.d
group by days.d
order by days.d;
```
(`filter (where ...)` is Postgres/DuckDB; elsewhere use `count(distinct case when ... then user_id end)`.)
On very large tables use HyperLogLog approximations (`approx_count_distinct`,
`hll_*` functions) and say so.
## First-touch and last-touch attribution
Output: one row per channel.
```sql
with touches as (
select t.user_id, t.channel, t.touched_at, c.converted_at,
row_number() over (partition by t.user_id order by t.touched_at asc) as first_rank,
row_number() over (partition by t.user_id order by t.touched_at desc) as last_rank
from touchpoints t
join conversions c on c.user_id = t.user_id and t.touched_at <= c.converted_at
and t.touched_at > c.converted_at - interval '30 days'
)
select channel,
count(*) filter (where first_rank = 1) as first_touch_conversions,
count(*) filter (where last_rank = 1) as last_touch_conversions
from touches
group by channel
order by last_touch_conversions desc;
```
Assumes one conversion per user; partition by conversion ID otherwise.
## Top N per group
```sql
select * from (
select category, product_id, revenue,
row_number() over (partition by category order by revenue desc) as rn
from product_revenue
) t
where rn <= 3;
```
Use `rank()` instead of `row_number()` if ties should all be included.
## Median and percentiles per group
```sql
-- Postgres / Snowflake / DuckDB
select region,
percentile_cont(0.5) within group (order by amount) as median_amount,
percentile_cont(0.95) within group (order by amount) as p95_amount
from orders
group by region;
-- BigQuery: approx_quantiles(amount, 100)[offset(50)]
```
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.