SQL Query Review
When to use this skill
Use when reviewing or improving a SQL query from its text alone — no query profile or runtime stats required. Triggers:
- "Review this query"
- "Check my SQL before I ship it"
- "Can this query be written better?"
- "Optimize this query" (when no query_history / profile is available)
- PR review of a
.sql file
Routing to related skills:
- Runtime stats available (query_history, query profile, spilling)? →
warehouse-query-optimization
- Suspected wrong results (dupes, fanout, missing rows)? →
sql-correctness-review
- Query is correct and fast but unreadable? →
modular-sql-ctes
A full review often chains all three: correctness first, then this skill, then runtime profiling if still slow.
Required inputs
| Input |
Why it matters |
| Query text |
The thing being reviewed |
| Intent (one sentence) |
"What question does this answer?" — catches queries that are fast but wrong |
| Expected output grain |
One row per what? Needed to judge joins and aggregates |
| Approx table sizes |
A SELECT * on 1K rows is fine; on 1B rows it's a finding |
| Run frequency |
One-off ad-hoc vs hourly pipeline changes the bar |
If table sizes / frequency are unknown, review anyway and mark size-dependent findings as conditional.
Workflow
Restate intent and grain. One sentence each. If you can't infer them from the query, ask — every later judgment depends on this.
Correctness scan first. Optimizing a wrong query makes it wrong faster. Quick pass for the big five logic bugs (dupes, fanout, wrong join type, NULL traps, CASE issues). If any are suspected, run sql-correctness-review before continuing.
Run the anti-pattern checklist (full catalog with fixes in reference.md):
Projection & scan
SELECT * feeding downstream steps that use few columns
- Columns selected but never used
- Missing date/partition filter on large event tables
Filters
- Functions wrapping filter columns (
where date(event_at) = ...) — kills partition pruning
- Implicit casts in predicates (string column compared to number)
- Filters applied late (in the outer query) that could be pushed into the first CTE
Joins
OR in join conditions (forces nested-loop-like plans)
- Join keys with type mismatch (
varchar = number)
- Joining then aggregating, where pre-aggregating one side first would shrink the join
Aggregation & dedup
DISTINCT used as a band-aid for fanout (treat as a correctness smell too)
UNION where UNION ALL is intended (UNION adds an expensive implicit dedup)
count(distinct ...) repeated many times over the same scan
Windows & CTEs
- Unbounded window over a huge partition where a
GROUP BY would do
- Expensive CTE referenced 2+ times (may be computed twice — consider temp table)
RANGE BETWEEN where ROWS BETWEEN suffices
Misc
NOT IN (subquery) — both a NULL trap and often slower than NOT EXISTS
- Scalar subqueries in the SELECT list executed per-row
ORDER BY in subqueries/CTEs (wasted sort — only the final result needs order)
Optionally run the static scanner for a fast first pass:
python scripts/antipattern_scan.py my_query.sql
It catches the mechanical patterns (SELECT *, NOT IN, UNION vs UNION ALL, function-wrapped filters, OR-joins). Treat its output as leads, not verdicts — confirm each in context.
Classify findings by severity:
- Blocker — likely wrong results or unbounded cost (missing join condition, NOT IN with nullable subquery)
- Major — significant performance or maintainability cost (no partition filter, fanout-then-distinct)
- Minor — style/readability (unused columns, inconsistent aliases)
Write the optimized rewrite. Preserve the output contract exactly: same columns, same grain, same row set. If a fix changes results (e.g., removing a DISTINCT that was masking fanout), flag it as a correctness finding instead of silently changing behavior.
Provide a verification plan — concrete queries proving old and new are equivalent:
-- Row count match
select (select count(*) from old_result) as old_n,
(select count(*) from new_result) as new_n;
-- Full-row equivalence (empty result = identical)
(select * from old_result minus select * from new_result)
union all
(select * from new_result minus select * from old_result);
Output format
# SQL Review: <one-line description of the query>
## Intent & grain
- **Intent:** <what question the query answers>
- **Output grain:** <one row per ...>
## Findings
| # | Severity | Location | Finding | Fix |
|---|---|---|---|---|
| 1 | Blocker | line 14 join | `orders` joined on `user_id` only — N:M fanout, then DISTINCT masks it | Join on (user_id, order_date); remove DISTINCT |
| 2 | Major | line 3 where | `date(event_at) = current_date - 1` defeats pruning | `event_at >= ... and event_at < ...` |
| 3 | Minor | line 1 | SELECT * but only 4 columns used downstream | Project explicitly |
## Optimized query
\`\`\`sql
-- [rewritten SQL]
\`\`\`
## Key changes
1. <change + why>
2. <change + why>
## Verification plan
\`\`\`sql
-- [equivalence queries]
\`\`\`
## Expected impact
- <e.g., "scan drops from full table to 1 day of partitions; DISTINCT removed after fixing fanout">
- <or "no runtime stats available — re-profile after deploying; see warehouse-query-optimization">
Validation checks
Edge cases & failure modes
- The query is fine. Say so explicitly ("no blockers, two minor style notes") rather than inventing findings. A clean review is a valid output.
- DISTINCT that's load-bearing. Removing a
DISTINCT "for performance" when it's masking a fanout changes results. Always trace why the dedup exists before touching it.
- Generated SQL (ORM, BI tool). Don't review style — flag only blockers and majors, and note the fix belongs in the generator.
- Vendor differences. This skill defaults to Snowflake semantics (CTE inlining, pruning). For BigQuery/Postgres/Redshift differences, see
reference.md.
- Premature optimization. A one-off ad-hoc query on a small table needs a correctness scan, not a rewrite. Match effort to run frequency.
Scripts
scripts/antipattern_scan.py — regex-based static scanner for mechanical anti-patterns. Fast first pass; not a substitute for reading the query.
python scripts/antipattern_scan.py path/to/query.sql
# or pipe:
cat query.sql | python scripts/antipattern_scan.py -
Related skills
sql-correctness-review — deep logic check (dupes, fanout, joins, NULLs, CASE)
warehouse-query-optimization — runtime-profile-driven tuning when stats are available
modular-sql-ctes — structural refactor into staged CTEs
data-quality-audit — when the problem is the table, not the query
1---2name: sql-query-review3description: Static review of a SQL query — finds anti-patterns, structural problems, and performance issues from the query text alone, then proposes an optimized rewrite with a verification plan. Use when the user says "review this query", "check my SQL", "is this query okay", "clean up this query", or "optimize this" without runtime profile data.4---56# SQL Query Review78## When to use this skill910Use when reviewing or improving a SQL query **from its text alone** — no query profile or runtime stats required. Triggers:1112- "Review this query"13- "Check my SQL before I ship it"14- "Can this query be written better?"15- "Optimize this query" (when no query_history / profile is available)16- PR review of a `.sql` file1718Routing to related skills:1920- Runtime stats available (query_history, query profile, spilling)? → `warehouse-query-optimization`21- Suspected **wrong results** (dupes, fanout, missing rows)? → `sql-correctness-review`22- Query is correct and fast but unreadable? → `modular-sql-ctes`2324A full review often chains all three: correctness first, then this skill, then runtime profiling if still slow.2526## Required inputs2728| Input | Why it matters |29|---|---|30| Query text | The thing being reviewed |31| Intent (one sentence) | "What question does this answer?" — catches queries that are fast but wrong |32| Expected output grain | One row per what? Needed to judge joins and aggregates |33| Approx table sizes | A `SELECT *` on 1K rows is fine; on 1B rows it's a finding |34| Run frequency | One-off ad-hoc vs hourly pipeline changes the bar |3536If table sizes / frequency are unknown, review anyway and mark size-dependent findings as conditional.3738## Workflow39401. **Restate intent and grain.** One sentence each. If you can't infer them from the query, ask — every later judgment depends on this.41422. **Correctness scan first.** Optimizing a wrong query makes it wrong faster. Quick pass for the big five logic bugs (dupes, fanout, wrong join type, NULL traps, CASE issues). If any are suspected, run `sql-correctness-review` before continuing.43443. **Run the anti-pattern checklist** (full catalog with fixes in `reference.md`):4546 ### Projection & scan47 - `SELECT *` feeding downstream steps that use few columns48 - Columns selected but never used49 - Missing date/partition filter on large event tables5051 ### Filters52 - Functions wrapping filter columns (`where date(event_at) = ...`) — kills partition pruning53 - Implicit casts in predicates (string column compared to number)54 - Filters applied late (in the outer query) that could be pushed into the first CTE5556 ### Joins57 - `OR` in join conditions (forces nested-loop-like plans)58 - Join keys with type mismatch (`varchar` = `number`)59 - Joining then aggregating, where pre-aggregating one side first would shrink the join6061 ### Aggregation & dedup62 - `DISTINCT` used as a band-aid for fanout (treat as a correctness smell too)63 - `UNION` where `UNION ALL` is intended (UNION adds an expensive implicit dedup)64 - `count(distinct ...)` repeated many times over the same scan6566 ### Windows & CTEs67 - Unbounded window over a huge partition where a `GROUP BY` would do68 - Expensive CTE referenced 2+ times (may be computed twice — consider temp table)69 - `RANGE BETWEEN` where `ROWS BETWEEN` suffices7071 ### Misc72 - `NOT IN (subquery)` — both a NULL trap and often slower than `NOT EXISTS`73 - Scalar subqueries in the SELECT list executed per-row74 - `ORDER BY` in subqueries/CTEs (wasted sort — only the final result needs order)75764. **Optionally run the static scanner** for a fast first pass:77 ```bash78 python scripts/antipattern_scan.py my_query.sql79 ```80 It catches the mechanical patterns (SELECT *, NOT IN, UNION vs UNION ALL, function-wrapped filters, OR-joins). Treat its output as leads, not verdicts — confirm each in context.81825. **Classify findings by severity:**83 - **Blocker** — likely wrong results or unbounded cost (missing join condition, NOT IN with nullable subquery)84 - **Major** — significant performance or maintainability cost (no partition filter, fanout-then-distinct)85 - **Minor** — style/readability (unused columns, inconsistent aliases)86876. **Write the optimized rewrite.** Preserve the output contract exactly: same columns, same grain, same row set. If a fix changes results (e.g., removing a `DISTINCT` that was masking fanout), flag it as a correctness finding instead of silently changing behavior.88897. **Provide a verification plan** — concrete queries proving old and new are equivalent:90 ```sql91 -- Row count match92 select (select count(*) from old_result) as old_n,93 (select count(*) from new_result) as new_n;9495 -- Full-row equivalence (empty result = identical)96 (select * from old_result minus select * from new_result)97 union all98 (select * from new_result minus select * from old_result);99 ```100101## Output format102103```markdown104# SQL Review: <one-line description of the query>105106## Intent & grain107- **Intent:** <what question the query answers>108- **Output grain:** <one row per ...>109110## Findings111112| # | Severity | Location | Finding | Fix |113|---|---|---|---|---|114| 1 | Blocker | line 14 join | `orders` joined on `user_id` only — N:M fanout, then DISTINCT masks it | Join on (user_id, order_date); remove DISTINCT |115| 2 | Major | line 3 where | `date(event_at) = current_date - 1` defeats pruning | `event_at >= ... and event_at < ...` |116| 3 | Minor | line 1 | SELECT * but only 4 columns used downstream | Project explicitly |117118## Optimized query119\`\`\`sql120-- [rewritten SQL]121\`\`\`122123## Key changes1241. <change + why>1252. <change + why>126127## Verification plan128\`\`\`sql129-- [equivalence queries]130\`\`\`131132## Expected impact133- <e.g., "scan drops from full table to 1 day of partitions; DISTINCT removed after fixing fanout">134- <or "no runtime stats available — re-profile after deploying; see warehouse-query-optimization">135```136137## Validation checks138139- [ ] Intent and grain stated before any finding140- [ ] Correctness scanned before performance (wrong-but-fast is a failure)141- [ ] Every finding has a severity, a location, and a concrete fix142- [ ] Rewrite preserves the output contract (or behavior changes are flagged as correctness findings)143- [ ] Verification plan included (row count + full-row equivalence)144- [ ] Size-dependent findings marked conditional when table sizes are unknown145146## Edge cases & failure modes147148- **The query is fine.** Say so explicitly ("no blockers, two minor style notes") rather than inventing findings. A clean review is a valid output.149- **DISTINCT that's load-bearing.** Removing a `DISTINCT` "for performance" when it's masking a fanout changes results. Always trace *why* the dedup exists before touching it.150- **Generated SQL** (ORM, BI tool). Don't review style — flag only blockers and majors, and note the fix belongs in the generator.151- **Vendor differences.** This skill defaults to Snowflake semantics (CTE inlining, pruning). For BigQuery/Postgres/Redshift differences, see `reference.md`.152- **Premature optimization.** A one-off ad-hoc query on a small table needs a correctness scan, not a rewrite. Match effort to run frequency.153154## Scripts155156- `scripts/antipattern_scan.py` — regex-based static scanner for mechanical anti-patterns. Fast first pass; not a substitute for reading the query.157158```bash159python scripts/antipattern_scan.py path/to/query.sql160# or pipe:161cat query.sql | python scripts/antipattern_scan.py -162```163164## Related skills165166- `sql-correctness-review` — deep logic check (dupes, fanout, joins, NULLs, CASE)167- `warehouse-query-optimization` — runtime-profile-driven tuning when stats are available168- `modular-sql-ctes` — structural refactor into staged CTEs169- `data-quality-audit` — when the problem is the table, not the query