Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sql-sanity

Catches the missing index and the full table scan before production does.

sql-sanity demo

Quickstart

git clone --depth 1 https://github.com/botaoishere/sql-sanity .claude/skills/sql-sanity

Restart Claude Code, then say "review this query" and paste the SQL.

Slow queries rarely announce themselves. A WHERE DATE(created_at) = ... looks perfectly reasonable, runs in 4ms on your 200 row dev database, and turns into a sequential scan over 8 million rows in production. This skill applies the same ten point checklist every time, so the scan gets caught in review instead of in a pager alert.

Usage

Paste a query and ask for a review.

> review this against orders (12M rows) and customers (400k)
>
> SELECT * FROM orders o
> JOIN customers c ON c.id = o.customer_id
> WHERE DATE(o.created_at) = '2026-07-01'
>   AND c.tenant_id = '9f3a'
> ORDER BY o.created_at DESC
> LIMIT 20 OFFSET 40000;

Output:

| Severity | Line | Issue                                        | Fix                                   |
| -------- | ---- | -------------------------------------------- | ------------------------------------- |
| High     | 4    | DATE(o.created_at) blocks any index on the   | half open range on created_at         |
|          |      | column, forcing a full scan of orders        |                                       |
| High     | 7    | OFFSET 40000 reads and throws away 40k rows  | keyset pagination on (created_at, id) |
| Medium   | 1    | SELECT * pulls 31 columns, 4 are used        | name the columns                      |
| Low      | 6    | ORDER BY created_at is not unique            | add id as a tiebreaker                |

Rewritten:

SELECT o.id, o.total_cents, o.created_at, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= '2026-07-01' AND o.created_at < '2026-07-02'
  AND c.tenant_id = '9f3a'
  AND (o.created_at, o.id) < ($last_created_at, $last_id)
ORDER BY o.created_at DESC, o.id DESC
LIMIT 20;

Indexes:

CREATE INDEX CONCURRENTLY idx_orders_created_id
  ON orders (created_at DESC, id DESC);
CREATE INDEX CONCURRENTLY idx_customers_tenant
  ON customers (tenant_id);

Confirm with: EXPLAIN (ANALYZE, BUFFERS) <query>;

Before and after on the plan for that query:

before:  Seq Scan on orders  (cost=0.00..412883.00 rows=61 width=214)
                             (actual time=0.281..3894.117 rows=1204 loops=1)
                             Filter: (date(created_at) = '2026-07-01'::date)
                             Rows Removed by Filter: 11998796

after:   Index Scan using idx_orders_created_id on orders
                             (cost=0.43..38.19 rows=1180 width=36)
                             (actual time=0.019..0.612 rows=1204 loops=1)
                             Index Cond: ((created_at >= ...) AND (created_at < ...))

What it checks

SELECT *, missing or unselective WHERE, functions wrapped around indexed columns, implicit type casts, OR chains that want to be a UNION, N+1 patterns in the surrounding ORM code, LIMIT without a deterministic ORDER BY, unbounded IN lists, composite index column order, and deep OFFSET pagination.

Engine aware for Postgres, MySQL and SQLite, including the right EXPLAIN invocation for each.

License

MIT. See LICENSE.

About

A Claude Code skill that reviews SQL for N+1s, missing indexes and silent full scans

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors