SQL Review
Review uncommitted SQL changes against a catalog of patterns that cause real production incidents — masked errors, unindexed new tables, silent truncation, SQL injection, deadlock-prone CATCH blocks — and surface findings as recommendations the user can act on, without editing SQL unprompted.
When to use this skill
- "/sql-review" / "review my SQL" / "review the SQL diff"
- "lint the SQL" / "SQL pre-commit check" / "check my SQL changes"
- "audit my stored proc" / "any SQL antipatterns in this diff"
- Before committing a
.sqlchange touching a stored procedure, function, or table
Do not auto-trigger when the user is asking for a general code review on a mixed diff — defer to code-review for that. This skill is specifically for SQL-heavy diffs where the antipattern catalog applies.
Hard rule: surface, don't refactor
If you find issues, do not start editing the SQL. Produce the findings report first. After delivering it, ask the user per finding: "Want me to fix #N?" — and wait for an explicit yes. Pre-authorization ("fix them all") proceeds through the list; absent that, default to ask-per-fix.
SQL changes are particularly sensitive because they often run against production data in a single non-reversible deployment. A drive-by refactor mid-review is more dangerous here than in application code.
Workflow
- Snapshot the SQL diff. Run in parallel:
git status --short— see what's modified, staged, untracked.git diff -- '*.sql'— unstaged SQL changes.git diff --staged -- '*.sql'— staged SQL changes.- If user passed an explicit range (e.g.
main..HEAD), use that instead:git diff <range> -- '*.sql'.
- Triage files into two buckets:
- New files (status
Aor??) — review the entire file. The whole file is new code; pre-existing antipatterns elsewhere don't apply. - Modified files (status
M) — review only the changed hunks. The rest of the file may have legacy antipatterns the user isn't introducing (e.g. existing repos commonly have dozens of SPs with thesp_send_dbmail-in-CATCH pattern; flagging all of them on an unrelated edit creates noise).
- New files (status
- For each check in What to check, look for the pattern in the relevant scope (whole file for new files, diff hunks for modified files). Use ripgrep — don't try to fully parse SQL.
- Categorize each finding as
BLOCKER/WARN/INFO(see Categories). - Cite
file:linefor every finding. Line numbers come from the diff or the current file content. - Produce the report in the Output format.
- Offer to fix the suggested findings. Wait for the user's selection before editing.
Categories
BLOCKER— would cause a production incident: silent data loss, masked errors, SQL injection, unindexed table that will grow, broken transaction handling. Fix before commit.WARN— likely a bug or maintenance hazard: NOLOCK in write paths, hardcoded environment values, missingSET NOCOUNT ON, cross-DB references. Should fix but won't necessarily incident.INFO— style / convention / hygiene: vestigial comments, cursor defaults, missingGRANT EXECUTEwhere the project's pattern exists.
Lead each finding with the why. "This swallows the original error and surfaces a misleading permission denial" beats "add ;THROW;".
Pattern hit ≠ finding
The 17 checks are pattern matches. A ripgrep hit is a candidate — before reporting it, Read the whole procedure/batch it sits in; severity depends on context the pattern can't see. Concrete demotions:
WITH (NOLOCK)hit, but the proc is a read-only reporting proc with noINSERT/UPDATE/DELETE→ WARN at most, not BLOCKER (check #13 blocks only when the same proc writes the tables it dirty-reads).DECLARE @retrywith noWHILE @retryin sight → check for aGOTO-label retry loop consuming it before flagging; only report when nothing reads the variable.- New
CREATE TABLEwith no index, but it's a static lookup/config table seeded with a handful of rows in the same diff → WARN with a one-line reason, not BLOCKER (the deadlock incident needs growth under concurrent writes).
Every reported finding must:
- Quote the offending SQL verbatim — the exact line(s) from the file, not a paraphrase. Severity turns on exact tokens.
- For
BLOCKER/WARN: state the concrete production incident in one sentence (this state → this outcome, e.g. "first deadlock under load → SP silently gives up, rows never written"). If you can't write that sentence after reading the full proc, demote one level.
Zero findings is a valid outcome — a clean diff gets a clean report. Never stretch INFO items into WARNs to fill sections; thoroughness is the 17 checks you ran, which the report shows.
What to check
Use ripgrep for detection. The patterns below are starting points — adapt to the actual diff text.
Error-handling antipatterns (BLOCKER class)
-
sp_send_dbmailinsideCATCH— masks the real exception with a permission/configuration error when the executing login lacksEXECUTEonmsdb.dbo.sp_send_dbmail. The real underlying error is silently lost.- Pattern:
rg -n -B 20 'sp_send_dbmail' <file>→ check whether the surrounding context is aBEGIN CATCH. - Fix: Replace with
;THROW;to re-raise, or write the error info to a local log table. Neversp_send_dbmailfrom inside operational SPs.
- Pattern:
-
Broken retry pattern —
DECLARE @retry INT = N;exists, but noWHILE @retryloop wraps theTRY/CATCH. The retry variable is decremented in the CATCH but never read, so the SP gives up on the first deadlock instead of retrying.- Pattern:
rg -n '@retry' <file>→ confirm there's a matchingWHILE.*@retrysomewhere in the same SP body. - Fix: Add the missing
WHILE @retry > 0 BEGIN ... ENDaround the TRY/CATCH, or remove the vestigial variable entirely.
- Pattern:
-
Empty / swallowing
CATCHblocks —BEGIN CATCH ... END CATCHwith noTHROW, noRAISERROR, and noINSERT INTO <error_log_table>. Silent failure → silent data loss.- Pattern:
rg -n -A 30 'BEGIN CATCH' <file>→ check each match for at least one ofTHROW,RAISERROR, orINSERT INTO. - Fix: End the CATCH with
;THROW;unless there's a deliberate reason to suppress (and document that reason).
- Pattern:
-
BEGIN TRANSACTIONwithoutTRY/CATCH+XACT_STATEhandling — an error mid-transaction leaves an open transaction on the connection.- Pattern:
rg -n 'BEGIN TRAN' <file>→ confirm the same SP hasBEGIN TRYandXACT_STATE()checks in CATCH. - Fix: Wrap in
BEGIN TRY / BEGIN TRANSACTION ... COMMIT TRANSACTION / END TRY / BEGIN CATCH / IF XACT_STATE() = -1 ROLLBACK; IF XACT_STATE() = 1 COMMIT; ;THROW; / END CATCH.
- Pattern:
-
Vestigial control-flow comments —
-- end while loopwith noWHILEkeyword,-- retry on deadlockwith no retry loop, etc. Strong signal that a refactor left dead code behind.- Pattern:
rg -n -- '-- end (while|for|repeat)' <file>→ check for matching opening keyword. - Fix: Either restore the missing loop or delete the misleading comment.
- Pattern:
Schema / performance antipatterns (BLOCKER for new tables, WARN otherwise)
-
New
CREATE TABLEwithout a primary key, clustered index, or any index — heap tables will deadlock-and-table-scan once they grow. This is the exact pattern that caused real production deadlocks.- Pattern:
rg -n -A 50 'CREATE TABLE' <file>→ confirm at least one ofPRIMARY KEY,CLUSTERED INDEX, orCREATE INDEXexists on that table within the diff or in a sibling file. - Fix: Add
PRIMARY KEY CLUSTEREDon the natural ID column. If the table is append-mostly with no natural ID, add a clustered index on the column the populating SPs filter by inWHERE.
- Pattern:
-
Parameter-to-column type-width mismatches — passing
@x VARCHAR(50)into a column declaredVARCHAR(3)causes silent truncation (or runtime error 8152 depending onANSI_WARNINGS).- Pattern: cross-reference the SP's
@param VARCHAR(N)declarations against the target table's column widths. Flag any narrowing. - Fix: Match widths between parameter and column, or add explicit
LEFT(@x, 3)truncation at the boundary so it's intentional and visible.
- Pattern: cross-reference the SP's
-
Implicit type conversions in
JOIN/WHERE—WHERE intCol = @varcharParamdefeats indexes and produces table scans.- Pattern:
rg -n -i 'WHERE\s+\w+\s*=\s*@\w+' <file>→ cross-check parameter type vs. column type. - Fix: Match the parameter type to the column type. If they're authoritatively different, cast the parameter, not the column (casting the column kills the index).
- Pattern:
Security antipatterns (BLOCKER)
-
Dynamic SQL via string concatenation passed to
EXEC()withoutsp_executesql @stmt, @paramsparameterization — SQL injection.- Pattern:
rg -n 'EXEC\s*\(' <file>→ confirm any string-concat dynamic SQL nearby usessp_executesqlwith parameters, notEXEC(@strSQL)with values inlined. - Fix: Switch to
EXEC sp_executesql @stmt = @strSQL, @params = N'@p1 INT, @p2 VARCHAR(50)', @p1 = @value1, @p2 = @value2;.
- Pattern:
-
Hardcoded environment-specific values — email addresses, server names, file paths, IP addresses, connection strings.
- Pattern:
rg -nE "'[\w.-]+@[\w.-]+\.\w+'|@server_name\s*=\s*'\w+'|\\\\\\\\[\\w.-]+\\\\" <file>(emails, linked servers, UNC paths). - Fix: Move env-specific values into a configuration table or pass them as parameters from the application layer.
- Pattern:
-
Cross-database references —
msdb.dbo.*,master.dbo.*, linked server[srv].db.dbo.*. These require permissions that may not exist in all environments (the canonical example: a SP callingmsdb.dbo.sp_send_dbmailfrom a login that lacks EXECUTE on it).- Pattern:
rg -n 'msdb\.|master\.dbo\.|\[\w+\]\.\w+\.\w+\.' <file>. - Fix: Move the cross-DB call out of operational SPs into infrastructure-owned code. If it must stay, document the required permission in a comment so deployments to new environments include the GRANT.
- Pattern:
Correctness / data-safety antipatterns (BLOCKER)
-
UPDATEorDELETEwithout aWHEREclause — almost always a bug; will affect every row.- Pattern:
rg -n -B 0 -A 20 'UPDATE\s+\w+' <file>→ confirm aWHEREclause is present and not commented out. - Pattern:
rg -n -B 0 -A 10 'DELETE\s+FROM' <file>→ same. - Fix: Add the
WHEREclause. If "every row" really is intended (TRUNCATE-style), useTRUNCATE TABLEand add a comment explaining why.
- Pattern:
-
NOLOCK/READ UNCOMMITTEDinside transactional write paths —WITH (NOLOCK)is fine for ad-hoc reporting reads, but in anUPDATE/INSERT/DELETESP it can read uncommitted data and corrupt the write.- Pattern:
rg -n -i 'NOLOCK|READ UNCOMMITTED' <file>→ check whether the same SP hasINSERT,UPDATE, orDELETEagainst the same tables. - Fix: Remove the hint on tables being modified. Consider
SNAPSHOTisolation if the goal is to avoid blocking.
- Pattern:
-
TRUNCATE TABLEorDROPwithoutIF EXISTSin deploy scripts that are supposed to be idempotent (re-runnable). Many repos use flat idempotentDeploy_*.sqlfiles where re-runs must not fail on missing objects.- Pattern:
rg -n -i 'DROP (TABLE|PROC|FUNCTION|VIEW)' <file>→ confirmIF EXISTSorOBJECT_ID(...) IS NOT NULLguard. - Fix:
IF OBJECT_ID('dbo.X', 'U') IS NOT NULL DROP TABLE dbo.X;orDROP TABLE IF EXISTS dbo.X;(SQL 2016+).
- Pattern:
Style / convention (WARN/INFO)
-
Missing
SET NOCOUNT ONat the top of a newCREATE PROC— causes extra round-trips to the client and breaks some ORM drivers that don't handle row-count messages.- Pattern:
rg -n -A 5 'CREATE PROC' <file>→ confirmSET NOCOUNT ONappears within the first ~10 lines of the procedure body. - Fix: Add
SET NOCOUNT ON;as the first statement afterAS BEGIN.
- Pattern:
-
Cursors without
READ_ONLY FORWARD_ONLY LOCAL— default cursors are slow and persist beyond the current batch.- Pattern:
rg -n 'DECLARE\s+\w+\s+CURSOR\b' <file>→ confirmREAD_ONLY FORWARD_ONLY LOCAL(orFAST_FORWARD) is specified. - Fix:
DECLARE c CURSOR READ_ONLY FORWARD_ONLY LOCAL FOR SELECT ....
- Pattern:
-
Missing
GRANT EXECUTEat end ofCREATE PROC— only flag when the rest of the repo has a consistentGRANT EXECUTE ON ... TO <role>pattern at the end of every SP file. Detect the pattern by sampling a few neighboring.sqlfiles; if every existing SP grants to the same role and this new SP doesn't, flag it.- Pattern:
rg -n 'GRANT\s+EXECUTE' <file>→ confirm present if the repo convention requires it. - Fix: Append
GRANT EXECUTE ON [dbo].[<proc>] TO [<app_role>] GOmatching the role the rest of the repo grants to.
- Pattern:
Output format
# SQL review — uncommitted changes
**SQL files changed:** <N> (<S new>, <M modified>)
---
## Verdict
**Ship / Fix blockers first**
<one-paragraph summary of the change and overall state>
---
## Blockers (<N>)
### B1. <short title> — `path/to/file.sql:<line>`
**Evidence:** `<offending SQL fragment, quoted verbatim>`
**Why:** <root cause / production consequence — one line>
**Fix:** <concrete recommendation — one line>
### B2. ...
## Warnings (<N>)
### W1. <short title> — `path/to/file.sql:<line>`
**Evidence:** `<offending SQL fragment, quoted verbatim>`
**Why:** ...
**Fix:** ...
## Info (<N>)
- `path:line` — <one-liner>
---
## Summary
- BLOCKER: <count>
- WARN: <count>
- INFO: <count>
## Fixes I can apply
If you want, I can apply any of: B1, B2, W1. Which? (or "all", or "none")
End with the offer. Wait for the user's choice.
Examples
Example 1: New stored proc with mixed issues
User: "/sql-review"
Claude:
- Runs
git diff --staged -- '*.sql'andgit diff -- '*.sql'in parallel. - Finds one new file
Stored Procedures/dbo.usp_NewSave.sql. - Spots:
B1—BEGIN CATCHwithout;THROW;(line 88) — swallowing.B2— callsEXEC msdb.dbo.sp_send_dbmailinside that CATCH (line 92) — masking risk.W1— missingSET NOCOUNT ONat line 6.W2—WITH (NOLOCK)on the table being UPDATEd (line 45).I1— missingGRANT EXECUTEat end of file (the repo's other SPs all end with it).
- Reports, then asks: "Want me to fix B1, B2, W1, W2, I1?"
Example 2: Adding a new table — the canonical incident pattern
User: "review the SQL diff before I push"
Claude:
- Spots
Tables/dbo.NewAuditLog.sql(new file) —CREATE TABLEdeclared but no PRIMARY KEY, no CLUSTERED INDEX, no CREATE INDEX. - Flags as
B1— "Heap table will table-scan on every read/UPDATE. Once the table grows past a few hundred thousand rows under concurrent writes, SQL Server's optimizer picks parallel plans and the workers deadlock on heap pages — a recurring production incident pattern." - Fix: "Add
PRIMARY KEY CLUSTERED (id)or a clustered index on the lookup column." - Also flags the SP that populates the new table (
B2— implicit type conversionWHERE recordID = @recordIDwhere the new table'srecordIDisINTbut the param is declaredVARCHAR).
Example 3: Modified existing SP — flag only new badness, not legacy
User: "lint the SQL — just the staged hunks"
Claude:
- The file
usp_UpdateBigOldSP.sqlis modified but huge. The user's diff added a 20-line block that doesUPDATE dbo.Customers SET ...without aWHEREclause. - Only flags the new block's
UPDATEwithoutWHEREasB1— does NOT flag the legacysp_send_dbmailcall elsewhere in the file (which was already there before the user's edit). - Notes in the report footer: "1 pre-existing antipattern observed in this file but outside the diff — flagging suppressed per scope. Full-file audit available on request."
Anti-patterns
- ❌ Trying to parse SQL with a full grammar — use ripgrep on the diff text. The skill is heuristic by design.
- ❌ Flagging every existing antipattern in a modified file. Stay scoped to the diff for modified files; full-file scan only for new files.
- ❌ Editing SQL during the review pass. Report first, always.
- ❌ Suggesting autofixes for
BLOCKERitems without asking. SQL deploys to production; user confirmation gates the change. - ❌ Listing a
nitwhen there's aBLOCKERnext to it. Lead with severity. - ❌ Hand-wavy findings without
file:line. - ❌ Reporting a raw ripgrep hit without reading the surrounding procedure. A pattern match is a candidate; the proc's full context sets the severity (see Pattern hit ≠ finding).
- ❌ Paraphrasing the offending SQL instead of quoting it verbatim.
WITH (NOLOCK)vsREADPAST,@retryvs@retryCount— the exact token is the finding. - ❌ A
BLOCKERwith no one-sentence production-incident scenario. Can't name the incident? It's aWARN. - ❌ Padding a clean diff with stretched findings. Zero findings is a valid report.
- ❌ Inventing antipatterns not in What to check. This is a fixed catalog — extending it is a
write-a-skillchange, not a per-run improvisation. - ❌ Running on non-SQL files. The skill is
.sql-scoped; mixed diffs route tocode-reviewfor the non-SQL parts. - ✅ Diff-scoped scan for modified files, full scan for new files, findings categorized + cited + offered as fixes.
Notes
- The check catalog is intentionally finite — 17 checks chosen because each one maps to a real production incident class. Don't try to be a full SQL linter (e.g. SQLFluff or sqlcheck) — those exist and run from CI; this skill is the pre-commit human-pass complement focused on bug classes that linters miss.
- For repos that use flat idempotent
Deploy_*.sqlfiles (every deploy is a full re-run), check #14 (DROP/TRUNCATEwithoutIF EXISTS) is critical — the deploy will fail on second run otherwise. For repos that use forward-only migrations, this check is lower priority. - The "broken retry" pattern (check #2) is sneakier than it sounds: a
DECLARE @retry INT = 5;followed bySET @retry = @retry - 1;in CATCH but noWHILEloop looks like deadlock handling. It isn't. Multiple SPs in real codebases have this bug from removed-loop refactors. - If the user wants the broader cleanup (refactor every legacy SP in the repo with one of these issues), recommend opening a tracked task rather than doing it in the current review — these fixes ripple through deploy pipelines and need coordinated review.
- This skill composes downstream with
code-review— on a mixed diff (.cs+.sql), runcode-reviewfor the application code andsql-reviewfor the database changes.
