Skip to content

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.

  • A read-only database MCP setup for Claude Code, Codex, and Cursor, so the agent reads EXPLAIN output 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 ALL parity 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:

  1. Measure. Capture the plan on staging with production-sized data. A 50,000-row table and an 80-million-row table get different plans.
  2. 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.
  3. Simulate. Test the candidate index as a hypothetical index (the hypopg extension) before you build anything.
  4. Change. Write one index or one rewrite as a migration file. Do not apply it from the agent session.
  5. Prove. Attach the before-and-after plan, the parity result for rewrites, and the write cost to the pull request.

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 window
# Terminal, repo root: writes .mcp.json; ${STAGING_DATABASE_URL} is expanded from your shell at launch
claude mcp add -s project postgres -e 'DATABASE_URI=${STAGING_DATABASE_URL}' \
-- uvx postgres-mcp --access-mode=restricted

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 databaseServerWhy
Self-hosted or managed PostgresPostgres MCP Pro (crystaldba/postgres-mcp)explain_query with hypothetical indexes, get_top_queries, index recommendations
MySQL, MariaDB, SQL Server, SQLite, or several enginesDBHub (@bytebase/dbhub)One server with read-only mode, row limits, and query timeouts
Many engines behind one configMCP Toolbox for Databases (Google)Prebuilt tools per engine, or custom tools in tools.yaml
Supabase or NeonThe vendor’s remote serverSupabase 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 those
CREATE INDEX CONCURRENTLY idx_customers_active
ON customers (customer_id) WHERE status = 'active';
-- GIN: JSONB containment (@>) and full-text search
CREATE INDEX CONCURRENTLY idx_products_attributes
ON products USING gin (attributes);
-- BRIN: append-only, naturally time-ordered data; tiny on disk
CREATE 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:

Terminal window
claude --permission-mode plan

Paste 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.

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.

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 needPostgreSQLMySQLBigQuery, Snowflake
Approximate distinct countNo 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 tableAPPROX_COUNT_DISTINCT(id) is native
A plan with real timingsEXPLAIN (ANALYZE, BUFFERS)EXPLAIN ANALYZE (MySQL 8.0.18 and later); it also executes the queryEach engine’s query profile
An index build that does not block writesCREATE INDEX CONCURRENTLYInnoDB online DDL: ALGORITHM=INPLACE, LOCK=NONENot applicable
Test a drop before droppingHypothetical indexes via hypopgMake the index INVISIBLE, watch, then dropNot 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.

ChangeEvidence that proves itWho signs off
New indexBefore-and-after EXPLAIN (ANALYZE, BUFFERS) from staging, the tables whose writes it slows, and its sizeThe query owner; the tech lead for tables with heavy writes
Query rewriteParity count of 0 with EXCEPT ALL both ways, committed as a test, and both timingsThe query owner
N+1 fixQuery-count test in CI and the two SQL statements it issuesThe query owner
Index dropZero scans since a known reset on every replica, or an invisible or hypothetical test periodThe tech lead
Summary tableReconciliation query returns 0 rows after the backfill, committed as a test, plus a nightly drift alertThe 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”

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.