SQL Optimization Patterns
SQL optimization with an AI agent works when every suggestion is grounded in the database’s own evidence. A read-only MCP server lets Claude Code, Codex, or Cursor read the real execution plan, table statistics, and index usage. Each change then ships with a before-and-after plan, a parity query that returns zero rows, and a stated write cost.
This recipe is for the developer who owns a slow query and the tech lead who approves the fix. A dashboard query that ran in 40 ms on your laptop now times out in production because the table grew and the planner switched to a sequential scan. You paste the SQL into your agent and get back a confident “add an index on customer_id”. That index already exists, and the planner is ignoring it.
What this SQL tuning recipe gives you
Section titled “What this SQL tuning recipe gives you”- A read-only database MCP setup for Claude Code, Codex, and Cursor, so the agent reads
EXPLAINoutput instead of guessing. - A prompt that turns an
EXPLAIN (ANALYZE, BUFFERS)plan into ranked bottlenecks and at most two justified indexes. - A rewrite prompt that proves the new query returns the same rows, with an
EXCEPT ALLparity check in both directions. - An N+1 prompt that collapses a per-row ORM loop into two statements, plus a query-count test that keeps it fixed.
- An evidence checklist a reviewer can approve without reading every line of SQL.
Why does the agent’s first index suggestion miss?
Section titled “Why does the agent’s first index suggestion miss?”An agent that sees only the SQL text pattern-matches: it indexes the WHERE columns, adds a few more “to be safe”, and cannot tell a missing index from stale statistics. The plan holds the answer. It shows which node took the time, how far the row estimate was off, and whether a sort spilled to disk.
So the order of work is fixed:
- Measure. Capture the plan on staging with production-sized data. A 50,000-row table and an 80-million-row table get different plans.
- Diagnose. Rank nodes by actual time. Check whether the row estimate is off by 10x or more, which usually means stale statistics, not a missing index.
- Simulate. Test the candidate index as a hypothetical index (the
hypopgextension) before you build anything. - Change. Write one index or one rewrite as a migration file. Do not apply it from the agent session.
- Prove. Attach the before-and-after plan, the parity result for rewrites, and the write cost to the pull request.
Connect a read-only database MCP server
Section titled “Connect a read-only database MCP server”The MCP server gives the agent the plan, pg_stats, and pg_stat_user_indexes directly. Use a staging database or a database branch, a read-only role, and a statement timeout. The role, the extensions, and every server option are covered in database MCP servers. This page uses Postgres MCP Pro in restricted mode, which the vendor README defines as read-only transactions with an execution-time limit.
# Terminal, repo root: writes .mcp.json; ${STAGING_DATABASE_URL} is expanded from your shell at launchclaude mcp add -s project postgres -e 'DATABASE_URI=${STAGING_DATABASE_URL}' \ -- uvx postgres-mcp --access-mode=restricted# ~/.codex/config.toml: env_vars forwards DATABASE_URI from your shell.# Export DATABASE_URI first, pointing at the staging read-only URL:# export DATABASE_URI="$STAGING_DATABASE_URL"[mcp_servers.postgres]command = "uvx"args = ["postgres-mcp", "--access-mode=restricted"]env_vars = ["DATABASE_URI"]Put the entry in the project’s .cursor/mcp.json. Cursor’s MCP docs describe ${env:NAME} interpolation, so the file can hold a reference to an environment variable instead of the secret. Confirm the server connects in Cursor’s MCP settings before you commit the file:
{ "mcpServers": { "postgres": { "command": "uvx", "args": ["postgres-mcp", "--access-mode=restricted"], "env": { "DATABASE_URI": "${env:STAGING_DATABASE_URL}" }} } }Postgres MCP Pro is the PyPI package postgres-mcp (0.3.0, the last release on 2025-05-16), so it runs with uvx, not npx. Its index tools need the pg_stat_statements and hypopg extensions on the database. Pick another server when your stack differs:
| Your database | Server | Why |
|---|---|---|
| Self-hosted or managed Postgres | Postgres MCP Pro (crystaldba/postgres-mcp) | explain_query with hypothetical indexes, get_top_queries, index recommendations |
| MySQL, MariaDB, SQL Server, SQLite, or several engines | DBHub (@bytebase/dbhub) | One server with read-only mode, row limits, and query timeouts |
| Many engines behind one config | MCP Toolbox for Databases (Google) | Prebuilt tools per engine, or custom tools in tools.yaml |
| Supabase or Neon | The vendor’s remote server | Supabase takes read_only=true in its URL; Neon’s npm package is deprecated in favour of its remote server |
If you want Postgres guidance without a live connection, Supabase publishes a skill that covers performance and row-level security: npx skills add supabase/agent-skills --skill supabase-postgres-best-practices. A skill supplies rules; only the MCP server supplies your plan and statistics, so use both for tuning work.
How do you get index advice from a real execution plan?
Section titled “How do you get index advice from a real execution plan?”Start by handing the agent a real plan and forbidding guesses. The grounding lines in the prompt below (check pg_stats, list existing indexes first, cap the count at two) are what separate it from a search-engine answer.
EXPLAIN ANALYZE executes the statement. On a SELECT against staging that is what you want. On an UPDATE, DELETE, or INSERT, wrap it in BEGIN; … ROLLBACK;, and never run it against production.
Choose the index type from the access pattern
Section titled “Choose the index type from the access pattern”Once the plan points at a scan, ask for the smallest index that serves it. The agent defaults to B-tree everywhere unless you name the alternatives:
-- Partial index: the hot path touches only active rows, so index only thoseCREATE INDEX CONCURRENTLY idx_customers_active ON customers (customer_id) WHERE status = 'active';
-- GIN: JSONB containment (@>) and full-text searchCREATE INDEX CONCURRENTLY idx_products_attributes ON products USING gin (attributes);
-- BRIN: append-only, naturally time-ordered data; tiny on diskCREATE INDEX CONCURRENTLY idx_events_created_at ON events USING brin (created_at);Unused indexes cost you on every write. The same MCP session can find them: indexes with idx_scan = 0 in pg_stat_user_indexes since the last statistics reset are drop candidates. Confirm the reset date and check replicas before dropping anything, because the counters are per server.
Drive the tuning session in Claude Code, Codex, and Cursor
Section titled “Drive the tuning session in Claude Code, Codex, and Cursor”The MCP config is the same idea in all three tools; the difference is how you keep the agent in read-only analysis until you have the evidence, then let it write one migration file.
Start in plan mode so the agent analyses and proposes without editing files:
claude --permission-mode planPaste the plan prompt above. When the proposal carries a simulated plan change you accept, press Shift+Tab to leave plan mode (or accept the plan when Claude Code offers to proceed) and ask it to write the migration file. Claude Code shows each MCP tool call, so you can see which queries it ran.
Run the analysis under the built-in :read-only permission profile (beta, Codex CLI 0.138.0 or later), then a second run under :workspace to produce the migration as a reviewable diff:
# Analysis: no file writescodex -c default_permissions=":read-only" -a on-request "Using the postgres MCP server, rank the plan nodes for the query in docs/slow-dashboard.sql and simulate at most two indexes. Do not create anything."
# Change: one migration file, reviewed as a diffcodex -c default_permissions=":workspace" -a on-request "Write the accepted index as CREATE INDEX CONCURRENTLY in a new file in db/migrations, with a matching DROP INDEX CONCURRENTLY down migration."For a scripted run, codex exec -c default_permissions=":read-only" "…" prints the analysis without an interactive session. The legacy --sandbox read-only and --sandbox workspace-write flags still work in codex-cli 0.157.1, but they do not compose with permission profiles, so use one mechanism or the other, never both.
Start in Plan Mode, which plans before it writes any code, so the analysis stays read-only. With the postgres server enabled, attach the migrations folder and the slow query file as context with @ and paste the plan prompt. When you accept a simulated plan change, switch the mode from Plan back to Agent and ask it to write the migration file, then review the diff before you accept it.
Rewrite a correlated subquery without changing its results
Section titled “Rewrite a correlated subquery without changing its results”Some slow queries are a shape problem, not an index problem. A correlated subquery that re-runs for every row is the classic case, and a window function is the fix. The dangerous part is not the rewrite; it is a rewrite that returns slightly different rows. Make the agent prove parity.
Use EXCEPT ALL, not EXCEPT: plain EXCEPT removes duplicates, so a rewrite that returns a row twice still passes. Commit the parity query next to the rewrite as a test fixture, so the next refactor has to pass it too.
Collapse an N+1 into two statements
Section titled “Collapse an N+1 into two statements”An ORM loop that touches a relation for each parent row fires one query per row, so an orders page with 500 orders makes 501 round-trips. The fix is to load all children in one query keyed by parent ID and group them in memory. The anchor stack here is Drizzle on Postgres; the ORM patterns page covers Prisma and TypeORM.
The query-count test is the verification: it goes red the day someone reintroduces the loop, without anyone reading the diff. It also catches the other bad rewrite agents produce, a single join that sends every order row once per item over the wire.
Denormalize a hot read path without drifting
Section titled “Denormalize a hot read path without drifting”When a join is hot and its rows rarely change, a maintained summary table beats re-aggregating on every request. The trigger that keeps it correct is the part agents forget, and a stale summary is worse than a slow query. Tell the agent your read/write ratio, or it will tune for reads alone.
Which SQL functions differ between engines?
Section titled “Which SQL functions differ between engines?”Agents mix dialects. A connected MCP server lets the agent check that a function exists before it suggests it, but only if you ask. The traps that come up most:
| You need | PostgreSQL | MySQL | BigQuery, Snowflake |
|---|---|---|---|
| Approximate distinct count | No built-in: the hll extension (hll_cardinality(hll_add_agg(hll_hash_bigint(id)))) or an exact COUNT(DISTINCT …) | No built-in: exact COUNT(DISTINCT …) or a counter table | APPROX_COUNT_DISTINCT(id) is native |
| A plan with real timings | EXPLAIN (ANALYZE, BUFFERS) | EXPLAIN ANALYZE (MySQL 8.0.18 and later); it also executes the query | Each engine’s query profile |
| An index build that does not block writes | CREATE INDEX CONCURRENTLY | InnoDB online DDL: ALGORITHM=INPLACE, LOCK=NONE | Not applicable |
| Test a drop before dropping | Hypothetical indexes via hypopg | Make the index INVISIBLE, watch, then drop | Not applicable |
When the agent names an extension or function you have not seen, such as a plausible pg_fastsearch, make it query pg_available_extensions or the catalogue through the MCP server before the suggestion goes any further.
What evidence should an SQL change carry to review?
Section titled “What evidence should an SQL change carry to review?”A reviewer approves the evidence, not the prose around it. Ask the agent to assemble it in the pull request description; the DDL itself is a few lines and still gets read, because schema changes are one of the classes where someone still reads the code.
| Change | Evidence that proves it | Who signs off |
|---|---|---|
| New index | Before-and-after EXPLAIN (ANALYZE, BUFFERS) from staging, the tables whose writes it slows, and its size | The query owner; the tech lead for tables with heavy writes |
| Query rewrite | Parity count of 0 with EXCEPT ALL both ways, committed as a test, and both timings | The query owner |
| N+1 fix | Query-count test in CI and the two SQL statements it issues | The query owner |
| Index drop | Zero scans since a known reset on every replica, or an invisible or hypothetical test period | The tech lead |
| Summary table | Reconciliation query returns 0 rows after the backfill, committed as a test, plus a nightly drift alert | The tech lead |
After deploy, compare mean execution time for the same statement in pg_stat_statements. The rollback for an index is DROP INDEX CONCURRENTLY, which belongs in the down migration. Lock safety for the migration itself (a CONCURRENTLY build cannot run inside a transaction block, and a linter such as Squawk catches the plain form) is covered in database migration patterns, along with the expand, backfill, and contract sequence for column renames. The general PR format is in the evidence bundle.
When SQL optimization advice goes wrong in production
Section titled “When SQL optimization advice goes wrong in production”Where to go next with SQL tuning
Section titled “Where to go next with SQL tuning”For the whole database workflow from schema design to release, start at database development with agents. The other database recipes are listed in the database recipes overview.