SQL Optimization Patterns
A dashboard query that ran in 40ms on your laptop is timing out at 12 seconds in production. The table grew from 50k rows to 80M, the optimizer quietly switched to a sequential scan, and your APM is now red. You paste the query into your AI assistant and get back a confident “add an index on customer_id” — except that index already exists and the planner is ignoring it.
The fix is to stop guessing. When you wire a database MCP server into Cursor, Claude Code, or Codex, the assistant reads your actual execution plan, your actual table statistics, and your actual index usage — then proposes changes you can verify against real numbers instead of folklore.
What You’ll Walk Away With
Section titled “What You’ll Walk Away With”- A connected Postgres MCP server so the assistant can read live schema and
EXPLAINoutput instead of hallucinating - A copy-paste prompt that turns an
EXPLAIN (ANALYZE, BUFFERS)dump into a ranked list of bottlenecks - A prompt that recommends indexes from real query patterns — and rejects redundant ones
- A subquery-to-window-function rewrite prompt that preserves results
- A clear sense of when the AI’s advice will mislead you (small tables, MATERIALIZED CTEs,
EXPLAIN ANALYZEside effects)
Connect a Database MCP Server First
Section titled “Connect a Database MCP Server First”Without an MCP server, the assistant is reasoning about a schema it can’t see. With one, it can run EXPLAIN, inspect pg_stat_user_indexes, and check column statistics directly. Setup is identical across Cursor, Claude Code, and Codex — they all read the same MCP config; only the file location differs.
For local development and schema-aware work, the Prisma Postgres MCP ships in the Prisma CLI and needs no extra install:
{ "mcpServers": { "postgres": { "command": "npx", "args": ["-y", "prisma", "mcp"] } }}Drop the config into .cursor/mcp.json (Cursor), register it with claude mcp add (Claude Code), or add it to ~/.codex/config.toml under [mcp_servers.postgres] (Codex). After that the workflow below is the same everywhere.
Read the Execution Plan Like a Senior DBA
Section titled “Read the Execution Plan Like a Senior DBA”The single highest-leverage move is feeding the assistant a real EXPLAIN (ANALYZE, BUFFERS) plan and asking it to rank problems by cost. Generic “optimize this query” prompts produce generic advice; a plan-grounded prompt produces specifics.
The grounding constraints — “check pg_stats”, “don’t suggest existing indexes”, “flag Rows Removed by Filter” — are what separate this from a search-engine answer. A 10x row-estimate miss almost always means stale statistics (ANALYZE the table) rather than a missing index, and the assistant will only catch that if you make it look.
Recommend Indexes From Real Usage
Section titled “Recommend Indexes From Real Usage”Once the plan points at a scan, the next question is which index — and whether you already have one doing the job. This is where reading pg_stat_user_indexes beats intuition: teams routinely carry half a dozen unused indexes that slow every write.
In Agent mode, with the Postgres MCP connected, attach your schema and migrations folder as context and let the agent both diagnose and write the migration:
@migrations Using the Postgres MCP, query pg_stat_user_indexes andpg_stat_user_tables for the orders and order_items tables. Then:
- List indexes with idx_scan = 0 over the last stats window (candidates to drop)- Propose the minimal set of indexes for the WHERE/JOIN/ORDER BY in the slow dashboard query, preferring one composite index over several single-column ones where the leading column is selective- Write the CREATE INDEX CONCURRENTLY statements as a new migration file- Estimate the write-amplification cost of each new indexPoint Claude Code at your migrations directory (a directory, not a file) and drive it from the terminal:
claude --add-dir db/migrationsThen in the session:
Using the Postgres MCP server, analyze index usage on orders andorder_items via pg_stat_user_indexes. Recommend indexes for the slowdashboard query, reject any that duplicate an existing index, and writethem as a timestamped migration using CREATE INDEX CONCURRENTLY so wedon't lock the table in production.Codex reads the same MCP config from ~/.codex/config.toml. Run it headless to produce the migration as a reviewable diff:
codex exec --ask-for-approval on-request \ "Using the Postgres MCP server, query pg_stat_user_indexes for orders and order_items, propose the minimal index set for the slow dashboard query, and write CREATE INDEX CONCURRENTLY statements to a new file in db/migrations. Reject indexes that duplicate existing ones."codex exec keeps the run non-interactive and leaves the change as a diff you review before applying — ideal for wiring into a worktree or CI check.
Rewrite Patterns the Optimizer Can’t Save You From
Section titled “Rewrite Patterns the Optimizer Can’t Save You From”Some slow queries aren’t an indexing problem — they’re a shape problem. Correlated subqueries that re-run per row are the classic case, and the fix is a window function. Always ask the assistant to prove the rewrite returns identical rows.
The assistant should return the obvious SUM(...) OVER (PARTITION BY customer_id ORDER BY order_date) form — but the EXCEPT-both-ways verification is the part that matters. Window-frame defaults differ from a naive subquery (RANGE vs ROWS, tie handling on equal order_date), and silently changing results is the most expensive kind of “optimization.”
For PostgreSQL 18 specifically, push for the right index type per access pattern rather than B-tree everywhere:
-- Partial index for the hot path (active rows only) — smaller, fasterCREATE INDEX CONCURRENTLY idx_active_customers ON customers (customer_id) WHERE status = 'active';
-- GIN for JSONB containment / full-text searchCREATE INDEX CONCURRENTLY idx_product_attributes ON products USING gin (attributes);
-- BRIN for naturally time-ordered, append-only data — tiny on diskCREATE INDEX CONCURRENTLY idx_events_created_at ON events USING brin (created_at);Strategic denormalization for read-heavy paths
Section titled “Strategic denormalization for read-heavy paths”When a join is hot and the underlying rows rarely change, a maintained summary table beats re-aggregating on every request. Ask the assistant to generate both the table and the trigger that keeps it correct — the trigger is the part people forget, and a stale summary is worse than a slow query.
Approximate Counts: Know Your Engine
Section titled “Approximate Counts: Know Your Engine”A subtle accuracy trap: APPROX_COUNT_DISTINCT() is not a native PostgreSQL or MySQL function. It exists in BigQuery, Snowflake, and Spark, so AI suggestions that include it will throw function approx_count_distinct does not exist on stock Postgres.
- BigQuery / Snowflake:
APPROX_COUNT_DISTINCT(customer_id)is native — use it. - PostgreSQL: install the
hllextension and usehll_cardinality(hll_add_agg(hll_hash_bigint(customer_id))), or accept an exactCOUNT(DISTINCT ...)backed by a covering index. - MySQL: there is no built-in approximate distinct; use exact
COUNT(DISTINCT ...)or maintain a counter table.
This is exactly the kind of cross-engine error a connected MCP server prevents — the assistant can confirm the function exists before suggesting it.