Modular SQL with Layered CTEs
When to use this skill
Use when transforming a working-but-unreadable SQL query into a maintainable, testable model. Triggers:
- "Refactor this SQL"
- "Make this query more readable"
- "Build a dbt model for…"
- "Modularize this query"
- "This SQL is hard to follow"
Don't use for one-off exploration queries that won't be reused. Refactoring exploratory code is over-engineering.
Required inputs
| Input |
Why it matters |
| Current SQL |
The query to refactor |
| Target grain |
What one row in the final output represents |
| Source tables |
Where the data is coming from |
| Warehouse |
Snowflake / BigQuery / Postgres / Redshift |
| dbt or raw SQL |
Affects model layout |
Workflow
Restate the grain in plain English. "Each row is one user-day" or "one order" or "one experiment-variant-day." If you can't say this clearly, the query has a grain bug.
Identify the layers. Every analytics query has 3 logical layers:
- Staging (
stg_): rename columns, cast types, light filters. One staging CTE per source table.
- Intermediate (
int_): business logic — joins, derived columns, aggregations to intermediate grain.
- Fact / Final (
fct_/dim_ or final select): the output at target grain, with only the columns consumers need.
One purpose per CTE. If a CTE name needs "and" ("users_and_orders_and_revenue"), split it.
Filter early. Apply WHERE clauses in staging where possible to reduce data scanned downstream.
Use explicit JOIN types — inner join, left join, full join. Never bare join.
No SELECT * in production layers. Only allowed in staging if every source column is intentionally used. List columns explicitly in int_ and fct_.
Add inline comments ONLY for non-obvious business rules. Don't narrate what the SQL does.
Add a final-select preamble. A 1-line comment block at the top of the file stating: grain, primary key, source tables, and refresh cadence.
Output format
-- =========================================================
-- Model: fct_user_daily_activity
-- Grain: one row per (user_id, activity_date) in UTC
-- Primary key: (user_id, activity_date)
-- Sources:
-- raw.events.product_events
-- raw.users.dim_user
-- Refresh: daily at 06:00 UTC
-- Owner: @sarah.kim
-- =========================================================
with stg_events as (
select
cast(user_id as varchar) as user_id,
lower(event_name) as event_name,
cast(event_at as timestamp_ntz) as event_ts,
cast(event_at as date) as event_date
from raw.events.product_events
where event_at >= '2024-01-01'
and user_id is not null
),
stg_users as (
select
cast(user_id as varchar) as user_id,
country,
plan_tier,
signup_at
from raw.users.dim_user
where is_internal_user = false
),
int_events_enriched as (
select
e.user_id,
e.event_ts,
e.event_date,
e.event_name,
case when e.event_name = 'purchase' then 1 else 0 end as is_purchase,
case when e.event_name = 'session_start' then 1 else 0 end as is_session
from stg_events e
),
int_user_daily as (
select
ee.user_id,
ee.event_date,
count(*) as total_events,
sum(ee.is_purchase) as purchase_count,
sum(ee.is_session) as session_count
from int_events_enriched ee
group by ee.user_id, ee.event_date
),
fct_user_daily_activity as (
select
ud.user_id,
ud.event_date as activity_date,
u.country,
u.plan_tier,
ud.total_events,
ud.purchase_count,
ud.session_count,
case when ud.purchase_count > 0 then 1 else 0 end as did_purchase
from int_user_daily ud
inner join stg_users u using (user_id)
)
select * from fct_user_daily_activity;
Validation checks
Edge cases & failure modes
- Snowflake views vs tables: views recompute every time; for expensive logic, materialize as table or dynamic table.
- Joins changing grain: joining a
1:N table without aggregating first inflates row counts. Add qualify row_number() over ... = 1 or aggregate before joining.
- Window functions over too-large partitions: Snowflake spills to disk. Pre-aggregate, then window.
- Date filter in WHERE vs ON: filtering an outer-joined table's date in
WHERE converts the join to inner. Put it in ON instead.
dbt-specific notes
- One model per file, named after the final table
- Source declarations in
sources.yml
- Column-level docs in
<model>.yml
- Tests: at minimum
unique and not_null on primary key columns
- Use
{{ ref('stg_users') }} for cross-model dependencies
- Materialize
stg_ as views, int_ and fct_ as tables
Cross-warehouse notes
| Snowflake |
BigQuery |
Postgres |
date_trunc('week', d) |
date_trunc(d, week) |
date_trunc('week', d) |
datediff('day', a, b) |
date_diff(b, a, day) |
(b - a) |
dateadd('day', n, d) |
date_add(d, interval n day) |
d + interval 'n day' |
qualify |
qualify |
use subquery |
nullif(a, b) |
nullif(a, b) |
nullif(a, b) |
Related skills
warehouse-query-optimization — when the refactored query needs to be fast
data-quality-audit — verify the output meets grain/uniqueness expectations
metric-definition — wrap the final model in a metric spec
1---2name: modular-sql-ctes3description: Refactors SQL into staging, intermediate, and fact CTE layers with explicit grain and naming conventions. Use when the user asks to refactor a SQL query, clean up a model, build a dbt model, modularize a query, or mentions CTE structure, query readability, or "this SQL is hard to follow."4---56# Modular SQL with Layered CTEs78## When to use this skill910Use when transforming a working-but-unreadable SQL query into a maintainable, testable model. Triggers:1112- "Refactor this SQL"13- "Make this query more readable"14- "Build a dbt model for…"15- "Modularize this query"16- "This SQL is hard to follow"1718Don't use for one-off exploration queries that won't be reused. Refactoring exploratory code is over-engineering.1920## Required inputs2122| Input | Why it matters |23|---|---|24| Current SQL | The query to refactor |25| Target grain | What one row in the final output represents |26| Source tables | Where the data is coming from |27| Warehouse | Snowflake / BigQuery / Postgres / Redshift |28| dbt or raw SQL | Affects model layout |2930## Workflow31321. **Restate the grain in plain English.** "Each row is one *user-day*" or "one *order*" or "one *experiment-variant-day*." If you can't say this clearly, the query has a grain bug.33342. **Identify the layers.** Every analytics query has 3 logical layers:35 - **Staging (`stg_`)**: rename columns, cast types, light filters. One staging CTE per source table.36 - **Intermediate (`int_`)**: business logic — joins, derived columns, aggregations to intermediate grain.37 - **Fact / Final (`fct_`/`dim_` or final select)**: the output at target grain, with only the columns consumers need.38393. **One purpose per CTE.** If a CTE name needs "and" ("users_and_orders_and_revenue"), split it.40414. **Filter early.** Apply `WHERE` clauses in staging where possible to reduce data scanned downstream.42435. **Use explicit JOIN types** — `inner join`, `left join`, `full join`. Never bare `join`.44456. **No `SELECT *` in production layers.** Only allowed in staging if every source column is intentionally used. List columns explicitly in `int_` and `fct_`.46477. **Add inline comments** ONLY for non-obvious business rules. Don't narrate what the SQL does.48498. **Add a final-select preamble.** A 1-line comment block at the top of the file stating: grain, primary key, source tables, and refresh cadence.5051## Output format5253```sql54-- =========================================================55-- Model: fct_user_daily_activity56-- Grain: one row per (user_id, activity_date) in UTC57-- Primary key: (user_id, activity_date)58-- Sources:59-- raw.events.product_events60-- raw.users.dim_user61-- Refresh: daily at 06:00 UTC62-- Owner: @sarah.kim63-- =========================================================6465with stg_events as (66 select67 cast(user_id as varchar) as user_id,68 lower(event_name) as event_name,69 cast(event_at as timestamp_ntz) as event_ts,70 cast(event_at as date) as event_date71 from raw.events.product_events72 where event_at >= '2024-01-01'73 and user_id is not null74),7576stg_users as (77 select78 cast(user_id as varchar) as user_id,79 country,80 plan_tier,81 signup_at82 from raw.users.dim_user83 where is_internal_user = false84),8586int_events_enriched as (87 select88 e.user_id,89 e.event_ts,90 e.event_date,91 e.event_name,92 case when e.event_name = 'purchase' then 1 else 0 end as is_purchase,93 case when e.event_name = 'session_start' then 1 else 0 end as is_session94 from stg_events e95),9697int_user_daily as (98 select99 ee.user_id,100 ee.event_date,101 count(*) as total_events,102 sum(ee.is_purchase) as purchase_count,103 sum(ee.is_session) as session_count104 from int_events_enriched ee105 group by ee.user_id, ee.event_date106),107108fct_user_daily_activity as (109 select110 ud.user_id,111 ud.event_date as activity_date,112 u.country,113 u.plan_tier,114 ud.total_events,115 ud.purchase_count,116 ud.session_count,117 case when ud.purchase_count > 0 then 1 else 0 end as did_purchase118 from int_user_daily ud119 inner join stg_users u using (user_id)120)121122select * from fct_user_daily_activity;123```124125## Validation checks126127- [ ] Grain stated in header and matches actual output128- [ ] Primary key uniqueness can be verified (one row per stated key)129- [ ] No `select *` outside staging130- [ ] All joins have explicit type131- [ ] CTE names follow `stg_` / `int_` / `fct_` convention132- [ ] No CTE has both "and" in its name and >50 lines133- [ ] Recent partitions filtered early (no full-table scans without reason)134135## Edge cases & failure modes136137- **Snowflake views vs tables**: views recompute every time; for expensive logic, materialize as table or dynamic table.138- **Joins changing grain**: joining a `1:N` table without aggregating first inflates row counts. Add `qualify row_number() over ... = 1` or aggregate before joining.139- **Window functions over too-large partitions**: Snowflake spills to disk. Pre-aggregate, then window.140- **Date filter in WHERE vs ON**: filtering an outer-joined table's date in `WHERE` converts the join to inner. Put it in `ON` instead.141142## dbt-specific notes143144- One model per file, named after the final table145- Source declarations in `sources.yml`146- Column-level docs in `<model>.yml`147- Tests: at minimum `unique` and `not_null` on primary key columns148- Use `{{ ref('stg_users') }}` for cross-model dependencies149- Materialize `stg_` as views, `int_` and `fct_` as tables150151## Cross-warehouse notes152153| Snowflake | BigQuery | Postgres |154|---|---|---|155| `date_trunc('week', d)` | `date_trunc(d, week)` | `date_trunc('week', d)` |156| `datediff('day', a, b)` | `date_diff(b, a, day)` | `(b - a)` |157| `dateadd('day', n, d)` | `date_add(d, interval n day)` | `d + interval 'n day'` |158| `qualify` | `qualify` | use subquery |159| `nullif(a, b)` | `nullif(a, b)` | `nullif(a, b)` |160161## Related skills162163- `warehouse-query-optimization` — when the refactored query needs to be fast164- `data-quality-audit` — verify the output meets grain/uniqueness expectations165- `metric-definition` — wrap the final model in a metric spec