SQL Style
Two sections: General SQL rules (any dialect) and Snowflake-specific rules (apply when the target is Snowflake). Some general rules are marked (aspirational) — they pull new code toward the standard; legacy code is what it is.
Real-world reference: a well-sectioned feature-engineering model (numbered sections, source CTEs, progressive JOIN CTEs) is the canonical example of these conventions in action — especially the sectioning and progressive-JOIN patterns below.
Section 1 — General SQL rules
Layout & syntax
- Keywords UPPERCASE —
SELECT, FROM, WHERE, INNER JOIN, QUALIFY, etc. (needed for SQL syntax highlighting inside Python string literals).
- Identifiers lower_snake_case —
user_id, order_date. No . in names. No leading digits.
- Trailing commas, not leading.
- 4-space indent inside SELECT lists, CTE bodies, subqueries, and
ON continuation lines.
- Spaces around operators —
price > 100, a.id = b.user_id.
- Parentheses for complex logic — group conditions in WHERE / CASE for clarity.
- Single quotes for string literals —
'active', '2026-01-01'.
Aliases
- Always
AS for aliases — users AS usr, total AS order_total (aspirational).
- Descriptive initials —
usr, ord, prd, evt — never single letters (u, o, a).
- Cap at 3 letters; extend to 4 only when 3 would collide (e.g.,
payments vs payouts → pmt / pyt).
- Same length per CTE/block — vertically aligned
AS columns (aspirational).
- Same alias for the same table across the entire query, unless impossible (self-joins).
- No alias needed in single-table CTEs — no JOIN, no ambiguity, no alias.
-- Good
SELECT
usr.email,
ord.total
FROM users AS usr
JOIN orders AS ord
ON usr.id = ord.user_id
Clauses
- Clause order is a hard rule:
SELECT → FROM → JOIN → WHERE → GROUP BY → HAVING → QUALIFY → ORDER BY → LIMIT.
- SELECT layout: stacked. SELECT keyword at column 0, each column on its own line indented 4 spaces, trailing commas.
SELECT
col_a,
col_b,
col_c
FROM my_table
- Column grouping inside wide SELECTs — when a SELECT has 10+ columns, group them logically with
-- group title comment lines:
SELECT
-- Identifiers
event_id,
external_job_id,
-- Event attributes
event_code,
event_reason,
event_type,
-- Time features
DATE_TRUNC('hour', event_datetime) AS rounded_event_ts,
DAYOFWEEKISO(event_datetime) AS weekday_iso
CTEs
- No CTEs for very simple queries. If the query is a single-table SELECT with simple WHERE filtering, no joins, no aggregations, no window functions, no subqueries — write it directly without CTEs. The rest of the CTE rules below apply only when the query actually needs CTEs.
- CTE layout: name at column 0, body indented 4 spaces, blank line between CTEs.
WITH
active_users_cte AS (
SELECT
user_id,
email
FROM users
WHERE is_active = TRUE
),
recent_orders_cte AS (
SELECT
order_id,
user_id
FROM orders
WHERE order_date >= '2026-01-01'
)
- CTE naming: use the
_cte suffix — active_users_cte, simple_features_cte, etc.
- Source CTE pattern. Unless the query is very simple, the top of the query has dedicated "source CTEs" that:
- read from exactly one upstream table/asset (
{{ ref(...) }} or {{ source(...) }} in dbt)
- list all columns explicitly (no
SELECT * here)
- may filter rows as needed
- serve as the only source of truth — all downstream CTEs reference these, not the raw refs
SELECT *: OK in middle CTEs (e.g., passing through after a JOIN). Never in source CTEs. Never in the final SELECT.
Sectioning
For non-trivial queries, organize CTEs into numbered sections. Match this convention:
- A
---...--- dash separator (~126 dashes) above every section / sub-section
- A
-- N️⃣ Section title line — one line preferred; multi-line -- description allowed when needed
- Sub-numbers
N️⃣.M️⃣ when a section groups multiple related CTEs
- Use
🔟 for top-level section 10; use 1️⃣0️⃣ inside sub-numbers; 1️⃣1️⃣ for 11, etc.
- Each CTE's purpose is captured by its section/sub-section header (no extra
-- line needed per CTE — the header serves that role)
------------------------------------------------------------------------------------------------------------------------------
-- 1️⃣ Load and filter source tables
events_cte AS (...),
users_cte AS (...),
------------------------------------------------------------------------------------------------------------------------------
-- 5️⃣ Encoding for embedding: Map categorical features to embedding index IDs
------------------------------------------------------------------------------------------------------------------------------
-- 5️⃣.1️⃣ Join user segment embedding
with_user_segment_embedding_cte AS (...),
------------------------------------------------------------------------------------------------------------------------------
-- 5️⃣.2️⃣ Join product category embedding
with_product_category_embedding_cte AS (...),
Subqueries
- Prefer CTEs over subqueries by default. A subquery may remain inline only if it's clearly simple (e.g., a scalar
(SELECT MAX(...)) used in one place).
- Hard cap: never more than 1 level of nesting — no subquery inside a subquery, ever.
JOINs
- Always explicit JOIN type —
INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN. Never bare JOIN.
- Never implicit comma joins (no
FROM a, b WHERE a.id = b.id).
- One JOIN per line.
ON clause on the next line, indented 4. Multi-condition ON continues with AND on indented lines.
ON alias order follows FROM/JOIN reading order — the alias that appeared first goes on the left:
FROM users AS usr
INNER JOIN orders AS ord
ON usr.id = ord.user_id -- usr first → usr on left
LEFT JOIN items AS itm
ON ord.id = itm.order_id -- ord came before itm → ord on left
AND itm.is_active = TRUE
- JOIN placement:
- Default: do JOINs in the final SELECT (or in the smallest possible set of CTEs).
- Exception: progressive JOIN CTEs are allowed for feature engineering / ML pipelines when each enrichment step is a meaningfully named, individually inspectable transformation (one CTE = one JOIN). Use judgment.
WHERE patterns (neutral-element prefix)
For a single static condition, just write the condition directly — no neutral element needed:
WHERE _snowflake_deleted = FALSE
For multiple conditions OR any Jinja-templated condition, prefix with the appropriate neutral element so every condition line is uniform and the first condition can be conditionally absent:
WHERE 1 = 1 for AND-chained filters (the common case)
WHERE 0 = 1 for OR-chained filters (less common; use when it improves readability)
-- AND chain
FROM events
WHERE 1 = 1
AND event_type = 'ASSIGNMENT'
{% if is_incremental() %}
AND ingested_at >= DATEADD(DAY, -7, CURRENT_TIMESTAMP)
{% endif %}
-- OR chain
FROM events
WHERE 0 = 1
{% if include_active %}
OR status = 'active'
{% endif %}
{% if include_pending %}
OR status = 'pending'
{% endif %}
Audit columns
- Every asset must have a
created_at column — non-negotiable. Every model/table carries a row-creation timestamp.
- When
created_at is propagated from an upstream model, suffix with the source model name — created_at__stg_events, created_at__int_orders. Makes lineage visible in the row itself and avoids collisions when a downstream model pulls created_at from multiple sources.
- The freshly-generated
created_at in the current model stays unsuffixed — e.g. CURRENT_TIMESTAMP AS created_at. So a downstream model can pull created_at from an upstream and end up with both created_at (its own) and created_at__<upstream> (inherited).
SELECT
-- Identifiers
event_id,
-- Inherited lineage
stg.created_at AS created_at__stg_events,
dim.created_at AS created_at__dim_customers,
-- This model's own timestamp
CURRENT_TIMESTAMP AS created_at
FROM {{ ref('stg_events') }} AS stg
LEFT JOIN {{ ref('dim_customers') }} AS dim
ON stg.customer_id = dim.customer_id
Comments
- Comment non-obvious logic, big steps (CTE sections already covered above), and non-trivial parts. The goal: someone reading the file should grasp it fast without digging into every line.
- Don't comment what good naming already conveys.
Section 2 — Snowflake-specific rules
These apply when the target dialect is Snowflake. Skip when writing for Postgres / BigQuery / etc.
QUALIFY for filtering window functions — no subquery wrap:
-- Good
SELECT *
FROM events
QUALIFY ROW_NUMBER() OVER (
PARTITION BY event_id
ORDER BY created_at DESC
) = 1
-- Bad
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (...) AS rn
FROM events
)
WHERE rn = 1
Reuse computed column aliases in the same SELECT (important):
SELECT
a + b AS sum_ab,
sum_ab * 2 AS doubled, -- reuses sum_ab in the same SELECT
sum_ab / total AS ratio -- and again
FROM t
Trailing comma on the last column is allowed and encouraged — produces cleaner diffs when adding/removing columns:
SELECT
a,
b,
c, -- trailing comma OK in Snowflake
FROM t
Prefer IFF(cond, x, y) over CASE WHEN for simple binary cases:
IFF(qty > 0, 'positive', 'zero_or_neg')
* EXCLUDE (col1, col2) when selecting "everything except a few" — avoids listing 30+ columns to drop 2:
SELECT * EXCLUDE (id, created_at) FROM t
Prefer Snowflake datetime functions: DATE_TRUNC, DATEDIFF, DATEADD, EXTRACT, DAYOFWEEKISO, DATE_PART.
Prefer Snowflake array functions: ARRAY_CONSTRUCT, ARRAY_SIZE, ARRAY_EXCEPT, ARRAY_CONTAINS, ARRAY_AGG.
HASH() for deterministic surrogate keys / encoded IDs — fast, deterministic, 64-bit:
HASH(order_id) AS order_id_int
Section 3 — Reference example
The strongest demonstration of these conventions applied together is a feature-engineering model for an ML pipeline — the shape that exercises every rule at once: source CTEs at the top, numbered sections with sub-numbers, progressive JOIN CTEs (one enrichment per CTE), column grouping in a wide final SELECT, QUALIFY for last-event-per-key selection, and Snowflake functions (HASH, DATE_TRUNC, IFF). When you write one, build it to this standard and it becomes the reference for the rest of the codebase.
1---2name: sql-style-m3description: SQL style and structure rules for Claude-written SQL. Snowflake-tested but mostly vendor-neutral. Auto-applies whenever SQL is being written in dbt projects, .sql files, or Dagster asset files.4---56# SQL Style78Two sections: **General SQL rules** (any dialect) and **Snowflake-specific rules** (apply when the target is Snowflake). Some general rules are marked **(aspirational)** — they pull new code toward the standard; legacy code is what it is.910Real-world reference: a well-sectioned feature-engineering model (numbered sections, source CTEs, progressive JOIN CTEs) is the canonical example of these conventions in action — especially the sectioning and progressive-JOIN patterns below.1112---1314## Section 1 — General SQL rules1516### Layout & syntax1718- **Keywords UPPERCASE** — `SELECT`, `FROM`, `WHERE`, `INNER JOIN`, `QUALIFY`, etc. (needed for SQL syntax highlighting inside Python string literals).19- **Identifiers lower_snake_case** — `user_id`, `order_date`. No `.` in names. No leading digits.20- **Trailing commas, not leading.**21- **4-space indent** inside SELECT lists, CTE bodies, subqueries, and `ON` continuation lines.22- **Spaces around operators** — `price > 100`, `a.id = b.user_id`.23- **Parentheses for complex logic** — group conditions in WHERE / CASE for clarity.24- **Single quotes for string literals** — `'active'`, `'2026-01-01'`.2526### Aliases2728- **Always `AS`** for aliases — `users AS usr`, `total AS order_total` *(aspirational)*.29- **Descriptive initials** — `usr`, `ord`, `prd`, `evt` — **never single letters** (`u`, `o`, `a`).30- **Cap at 3 letters**; extend to 4 only when 3 would collide (e.g., `payments` vs `payouts` → `pmt` / `pyt`).31- **Same length per CTE/block** — vertically aligned `AS` columns *(aspirational)*.32- **Same alias for the same table** across the entire query, unless impossible (self-joins).33- **No alias needed in single-table CTEs** — no JOIN, no ambiguity, no alias.3435```sql36-- Good37SELECT38 usr.email,39 ord.total40FROM users AS usr41JOIN orders AS ord42 ON usr.id = ord.user_id43```4445### Clauses4647- **Clause order is a hard rule:**48 `SELECT → FROM → JOIN → WHERE → GROUP BY → HAVING → QUALIFY → ORDER BY → LIMIT`.49- **SELECT layout: stacked.** SELECT keyword at column 0, each column on its own line indented 4 spaces, trailing commas.5051```sql52SELECT53 col_a,54 col_b,55 col_c56FROM my_table57```5859- **Column grouping inside wide SELECTs** — when a SELECT has **10+ columns**, group them logically with `-- group title` comment lines:6061```sql62SELECT63 -- Identifiers64 event_id,65 external_job_id,6667 -- Event attributes68 event_code,69 event_reason,70 event_type,7172 -- Time features73 DATE_TRUNC('hour', event_datetime) AS rounded_event_ts,74 DAYOFWEEKISO(event_datetime) AS weekday_iso75```7677### CTEs7879- **No CTEs for very simple queries.** If the query is a single-table SELECT with simple WHERE filtering, no joins, no aggregations, no window functions, no subqueries — write it directly without CTEs. The rest of the CTE rules below apply *only when the query actually needs CTEs*.80- **CTE layout:** name at column 0, body indented 4 spaces, blank line between CTEs.8182```sql83WITH8485active_users_cte AS (86 SELECT87 user_id,88 email89 FROM users90 WHERE is_active = TRUE91),9293recent_orders_cte AS (94 SELECT95 order_id,96 user_id97 FROM orders98 WHERE order_date >= '2026-01-01'99)100```101102- **CTE naming:** use the `_cte` suffix — `active_users_cte`, `simple_features_cte`, etc.103- **Source CTE pattern.** Unless the query is very simple, the **top of the query** has dedicated "source CTEs" that:104 - read from exactly **one** upstream table/asset (`{{ ref(...) }}` or `{{ source(...) }}` in dbt)105 - **list all columns explicitly** (no `SELECT *` here)106 - may filter rows as needed107 - serve as the only source of truth — all downstream CTEs reference these, not the raw refs108- **`SELECT *`:** OK in **middle CTEs** (e.g., passing through after a JOIN). **Never** in source CTEs. **Never** in the final SELECT.109110#### Sectioning111112For non-trivial queries, organize CTEs into numbered sections. Match this convention:113114- A **`---`...`---` dash separator** (~126 dashes) above every section / sub-section115- A **`-- N️⃣ Section title`** line — one line preferred; multi-line `--` description allowed when needed116- **Sub-numbers `N️⃣.M️⃣`** when a section groups multiple related CTEs117- Use **`🔟`** for top-level section 10; use `1️⃣0️⃣` inside sub-numbers; `1️⃣1️⃣` for 11, etc.118- Each CTE's purpose is captured by its section/sub-section header (no extra `--` line needed per CTE — the header serves that role)119120```sql121------------------------------------------------------------------------------------------------------------------------------122-- 1️⃣ Load and filter source tables123events_cte AS (...),124users_cte AS (...),125126------------------------------------------------------------------------------------------------------------------------------127-- 5️⃣ Encoding for embedding: Map categorical features to embedding index IDs128129------------------------------------------------------------------------------------------------------------------------------130-- 5️⃣.1️⃣ Join user segment embedding131with_user_segment_embedding_cte AS (...),132133------------------------------------------------------------------------------------------------------------------------------134-- 5️⃣.2️⃣ Join product category embedding135with_product_category_embedding_cte AS (...),136```137138### Subqueries139140- **Prefer CTEs over subqueries by default.** A subquery may remain inline only if it's clearly simple (e.g., a scalar `(SELECT MAX(...))` used in one place).141- **Hard cap: never more than 1 level of nesting** — no subquery inside a subquery, ever.142143### JOINs144145- **Always explicit JOIN type** — `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`. **Never** bare `JOIN`.146- **Never implicit comma joins** (no `FROM a, b WHERE a.id = b.id`).147- **One JOIN per line.** `ON` clause on the **next line, indented 4**. Multi-condition `ON` continues with `AND` on indented lines.148- **`ON` alias order follows FROM/JOIN reading order** — the alias that appeared first goes on the left:149150```sql151FROM users AS usr152INNER JOIN orders AS ord153 ON usr.id = ord.user_id -- usr first → usr on left154LEFT JOIN items AS itm155 ON ord.id = itm.order_id -- ord came before itm → ord on left156 AND itm.is_active = TRUE157```158159- **JOIN placement:**160 - **Default:** do JOINs in the **final SELECT** (or in the smallest possible set of CTEs).161 - **Exception:** progressive JOIN CTEs are allowed for **feature engineering / ML pipelines** when each enrichment step is a meaningfully named, individually inspectable transformation (one CTE = one JOIN). Use judgment.162163### WHERE patterns (neutral-element prefix)164165For a **single static condition**, just write the condition directly — no neutral element needed:166167```sql168WHERE _snowflake_deleted = FALSE169```170171For **multiple conditions** OR **any Jinja-templated condition**, prefix with the appropriate neutral element so every condition line is uniform and the first condition can be conditionally absent:172173- **`WHERE 1 = 1`** for AND-chained filters (the common case)174- **`WHERE 0 = 1`** for OR-chained filters (less common; use when it improves readability)175176```sql177-- AND chain178FROM events179WHERE 1 = 1180 AND event_type = 'ASSIGNMENT'181 {% if is_incremental() %}182 AND ingested_at >= DATEADD(DAY, -7, CURRENT_TIMESTAMP)183 {% endif %}184185-- OR chain186FROM events187WHERE 0 = 1188 {% if include_active %}189 OR status = 'active'190 {% endif %}191 {% if include_pending %}192 OR status = 'pending'193 {% endif %}194```195196### Audit columns197198- **Every asset must have a `created_at` column** — non-negotiable. Every model/table carries a row-creation timestamp.199- **When `created_at` is propagated from an upstream model, suffix with the source model name** — `created_at__stg_events`, `created_at__int_orders`. Makes lineage visible in the row itself and avoids collisions when a downstream model pulls `created_at` from multiple sources.200- The **freshly-generated `created_at` in the current model stays unsuffixed** — e.g. `CURRENT_TIMESTAMP AS created_at`. So a downstream model can pull `created_at` from an upstream and end up with both `created_at` (its own) and `created_at__<upstream>` (inherited).201202```sql203SELECT204 -- Identifiers205 event_id,206207 -- Inherited lineage208 stg.created_at AS created_at__stg_events,209 dim.created_at AS created_at__dim_customers,210211 -- This model's own timestamp212 CURRENT_TIMESTAMP AS created_at213FROM {{ ref('stg_events') }} AS stg214LEFT JOIN {{ ref('dim_customers') }} AS dim215 ON stg.customer_id = dim.customer_id216```217218### Comments219220- Comment **non-obvious logic**, **big steps** (CTE sections already covered above), and **non-trivial parts**. The goal: someone reading the file should grasp it fast without digging into every line.221- Don't comment what good naming already conveys.222223---224225## Section 2 — Snowflake-specific rules226227These apply when the target dialect is Snowflake. Skip when writing for Postgres / BigQuery / etc.2282291. **`QUALIFY` for filtering window functions** — no subquery wrap:230231 ```sql232 -- Good233 SELECT *234 FROM events235 QUALIFY ROW_NUMBER() OVER (236 PARTITION BY event_id237 ORDER BY created_at DESC238 ) = 1239240 -- Bad241 SELECT * FROM (242 SELECT *, ROW_NUMBER() OVER (...) AS rn243 FROM events244 )245 WHERE rn = 1246 ```2472482. **Reuse computed column aliases in the same SELECT** (important):249250 ```sql251 SELECT252 a + b AS sum_ab,253 sum_ab * 2 AS doubled, -- reuses sum_ab in the same SELECT254 sum_ab / total AS ratio -- and again255 FROM t256 ```2572583. **Trailing comma on the last column is allowed and encouraged** — produces cleaner diffs when adding/removing columns:259260 ```sql261 SELECT262 a,263 b,264 c, -- trailing comma OK in Snowflake265 FROM t266 ```2672684. **Prefer `IFF(cond, x, y)` over `CASE WHEN`** for simple binary cases:269270 ```sql271 IFF(qty > 0, 'positive', 'zero_or_neg')272 ```2732745. **`* EXCLUDE (col1, col2)`** when selecting "everything except a few" — avoids listing 30+ columns to drop 2:275276 ```sql277 SELECT * EXCLUDE (id, created_at) FROM t278 ```2792806. **Prefer Snowflake datetime functions:** `DATE_TRUNC`, `DATEDIFF`, `DATEADD`, `EXTRACT`, `DAYOFWEEKISO`, `DATE_PART`.2812827. **Prefer Snowflake array functions:** `ARRAY_CONSTRUCT`, `ARRAY_SIZE`, `ARRAY_EXCEPT`, `ARRAY_CONTAINS`, `ARRAY_AGG`.2832848. **`HASH()` for deterministic surrogate keys / encoded IDs** — fast, deterministic, 64-bit:285286 ```sql287 HASH(order_id) AS order_id_int288 ```289290---291292## Section 3 — Reference example293294The strongest demonstration of these conventions applied together is a **feature-engineering model for an ML pipeline** — the shape that exercises every rule at once: source CTEs at the top, numbered sections with sub-numbers, progressive JOIN CTEs (one enrichment per CTE), column grouping in a wide final `SELECT`, `QUALIFY` for last-event-per-key selection, and Snowflake functions (`HASH`, `DATE_TRUNC`, `IFF`). When you write one, build it to this standard and it becomes the reference for the rest of the codebase.