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.

  • nameanalytics-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.

~/.claude/skills/analytics-sql/SKILL.md

--- name: analytics-sql description: Writes correct analytical SQL — CTE pipelines with a stated grain, window functions with explicit frames, deduplication with ROW_NUMBER or… license: MIT ---

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

NeedPattern
Latest row per key (dedupe)row_number() over (partition by id order by updated_at desc) = 1
Previous value / gaplag(x) over (partition by user_id order by ts)
Running totalsum(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 totalx / sum(x) over ()
Rank within groupdense_rank() over (partition by g order by x desc)
Percentilespercentile_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

OperationPostgresSnowflakeBigQueryDuckDB
Truncate to monthdate_trunc('month', ts)date_trunc('month', ts)date_trunc(ts, month) / timestamp_truncdate_trunc('month', ts)
Difference in daysd2 - d1 (dates)datediff('day', d1, d2)date_diff(d2, d1, day)date_diff('day', d1, d2)
Add intervalts + interval '7 days'dateadd('day', 7, ts)timestamp_add(ts, interval 7 day)ts + interval 7 day
Convert time zonets at time zone 'Europe/Berlin'convert_timezone('Europe/Berlin', ts)datetime(ts, 'Europe/Berlin')timezone('Europe/Berlin', ts)
Safe dividex / nullif(y, 0)div0(x, y) or nullifsafe_divide(x, y)x / nullif(y, 0)
Integer division trap1/2 = 0decimalfloatdecimal (// 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. avg ignores nulls (which may or may not be the definition you want).
  • where on the right table of a left join turns it into an inner join; put the condition in the on clause.
  • between on 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; use not 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 217 lines · 6.8 KB
# 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.

esc