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.

  • Databases
  • $sql-query-review
  • 159 lines
  • 3 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- sql-query-review

Installs to ~/.agents/skills/sql-query-review/ for every project on this machine.

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

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.

~/.agents/skills/sql-query-review/SKILL.md

--- name: sql-query-review description: Reviews SQL queries, migrations and ORM-generated SQL for correctness, safety and performance - NULL semantics, join fan-out and double counting,… license: MIT ---

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

  1. Context. Read AGENTS.md, the dialect and version, the table definitions (\d+ table in psql, SHOW CREATE TABLE in MySQL, the ORM models) and the question the query is meant to answer. A correct query for the wrong question is still wrong.
  2. 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 = NULL is never true; use IS NULL.
  • NOT IN (subquery) returns zero rows if the subquery yields any NULL. Use NOT EXISTS.
  • col <> 'x' excludes rows where col is NULL. Intended?
  • COUNT(col) skips NULLs; COUNT(*) does not. AVG ignores NULLs (not zero).
  • SUM over zero rows is NULL, not 0: COALESCE(SUM(x), 0).
  • 'a' || NULL is NULL. Postgres concat() and MySQL CONCAT_WS skip NULLs; MySQL CONCAT returns 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 into ON, or use r.status = 'ok' OR r.id IS NULL deliberately.
  • Missing join condition or a condition on the wrong column produces a cross join; row counts explode.
  • DISTINCT added 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' = 1 in MySQL is true).

Grouping and aggregates

  • Every non-aggregated SELECT column must be in GROUP BY (MySQL with ONLY_FULL_GROUP_BY off will happily return arbitrary values).
  • HAVING vs WHERE: filter rows in WHERE, groups in HAVING.
  • Integer division: count_a / count_b truncates to 0 in Postgres, SQL Server and SQLite. Multiply by 1.0 or 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() vs CURRENT_DATE vs 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 20 with 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_sql with 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, DELETE without 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 balance then UPDATE balance = <app value>) lose updates under concurrency. Use an atomic UPDATE ... 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.
  • IN lists 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

LabelExamples
blockerInjection; unbounded UPDATE/DELETE; wrong numbers in financial or customer-facing output; migration that locks a hot table
majorNULL or fan-out bug on plausible data; missing transaction; non-sargable predicate on a large hot table
minorMissing tiebreaker; SELECT * in app code; OFFSET at modest depth
nitFormatting, 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.yamlgenerated from this page

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.py94 lines, 4.3 KB
#!/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.

esc