Analytical SQL review
Purpose
Analytical SQL fails quietly. A join fans out and the revenue total doubles; an
inner join drops the rows with no matching dimension and the count is 4% low; a
filter in the WHERE clause turns a left join into an inner one. None of these
raise an error, and all of them produce a number someone will act on. This skill
is the ordered checklist for those failure classes, plus the cost review that
stops a correct query from being unaffordable.
Prerequisites
- Inputs: the query; the grain each source table is at (one row per what?);
expected row counts or an order of magnitude for the result; the metric
definition it implements.
- Access: ability to run the query's plan and row counts against a
representative dataset. Reviewing SQL by reading alone catches syntax and
obvious logic errors, but not fan-out — that needs counts.
If nobody can state the grain of each source table, establish that first. Almost
every fan-out and row-loss bug is a grain misunderstanding, and no amount of
reading the SQL surfaces it.
Procedure
Pass 1 — Correctness
State the grain of every source and the intended grain of the output.
Write it down as a sentence per table: "one row per order line", "one row per
customer per day". Then check every join: joining a one-row-per-customer table
to a one-row-per-order table produces one row per order — if an aggregate
downstream sums a customer-level column, it is now multiplied by order count.
Test for fan-out with counts, not by reading. Compare the row count before
and after each join. Any increase is fan-out and must be intentional. The
classic silent version: a dimension table with a slowly-changing history, so
the join matches multiple versions unless the validity window is in the join
condition.
Test for row loss. Count distinct keys on the driving table before and
after. An inner join used where a left join was meant, or a join on a column
containing nulls, drops rows without complaint. Both fan-out and loss can
occur in the same join and partially cancel, which is why counts must be
checked per key, not only in total.
Check filter placement against join semantics.
| Pattern |
Effect |
Usually intended? |
| Left join, filter on the right table in WHERE |
Becomes an inner join; unmatched rows vanish |
No — move it into the ON clause |
| Left join, filter on the right table in ON |
Preserves unmatched rows with nulls |
Usually yes |
| Filter on an aggregated column in WHERE |
Applies before aggregation |
Rarely; HAVING or a subquery is meant |
| NOT IN against a column containing nulls |
Returns no rows at all |
Never — use NOT EXISTS |
Audit null semantics. Nulls do not compare equal, are skipped by most
aggregates but not by COUNT(*), and propagate through arithmetic. Check each:
join keys that may be null; equality comparisons intended to include nulls;
averages where nulls should have counted as zero; and string concatenation
that nulls out an entire field.
Check the date and time handling. Which column is the event actually
timestamped by — created, updated, ingested, effective? Is the range boundary
inclusive at both ends (a BETWEEN on a timestamp typically loses the last
day's rows unless the upper bound is handled as an exclusive next-day
boundary)? Is a timezone conversion applied consistently to both the data and
the boundaries? Mixed-timezone comparison is invisible in the result and shows
up as a persistent small discrepancy.
Review window functions for frame and partition errors. Confirm the
PARTITION BY matches the intended grouping grain, the ORDER BY makes the
result deterministic (ties without a tiebreaker give different answers per
run), and the frame clause is explicit — the default frame changes behaviour
between a running total and a whole-partition aggregate.
Check deduplication is deterministic. A row-number-per-key deduplication
with a non-unique ordering column picks arbitrarily and produces a different
answer on re-run. Add a tiebreaker.
Confirm it implements the stated metric definition. Population,
exclusions, dedup, window and timezone — read the contract rows against the
query. A technically correct query implementing the wrong definition is the
most expensive kind of correct.
Pass 2 — Cost
Read the plan for the biggest scan. Cost is dominated by the volume read,
not by query length.
| Symptom in the plan |
Cause |
Fix |
| Full scan of a partitioned table |
Partition column absent from the filter, or wrapped in a function |
Filter on the raw partition column |
| Very large intermediate result |
Join before filtering |
Push predicates down before joining |
| Repeated scans of one table |
The same subquery evaluated multiple times |
Materialise once |
| Skew: one worker far slower |
Join key heavily concentrated (nulls or a default value) |
Salt or exclude the degenerate key |
| Broadcast of a large table |
Optimiser statistics stale |
Refresh statistics; reorder joins |
Check the shape of the output. SELECT * into a downstream table, an
unbounded result set feeding a dashboard, or ordering a large result with no
limit — each costs disproportionately for no analytical value.
Decide incremental vs full recompute. If this runs on a schedule over
growing data, full recompute cost grows with history. Incremental needs a
watermark column and a defined late-arrival window — and if the source can be
updated in place, incremental will silently miss those updates unless the
watermark is on the update time.
Failure modes this skill exists to prevent
- Doubled revenue from a fan-out join that looks like growth.
- The disappearing left join, caused by a WHERE-clause filter on the right
table.
- NOT IN with nulls returning zero rows, read as "no matches exist".
- Non-deterministic dedup, giving a different answer on each run and
destroying trust in the whole pipeline.
- Function-wrapped partition filters, turning a cheap query into a full scan
while returning identical results — invisible until the bill arrives.
Data handling
Classification: inherits from the tables queried; treat as Confidential where
personal, financial, or client-level data is involved. Review queries against
schema, plans, and counts — not by pasting result rows containing customer data.
Never embed credentials or connection strings in a query or in the review; sample
outputs shown for illustration must use synthetic values. If real customer
records, account numbers, or positions are supplied to support the review, flag
it and do not proceed until they are removed or masked.
Boundaries
- The output is finished and the question is whether the report can ship —
data-analytics-report-qa.
- The disagreement is about what the metric should mean —
data-analytics-metric-definition.
- The SQL is application code inside a service (transaction boundaries, ORM
behaviour, injection risk) —
engineering-code-review covers that; this skill
targets analytical queries.
- Schema or migration changes to the underlying tables need
it-change-management for the production window.
Hand-offs
- Receives from:
data-analytics-metric-definition (the contract this query
must implement); engineering-code-review (query-heavy diffs referred for
deeper analysis).
- Routes to:
data-analytics-report-qa (a query cleared here still needs the
output-level checks before publication).
1---2name: data-analytics-sql-review3description: Reviews analytical SQL for correctness and cost: the join fan-out and row-loss traps, filters that silently change join semantics, window and grain errors, null and timezone handling, then scan volume, partition pruning, and predicate placement. Use when a query is about to feed a report or model, when results look wrong or duplicated, when a query is expensive or slow, or when reviewing someone else's analytical SQL. Trigger on 'review this query', 'why are my rows duplicated', 'this query is expensive', 'check my SQL', 'the join is wrong', 'why is this so slow'. Not for verifying a finished report's outputs against an independent source — that is data-analytics-report-qa; not for deciding what the metric should mean, which is data-analytics-metric-definition.4---56# Analytical SQL review78## Purpose910Analytical SQL fails quietly. A join fans out and the revenue total doubles; an11inner join drops the rows with no matching dimension and the count is 4% low; a12filter in the WHERE clause turns a left join into an inner one. None of these13raise an error, and all of them produce a number someone will act on. This skill14is the ordered checklist for those failure classes, plus the cost review that15stops a correct query from being unaffordable.1617## Prerequisites1819- **Inputs:** the query; the grain each source table is at (one row per what?);20 expected row counts or an order of magnitude for the result; the metric21 definition it implements.22- **Access:** ability to run the query's plan and row counts against a23 representative dataset. Reviewing SQL by reading alone catches syntax and24 obvious logic errors, but not fan-out — that needs counts.2526If nobody can state the grain of each source table, establish that first. Almost27every fan-out and row-loss bug is a grain misunderstanding, and no amount of28reading the SQL surfaces it.2930## Procedure3132### Pass 1 — Correctness33341. **State the grain of every source and the intended grain of the output.**35 Write it down as a sentence per table: "one row per order line", "one row per36 customer per day". Then check every join: joining a one-row-per-customer table37 to a one-row-per-order table produces one row per order — if an aggregate38 downstream sums a customer-level column, it is now multiplied by order count.39402. **Test for fan-out with counts, not by reading.** Compare the row count before41 and after each join. Any increase is fan-out and must be intentional. The42 classic silent version: a dimension table with a slowly-changing history, so43 the join matches multiple versions unless the validity window is in the join44 condition.45463. **Test for row loss.** Count distinct keys on the driving table before and47 after. An inner join used where a left join was meant, or a join on a column48 containing nulls, drops rows without complaint. Both fan-out and loss can49 occur in the same join and partially cancel, which is why counts must be50 checked per key, not only in total.51524. **Check filter placement against join semantics.**5354 | Pattern | Effect | Usually intended? |55 | --- | --- | --- |56 | Left join, filter on the right table in WHERE | Becomes an inner join; unmatched rows vanish | No — move it into the ON clause |57 | Left join, filter on the right table in ON | Preserves unmatched rows with nulls | Usually yes |58 | Filter on an aggregated column in WHERE | Applies before aggregation | Rarely; HAVING or a subquery is meant |59 | NOT IN against a column containing nulls | Returns no rows at all | Never — use NOT EXISTS |60615. **Audit null semantics.** Nulls do not compare equal, are skipped by most62 aggregates but not by COUNT(*), and propagate through arithmetic. Check each:63 join keys that may be null; equality comparisons intended to include nulls;64 averages where nulls should have counted as zero; and string concatenation65 that nulls out an entire field.66676. **Check the date and time handling.** Which column is the event actually68 timestamped by — created, updated, ingested, effective? Is the range boundary69 inclusive at both ends (a `BETWEEN` on a timestamp typically loses the last70 day's rows unless the upper bound is handled as an exclusive next-day71 boundary)? Is a timezone conversion applied consistently to both the data and72 the boundaries? Mixed-timezone comparison is invisible in the result and shows73 up as a persistent small discrepancy.74757. **Review window functions for frame and partition errors.** Confirm the76 PARTITION BY matches the intended grouping grain, the ORDER BY makes the77 result deterministic (ties without a tiebreaker give different answers per78 run), and the frame clause is explicit — the default frame changes behaviour79 between a running total and a whole-partition aggregate.80818. **Check deduplication is deterministic.** A row-number-per-key deduplication82 with a non-unique ordering column picks arbitrarily and produces a different83 answer on re-run. Add a tiebreaker.84859. **Confirm it implements the stated metric definition.** Population,86 exclusions, dedup, window and timezone — read the contract rows against the87 query. A technically correct query implementing the wrong definition is the88 most expensive kind of correct.8990### Pass 2 — Cost919210. **Read the plan for the biggest scan.** Cost is dominated by the volume read,93 not by query length.9495 | Symptom in the plan | Cause | Fix |96 | --- | --- | --- |97 | Full scan of a partitioned table | Partition column absent from the filter, or wrapped in a function | Filter on the raw partition column |98 | Very large intermediate result | Join before filtering | Push predicates down before joining |99 | Repeated scans of one table | The same subquery evaluated multiple times | Materialise once |100 | Skew: one worker far slower | Join key heavily concentrated (nulls or a default value) | Salt or exclude the degenerate key |101 | Broadcast of a large table | Optimiser statistics stale | Refresh statistics; reorder joins |10210311. **Check the shape of the output.** SELECT * into a downstream table, an104 unbounded result set feeding a dashboard, or ordering a large result with no105 limit — each costs disproportionately for no analytical value.10610712. **Decide incremental vs full recompute.** If this runs on a schedule over108 growing data, full recompute cost grows with history. Incremental needs a109 watermark column and a defined late-arrival window — and if the source can be110 updated in place, incremental will silently miss those updates unless the111 watermark is on the update time.112113## Failure modes this skill exists to prevent114115- **Doubled revenue from a fan-out join** that looks like growth.116- **The disappearing left join**, caused by a WHERE-clause filter on the right117 table.118- **NOT IN with nulls** returning zero rows, read as "no matches exist".119- **Non-deterministic dedup**, giving a different answer on each run and120 destroying trust in the whole pipeline.121- **Function-wrapped partition filters**, turning a cheap query into a full scan122 while returning identical results — invisible until the bill arrives.123124## Data handling125126Classification: inherits from the tables queried; treat as **Confidential** where127personal, financial, or client-level data is involved. Review queries against128schema, plans, and counts — not by pasting result rows containing customer data.129Never embed credentials or connection strings in a query or in the review; sample130outputs shown for illustration must use synthetic values. If real customer131records, account numbers, or positions are supplied to support the review, flag132it and do not proceed until they are removed or masked.133134## Boundaries135136- The output is finished and the question is whether the report can ship —137 `data-analytics-report-qa`.138- The disagreement is about what the metric should mean —139 `data-analytics-metric-definition`.140- The SQL is application code inside a service (transaction boundaries, ORM141 behaviour, injection risk) — `engineering-code-review` covers that; this skill142 targets analytical queries.143- Schema or migration changes to the underlying tables need144 `it-change-management` for the production window.145146## Hand-offs147148- **Receives from:** `data-analytics-metric-definition` (the contract this query149 must implement); `engineering-code-review` (query-heavy diffs referred for150 deeper analysis).151- **Routes to:** `data-analytics-report-qa` (a query cleared here still needs the152 output-level checks before publication).