Most developers discover database performance problems the hard way — when a feature that worked perfectly in development suddenly crawls in production with real users and real data. If you use AI coding tools like Claude CLI to move fast, this risk is higher than you think, not because the AI writes broken code, but because it writes code that works without necessarily writing code that scales.
This article is written from direct experience fixing a production Go backend for a SaaS task management platform. The issues were real, the fixes are concrete, and the prevention strategies are things you can apply to your own projects today.
Table of Contents
What vibe coding actually means
Vibe coding is when you describe what you want, accept what the AI generates, verify it works, and ship it. The feedback loop is: prompt → code → test → deploy. It is genuinely fast and genuinely productive.
The problem is subtle. AI tools optimize for correctness first. Given the prompt “record this task submission and update the user’s progress stats”, an AI will write code that does exactly that — correctly. What it will not do automatically is ask: how many database rows will this touch when the user has completed 500 tasks? How much JSON data will transfer over the network? Does the query have an index?
Those questions require thinking about scale, not just correctness. And vibe coding, by its nature, skips the thinking step.
The patterns that cause the most damage
Pattern 1: Fetching a truck to read a post-it note
This is the most common and most expensive pattern. The code needs one small value — the next sequence number, a count, a maximum — so it fetches every row in the table and reads one field from the first result.
Here is what this looks like in Go with sqlc:
// Old approach — fetches ALL task records including large JSONB columns
records, _ := store.GetTaskSubmissionsByProject(ctx, db, userID, projectID, taskType)
nextSequenceNumber := int32(1)
if len(records) > 0 {
nextSequenceNumber = records[0].SequenceNumber + 1
}
If the user has submitted this task type 20 times, and each row contains a task_metadata JSONB column that is 80KB, you have just transferred 1.6MB of data across a TCP socket to retrieve the number “21”. As the project history grows, this gets worse linearly.
The fix is a query that asks for exactly what you need:
-- name: GetNextTaskSequenceNumber :one
SELECT COALESCE(MAX(sequence_number), 0) + 1 AS next_sequence_number
FROM task_submissions
WHERE user_id = $1
AND project_id = $2
AND task_type = $3;
The database does the arithmetic. One number comes back. Nothing else moves.
Pattern 2: Doing database work in application code
This one is harder to spot because it looks like normal programming. You fetch rows, loop through them in Go or JavaScript, and compute something — an average, a sum, a best score per group.
Here is the kind of code that ends up in production when moving fast with AI:
// Old approach — fetches every submission with full JSONB, loops in Go
submissions, _ := store.GetTaskSubmissionsByUserAndType(ctx, db, userID, taskType)
bestScores := make(map[string]float64)
for _, sub := range submissions {
score, _ := strconv.ParseFloat(sub.ScorePercentage, 64)
key := sub.ProjectID + "_" + sub.TaskID
if score > bestScores[key] {
bestScores[key] = score
}
}
count := int32(len(bestScores))
var total float64
for _, score := range bestScores {
total += score
}
avg := total / float64(count)
A user with 60 completed tasks triggers a fetch of 60 full rows including all their metadata JSON. Go reads 60 × ~80KB = 4.8MB of data, builds a map, and computes an average that could have been calculated entirely inside PostgreSQL in milliseconds with zero data transfer:
-- name: GetTaskSummaryForType :one
-- Computes unique task count and average of best scores per task.
-- Replaces the Go aggregation loop entirely.
SELECT
COUNT(*) AS unique_task_count,
COALESCE(AVG(best_pct), 0) AS avg_best_percentage
FROM (
SELECT
task_id,
MAX(score_percentage) AS best_pct
FROM task_submissions
WHERE user_id = $1
AND task_type = $2
GROUP BY task_id
) best_per_task;
Two scalar values come back instead of megabytes of rows. The database was built for this kind of computation — it is far better at aggregation than application code running a loop.
The rule to remember: if you are writing a loop that touches database rows to compute a number, that computation belongs in SQL.
Pattern 3: SELECT * when the table has large columns
When a table has JSONB columns, SELECT * becomes expensive in a way that is invisible in development but significant in production. Development databases have small rows. Production databases have real user data.
A typical offending query:
-- Pulls task_metadata + response_details JSONB on every row
-- even though the caller only reads sequence_number and score_percentage
SELECT * FROM task_submissions
WHERE user_id = $1 AND task_type = $2 AND project_id = $3;
If only sequence_number and score_percentage are ever read from the result, the fix is direct:
SELECT id, sequence_number, score_percentage, completed_at
FROM task_submissions
WHERE user_id = $1 AND task_type = $2 AND project_id = $3
ORDER BY sequence_number DESC;
The JSONB columns stay in the database. The network sees a fraction of the data. At 100KB per row and 50 rows per query, that is 5MB versus a few kilobytes — a difference that is invisible at development scale and painful in production.
Pattern 4: Unbounded queries
Any query that can return every row in a table is a production incident waiting to happen. AI tools generate these constantly because the prompt does not mention scale:
-- Returns every task submission a user has ever made
-- Works fine with 10 rows in development
-- Returns thousands in production
SELECT * FROM task_submissions WHERE user_id = $1;
In development, a test user has ten rows. In production, an active user has three thousand. The fix is always a LIMIT:
SELECT id, sequence_number, score_percentage, task_type, completed_at
FROM task_submissions
WHERE user_id = $1
ORDER BY completed_at DESC
LIMIT 50;
For sync or export endpoints that legitimately need more data, add offset-based or cursor-based pagination rather than removing the limit entirely.
Pattern 5: RETURNING * when only one field is needed
This is less dramatic but consistently wasteful. After an INSERT or UPDATE, returning the full row is the easy default:
-- Returns all 40 columns across a TCP socket
-- The Go caller reads only .ID from the result
INSERT INTO task_submissions (user_id, project_id, task_type, sequence_number, score, ...)
VALUES ($1, $2, $3, $4, $5, ...)
RETURNING *;
If the Go code only ever reads the generated ID (submissionID := result.ID), every other column is serialized, transmitted, and deserialized for nothing. The fix in sqlc is to return only what is needed:
INSERT INTO task_submissions (user_id, project_id, task_type, sequence_number, score, ...)
VALUES ($1, $2, $3, $4, $5, ...)
RETURNING id;
The same principle applies to UPDATE queries. If the caller discards the result (_, err = store.UpdateUserStats(...)), change the annotation to :exec and remove RETURNING * entirely.
Pattern 6: Missing covering indexes
Indexes are the most impactful single performance lever in a relational database, and the easiest to forget when moving fast. A query that filters on (user_id, task_type, project_id, task_id) needs an index on all four columns — not just the first two.
Without the right index, PostgreSQL performs a heap fetch: it uses a partial index to narrow rows, then reads each matching row from disk to check the remaining conditions. With a covering index, the query never touches the table at all — it reads everything it needs directly from the index.
-- Covers queries filtering on all four columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_submissions_user_type_project_task
ON task_submissions(user_id, task_type, project_id, task_id);
-- Covers aggregation queries (enables index-only scan)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_submissions_user_type_score
ON task_submissions(user_id, task_type, task_id, score_percentage);
One important note if you use golang-migrate: CREATE INDEX CONCURRENTLY cannot run inside a transaction, and golang-migrate wraps migrations in BEGIN/COMMIT by default. Apply index-only migrations manually via psql, or remove CONCURRENTLY if you want the migration runner to handle it (this takes a brief table lock — acceptable in a maintenance window).
Why AI tools generate these patterns
Understanding why helps you prevent it.
AI tools generate code that satisfies the requirement as described. The requirement “get the next sequence number for this task” is completely satisfied by fetching all submissions and reading index zero. The code is correct. The test passes. The AI has done what was asked.
What the AI does not know — unless you tell it — is that your task_submissions table will have thousands of rows per user, that the task_metadata column is 80KB of JSON, and that this endpoint is called on every task submission. That context changes what “correct” means entirely.
The gap is not between working code and broken code. It is between code that works now and code that works at scale.
How to close the gap using Claude CLI
1. Write a CLAUDE.md file
This is the most impactful change you can make. Claude CLI reads CLAUDE.md automatically at the start of every session. Put your performance rules there and they apply to every query generated, without you having to remember to prompt for them each time.
A minimal but effective version for a Go + sqlc + PostgreSQL project:
## Database query rules
**Never use SELECT ***
Always list explicit columns. Tables may have large JSONB columns
(task_metadata, response_details). Never fetch them unless the
handler explicitly reads them.
**Never aggregate in application code**
If you are fetching rows to compute SUM, AVG, COUNT, MAX, or MIN in Go —
rewrite as a SQL query with GROUP BY. The database is faster and
no data leaves the server.
**Never fetch rows to read one value**
To get the next sequence number: SELECT COALESCE(MAX(col), 0) + 1.
Do not fetch all rows and read index 0.
**Every :many query must have LIMIT**
Default: 50 for list endpoints, 500 for sync endpoints.
Unbounded queries require an explicit comment explaining why.
**RETURNING * is banned on updates**
Use :exec or RETURNING id. Never return columns the caller does not read.
**Every new WHERE clause needs an index**
Before writing a new query, verify all filter columns are indexed.
If not, add a migration alongside the query.
## Before writing any database code, answer:
1. What columns does the Go code actually read from this query?
2. Can this computation happen in SQL instead of Go?
3. Does this query have a LIMIT?
4. Is every WHERE column in an existing index?
5. Does the INSERT/UPDATE return anything the caller uses?
These five questions catch the majority of the patterns described above. Claude CLI applies them to every query it generates in your project from the moment CLAUDE.md exists.
2. Create a query checklist template
Keep a file called db/QUERY_TEMPLATE.md in your project. Before any new sqlc query is written — by you or by Claude CLI — fill in the template first.
## New query checklist
**Query name:**
**Annotation:** :one / :many / :exec
**Table:**
**Purpose (one sentence):**
**Columns the Go caller reads:**
(list only what is used — never SELECT *)
**WHERE clause columns:**
**Index covering these columns:**
- [ ] Existing index found: ___
- [ ] No index — migration added alongside this query
**Aggregation check:**
- [ ] No Go loop that computes SUM / AVG / COUNT / MAX / MIN
- [ ] If :many — LIMIT added
**RETURNING check:**
- [ ] :exec (result discarded)
- [ ] RETURNING id only
- [ ] RETURNING specific columns: ___ (list them, explain why)
**Estimated rows returned:** 1 / <50 / <500 / unbounded (justify)
The template is not bureaucracy. It is a fast checklist that catches issues before they become production problems. Three minutes spent here saves hours later.
3. Create a performance skill for Claude CLI
Claude CLI supports skills — markdown files it loads when working on specific types of tasks. A database performance skill means Claude applies your rules automatically whenever it writes SQL, without repeating them in every prompt.
Create /mnt/skills/user/db-performance/SKILL.md with your specific rules, examples from your codebase, and the query checklist. Concrete examples from your actual project are more effective than generic advice — Claude CLI responds better to patterns it can recognize in the surrounding code. Include one example of a bad query and the corrected version for each major pattern.
4. Use spec-driven development for new features
Before building any feature that touches the database, write a one-page spec that includes a performance section. This does not need to be long:
## Feature: task submission endpoint
**Endpoint:** POST /api/user/tasks/submit
**Tables touched:** task_submissions, user_stats_summary
**Performance constraints:**
- sequence_number via MAX() scalar query — no row fetch
- Stats update via SQL GROUP BY — no Go aggregation loop
- No SELECT * on task_submissions (JSONB columns are large)
- All new queries covered by existing indexes before implementation
- LIMIT on any query returning multiple rows
Handing this spec to Claude CLI before any code is written changes the output significantly. The constraints are part of the requirement from the start, not an afterthought added during a performance review.
5. Ask Claude to audit before you ship
After generating any database-touching code, run this prompt before merging:
Review the queries in [file]. For each one:
1. List every column fetched and confirm each is read by the caller
2. Identify any computation done in Go that could be done in SQL
3. Check every :many query for a LIMIT clause
4. Check every RETURNING clause against what the caller reads
5. List every WHERE column and confirm index coverage
Report issues by severity: critical, high, medium, low.
This takes under a minute and consistently catches patterns that slip through during fast development.
The verification step: always confirm in production
Once you have shipped changes, confirm they are working as expected. For a PostgreSQL backend, two queries give you a quick health check:
-- Verify your new indexes exist
SELECT indexname, tablename
FROM pg_indexes
WHERE indexname LIKE 'idx_%'
ORDER BY tablename;
-- Verify sequence number integrity across all users
-- Zero rows = correct. Any rows = sequence numbering bug.
SELECT user_id, task_type, task_id,
COUNT(*) as total_submissions,
MAX(sequence_number) as max_sequence
FROM task_submissions
GROUP BY user_id, task_type, task_id
HAVING COUNT(*) != MAX(sequence_number);
For ongoing monitoring, enable slow query logging in PostgreSQL with log_min_duration_statement = 100 (logs any query over 100ms). Slow queries surface naturally as real usage grows, giving you early warning before users notice.
The results you can expect
Applying these fixes to a production backend produced measurable changes on the two highest-traffic endpoints:
On task submission (POST /tasks/submit): the data transferred per request dropped from O(n × row size) — growing with every submission the user had ever made — to a fixed two scalar values regardless of history length. The Go aggregation loop of 35 lines was replaced by 8 lines calling a single SQL query.
On the user stats dashboard (GET /dashboard): response time dropped from variable (dependent on how much history the user had) to consistent single-digit milliseconds because the summary table was already populated correctly by the submission path.
The query count per request stayed the same. The pathology was never too many queries — it was too much data per query.
The mindset shift
The tools are not the problem. Moving fast is not the problem. The problem is treating AI-generated code as finished rather than as a first draft that needs a performance review.
The developers who get the most out of AI coding tools are the ones who understand what the AI optimizes for — correctness and completeness — and apply their own judgment to what it does not optimize for automatically: data volume, index coverage, computation placement.
CLAUDE.md gives Claude CLI your performance standards as standing instructions. The query checklist makes those standards concrete before any code is written. The audit prompt catches anything that slips through. None of these steps are slow — together they take a few minutes per feature and prevent hours of production debugging.
Write code that works today. Make sure it still works when you have a hundred thousand users.
Quick reference checklist
Before shipping any database-touching feature:
- No queries inside application loops (N+1)
- No
SELECT *on tables with large or JSONB columns - No aggregation (SUM, AVG, COUNT, MAX, MIN) computed in application code
- No fetching multiple rows to read one value
- Every
:manyquery has aLIMIT - Every
RETURNINGclause returns only columns the caller reads - Every
WHEREcolumn has index coverage - New queries reviewed with the five-question checklist from
CLAUDE.md