SQL Query Review skill for Codex
Catch fan-out, NULL and injection bugs before merge. Codex reviews SQL in passes for NULL semantics, join fan-out, time boundaries, injection, blast radius and concurrency before looking at performance. A bundled standard-library scanner flags risky statements in .sql files as a starting point, and each confirmed finding comes with a tiny VALUES fixture showing the wrong result next to the expected one, plus a severity label and a fix.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- sql-query-review Installs to ~/.agents/skills/sql-query-review/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install sql-query-review The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project sql-query-review Installs to .agents/skills/sql-query-review/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/sql-query-review.zipunzip -o sql-query-review.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) sql-query-review PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $sql-query-review, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Reviews SQL queries, migrations and ORM-generated SQL for correctness, safety and performance - NULL semantics, join fan-out and double counting, LEFT JOIN filters that silently become inner joins, time zone and boundary errors, injection through string building, unbounded UPDATE and DELETE, missing transactions and locks, and plans that will not scale. Ships a stdlib scanner that flags risky statements in .sql files. Use when asked to review a query, a migration, a report's SQL or a PR that touches database code, or when numbers from a query look wrong.
Use it when
- A PR adds or changes SQL, migrations or raw ORM queries
- Numbers from a report or dashboard query look wrong
- Auditing a folder of .sql files for dangerous statements
- Checking a one-off data fix before it runs
Use something else when
- You need a general PR review across all code, not just SQL: use code-review
- The query is known-correct and only slow: use postgres-query-tuning
How to check the result
Every blocker and major finding includes a minimal fixture that reproduces the wrong result, and the proposed fix returns the expected result on that fixture.
The mistake it prevents
Accepting DISTINCT as a fix for duplicate rows, which hides a join fan-out that still inflates sums and counts.
A reviewed query returns the right rows for normal, empty, NULL and duplicate inputs, cannot be hijacked by its parameters, cannot damage more data than intended, and will still be fast at ten times today’s data. Each finding names the statement, a concrete input that breaks it, and a fix.
Workflow
- Context. Read
AGENTS.md, the dialect and version, the table definitions (\d+ tablein psql,SHOW CREATE TABLEin MySQL, the ORM models) and the question the query is meant to answer. A correct query for the wrong question is still wrong. - Scan. For files, run the bundled heuristic scanner to get a starting list:
python3 scripts/sql_risk_scan.py db/migrations/ reports/monthly.sql
It flags UPDATE/DELETE without WHERE, DROP/TRUNCATE, NOT IN with subqueries, LIMIT
without ORDER BY, leading-wildcard LIKE, functions on filtered columns, BETWEEN on
timestamps, SELECT * and lock-heavy DDL. Exit code 1 means HIGH findings exist.
It is regex-based: confirm every hit by reading the statement, and do not treat a
clean scan as a pass.
3. Read for correctness using the passes below.
4. Prove suspicions with a small fixture or a read-only query against a dev copy.
5. Check performance with EXPLAIN on realistic data (dialect-specific depth in
$postgres-query-tuning or $mysql-tuning).
6. Report in the output template. Do not run data-modifying statements to “test”
them on shared databases; use a transaction you roll back, on a copy, after asking.
Correctness passes
NULLs
col = NULLis never true; useIS NULL.NOT IN (subquery)returns zero rows if the subquery yields any NULL. UseNOT EXISTS.col <> 'x'excludes rows wherecolis NULL. Intended?COUNT(col)skips NULLs;COUNT(*)does not.AVGignores NULLs (not zero).SUMover zero rows is NULL, not 0:COALESCE(SUM(x), 0).'a' || NULLis NULL. Postgresconcat()and MySQLCONCAT_WSskip NULLs; MySQLCONCATreturns NULL. Know which one the query uses.
Joins
- Fan-out: joining orders to line_items and to payments, then summing order
totals, multiplies each total by items x payments. Aggregate each child in a
subquery/CTE first, then join. Check by comparing
COUNT(*)before and after each join. - LEFT JOIN undone by WHERE:
LEFT JOIN refunds r ... WHERE r.status = 'ok'drops orders with no refund. Move the condition intoON, or user.status = 'ok' OR r.id IS NULLdeliberately. - Missing join condition or a condition on the wrong column produces a cross join; row counts explode.
DISTINCTadded to “fix duplicates” usually hides a fan-out bug. Ask why duplicates exist.- Joining on columns with different types or collations forces casts and may mismatch
(
'01' = 1in MySQL is true).
Grouping and aggregates
- Every non-aggregated SELECT column must be in GROUP BY (MySQL with
ONLY_FULL_GROUP_BYoff will happily return arbitrary values). HAVINGvsWHERE: filter rows in WHERE, groups in HAVING.- Integer division:
count_a / count_btruncates to 0 in Postgres, SQL Server and SQLite. Multiply by1.0or cast.
Time
BETWEEN '2026-01-01' AND '2026-01-31'on a timestamp misses everything after midnight on the 31st. Use half-open ranges:>= '2026-01-01' AND < '2026-02-01'.- Which time zone defines “a day”?
date_trunc('day', ts)uses the session zone in Postgres for timestamptz. Make it explicit (AT TIME ZONE 'Europe/Berlin'). now()vsCURRENT_DATEvs app-supplied times in tests: non-deterministic queries need a parameter for testability.
Ordering and paging
- Without
ORDER BY, row order is undefined, even if it “always” comes back sorted. ORDER BY created_at LIMIT 20with duplicate timestamps pages inconsistently: add a unique tiebreaker (, id).- OFFSET pagination skips or repeats rows when data changes between pages; keyset is stable.
Safety passes
Injection
- Any query built with string concatenation, f-strings, template literals or
format()from user input is a blocker. Use bound parameters. - Identifiers (column names for sorting, table names) cannot be parameters: map user
input through an allowlist (
{"newest": "created_at DESC"}), never interpolate. - ORM escape hatches (
raw(),$queryRawUnsafe,text()with f-strings,extra(),find_by_sqlwith interpolation) need the same scrutiny.
Blast radius
- UPDATE/DELETE: run the same WHERE as a
SELECT count(*)first and state the expected count. Wrap in a transaction and check the affected row count before committing. - Bulk changes on large tables: batch them (
$data-backfills). DROP,TRUNCATE,DELETEwithout a narrow WHERE and column drops need explicit confirmation from the user and a backup or snapshot plan; never run them as part of a review.- Migrations that lock: classify with
$zero-downtime-migrations.
Concurrency
- Read-then-write sequences (
SELECT balancethenUPDATE balance = <app value>) lose updates under concurrency. Use an atomicUPDATE ... SET balance = balance - $1 WHERE balance >= $1,SELECT ... FOR UPDATE, or a version column ($database-transactions). - Multi-statement changes that must succeed together need one transaction.
- “Check then insert” for uniqueness races; rely on a UNIQUE constraint and
ON CONFLICT/ON DUPLICATE KEY.
Performance pass
- Predicates must be sargable: no functions or casts on the indexed column side.
SELECT *in application queries fetches unused columns, breaks covering indexes and changes shape when the table changes.- Correlated subqueries in SELECT execute per row; check the plan.
INlists with thousands of literals: use an array parameter or temp table.LIKE '%term%'needs trigram or full-text indexing ($full-text-search).- Check estimated vs actual rows in the plan at production scale, not dev scale.
- For ORM code, count queries per request (
$n-plus-one-queries).
Proving a correctness finding
Build the smallest dataset that breaks the query:
WITH orders(id, total) AS (VALUES (1, 100)),
items(order_id, sku) AS (VALUES (1, 'a'), (1, 'b')),
payments(order_id, amount) AS (VALUES (1, 60), (1, 40))
SELECT SUM(o.total) -- returns 400, expected 100
FROM orders o JOIN items i ON i.order_id = o.id
JOIN payments p ON p.order_id = o.id;
Include the fixture and the wrong vs expected result in the finding.
Severity
| Label | Examples |
|---|---|
| blocker | Injection; unbounded UPDATE/DELETE; wrong numbers in financial or customer-facing output; migration that locks a hot table |
| major | NULL or fan-out bug on plausible data; missing transaction; non-sargable predicate on a large hot table |
| minor | Missing tiebreaker; SELECT * in app code; OFFSET at modest depth |
| nit | Formatting, aliasing, CTE naming |
Output template
## SQL review: <file / query name>
**Dialect:** <engine version> **Question answered:** <one sentence>
**Scanner:** <n high / n medium, confirmed: n>
### Blockers
1. `<file>:<line>` <problem>. Breaking input: <fixture or scenario>. Result: <wrong> vs <expected>. Fix: <SQL>.
### Major
### Minor
### Performance
<plan observations at realistic scale, index suggestions>
### Not checked
<parts not run, missing schema, assumptions> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "SQL Query Review"
short_description: "Catch fan-out, NULL and injection bugs before merge"
default_prompt: "$sql-query-review Review the SQL in this PR for bugs and anything dangerous" scripts/sql_risk_scan.py
#!/usr/bin/env python3
"""Heuristic scan of .sql files for risky SQL patterns.
Usage: python3 sql_risk_scan.py <file-or-dir> [more paths...]
Flags statements that commonly cause data loss, wrong results or slow queries:
UPDATE/DELETE without WHERE, DROP/TRUNCATE, NOT IN with a subquery, LIMIT without
ORDER BY, leading-wildcard LIKE, functions wrapped around columns in WHERE,
SELECT *, BETWEEN on timestamps, and DDL that locks tables (CREATE INDEX without
CONCURRENTLY, ADD CONSTRAINT without NOT VALID).
It is a regex heuristic, not a parser: treat every hit as a prompt to read the
statement, and expect false positives. Exits 1 if any HIGH finding exists, else 0.
Only the standard library is used.
"""
import pathlib
import re
import sys
RULES = [
("HIGH", "UPDATE without WHERE", re.compile(r"^\s*UPDATE\b(?!.*\bWHERE\b)", re.I | re.S)),
("HIGH", "DELETE without WHERE", re.compile(r"^\s*DELETE\s+FROM\b(?!.*\bWHERE\b)", re.I | re.S)),
("HIGH", "DROP or TRUNCATE (destructive)", re.compile(r"^\s*(DROP\s+(TABLE|SCHEMA|DATABASE|COLUMN)|TRUNCATE)\b|\bDROP\s+COLUMN\b", re.I)),
("HIGH", "NOT IN with subquery (returns no rows if subquery yields NULL)", re.compile(r"\bNOT\s+IN\s*\(\s*SELECT\b", re.I)),
("MED", "CREATE INDEX without CONCURRENTLY (Postgres: blocks writes)", re.compile(r"\bCREATE\s+(UNIQUE\s+)?INDEX\b(?!\s+CONCURRENTLY)", re.I)),
("MED", "ADD FOREIGN KEY/CHECK without NOT VALID (Postgres: scans under lock)", re.compile(r"\bADD\s+CONSTRAINT\b(?=.*\b(FOREIGN\s+KEY|CHECK)\b)(?!.*\bNOT\s+VALID\b)", re.I | re.S)),
("MED", "LIMIT without ORDER BY (nondeterministic rows)", re.compile(r"^(?!.*\bORDER\s+BY\b).*\bLIMIT\s+\d+", re.I | re.S)),
("MED", "Leading-wildcard LIKE (cannot use B-tree index)", re.compile(r"\bI?LIKE\s+'%", re.I)),
("MED", "Function on column in WHERE (may defeat index)", re.compile(r"\bWHERE\b.*\b(LOWER|UPPER|DATE|DATE_TRUNC|CAST|COALESCE|TRIM|YEAR|MONTH)\s*\(\s*[a-z_][a-z0-9_.]*\s*[,)]", re.I | re.S)),
("MED", "BETWEEN on a timestamp-like column (inclusive upper bound)", re.compile(r"\b\w*(_at|time|date|ts)\s+BETWEEN\b", re.I)),
("LOW", "SELECT * (fragile column list, prevents covering indexes)", re.compile(r"\bSELECT\s+(DISTINCT\s+)?\*", re.I)),
("LOW", "OFFSET pagination (slow at depth)", re.compile(r"\bOFFSET\s+\d{4,}", re.I)),
]
def statements(text: str):
"""Split on semicolons outside quotes and comments, tracking start line."""
text = re.sub(r"--[^\n]*", lambda m: " " * len(m.group()), text)
text = re.sub(r"/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group()), text, flags=re.S)
buf, line, start, in_q = [], 1, 1, False
for ch in text:
if ch == "'":
in_q = not in_q
if ch == ";" and not in_q:
yield start, "".join(buf)
buf, start = [], line
continue
if ch == "\n":
line += 1
if not "".join(buf).strip():
start = line
buf.append(ch)
if "".join(buf).strip():
yield start, "".join(buf)
def scan(path: pathlib.Path):
findings = []
for start, stmt in statements(path.read_text(encoding="utf-8", errors="replace")):
body = stmt.strip()
if not body:
continue
first = body.splitlines()[0][:90]
for sev, label, rx in RULES:
if rx.search(body):
findings.append((sev, path, start, label, first))
return findings
def main() -> int:
if len(sys.argv) < 2:
print("usage: python3 sql_risk_scan.py <file-or-dir> [...]", file=sys.stderr)
return 2
files = []
for arg in sys.argv[1:]:
p = pathlib.Path(arg)
if p.is_dir():
files += sorted(p.rglob("*.sql"))
elif p.is_file():
files.append(p)
else:
print(f"error: {arg} not found", file=sys.stderr)
return 2
findings = [f for path in files for f in scan(path)]
order = {"HIGH": 0, "MED": 1, "LOW": 2}
for sev, path, line, label, first in sorted(findings, key=lambda f: (order[f[0]], str(f[1]), f[2])):
print(f"{sev:4} {path}:{line} {label}\n {first}")
counts = {s: sum(1 for f in findings if f[0] == s) for s in order}
print(f"\n{len(files)} file(s) scanned: {counts['HIGH']} high, {counts['MED']} medium, {counts['LOW']} low")
return 1 if counts["HIGH"] else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.