Skip to content
Back to skills

Sql Query Performance

ASecurity

Use when writing or changing a query against PostgreSQL from Go — avoiding N+1 loops, reading EXPLAIN output, indexing, and keyset pagination

  • 109 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 2, 2026
ai-agentsgojavasqlapidatabaseperformance

Works with

  • api

Security analysis

A100/100

Scanned October 2, 2026

npx -y skills add makifbaysal/tasktrooper --skill sql-query-performance --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Sql Query Performance?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Sql Query Performance
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/makifbaysal-sql-query-performance/badge)](https://www.skillsdirectory.com/skills/makifbaysal-sql-query-performance)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: sql-query-performance
category: database
description: Use when writing or changing a query against PostgreSQL from Go — avoiding N+1 loops, reading EXPLAIN output, indexing, and keyset pagination
tech_stack: PostgreSQL
source: samber/cc-skills-golang golang-database (MIT), wshobson/agents sql-optimization-patterns (MIT), adapted
---
# SQL Query Performance (Go)

## Overview

Go has no ORM to blame for an N+1 — it's a loop you wrote yourself, calling the repository once per item instead of once for the batch. This skill is the Go-side half of query performance; java-persistence covers the JPA/Hibernate side.

**Core principle:** one round trip per request-shaped operation, not one per row.

## N+1 in Go

```go
// ❌ one query per item: N+1
for _, id := range ids {
    task, err := repo.Get(ctx, id)   // round trip per id
    ...
}

// ✅ one query for the batch
tasks, err := repo.GetMany(ctx, ids)   // SELECT ... WHERE id = ANY($1)
```

```sql
SELECT id, title, status FROM tasks WHERE id = ANY($1);
```
Bind `ids` as a `[]uuid.UUID` (pgx encodes a Go slice as a Postgres array directly — no manual `IN (...)` string building). For a write-side batch, use `pgx.Batch` to pipeline multiple statements over one round trip instead of looping with individual `Exec` calls.

## Database hygiene

- Every call takes a `context.Context` — `QueryContext`/`ExecContext` (`database/sql`) or pgx's `ctx` parameter — so a slow query is cancelable and traceable.
- `defer rows.Close()` immediately after a successful `Query`, and check `rows.Err()` after the loop — a `Query` that returns rows can still fail mid-stream, and an unclosed `Rows` leaks the connection.
- Use `Exec`, not `Query`, for statements that return no rows (`INSERT`/`UPDATE`/`DELETE` without `RETURNING`) — `Query` leaves a result set open that must still be drained.
- No `SELECT *` in application code — name the columns, so an added column doesn't silently change scan order or payload size.

## Reading EXPLAIN

```
psql "$DATABASE_URL" -c "EXPLAIN (ANALYZE, BUFFERS) <query with literal values, not placeholders>"
```
Run it against realistically seeded data, not an empty table — an empty-table plan hides the index Postgres would actually need. Read for:
- `Seq Scan` on a table bigger than a few thousand rows → missing index.
- Estimated vs actual row counts far apart → stale statistics (`ANALYZE <table>`) or a predicate the planner can't estimate well.
- A `Sort` that spills to disk (`Sort Method: external merge`) → needs `work_mem` tuning or an index that avoids the sort.

## Indexes

- Postgres does **not** auto-index foreign-key columns — add one explicitly for every FK you join or filter on.
- Composite index column order follows the leftmost-prefix rule: an index on `(a, b)` serves queries filtering on `a` alone or `a AND b`, not `b` alone.
- A partial index (`WHERE status = 'open'`) for a hot subset of a much larger table.
- A covering index (`INCLUDE (col)`) to let an index-only scan satisfy a query without a heap fetch.

See postgres-migrations for how to add an index on a live table without locking it.

## Query-count regression test

Wrap the pool/connection to count statements in a test, or use `pg_stat_statements` where the test DB has it enabled, and assert the count stays constant as the dataset grows — this is what catches an N+1 before it ships, the same way java-persistence's Hibernate-statistics assertion does on the Java side.

## Keyset pagination

See api-design-conventions for the full pagination guidance; the query shape is:
```sql
SELECT ... FROM tasks
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3 + 1;   -- fetch one extra row to know has_more
```

## Common Mistakes

- A loop calling the repository once per id instead of a batched `= ANY($1)` query.
- `rows.Close()` missing or only deferred conditionally (e.g. after an early `if err != nil { return }` that skips the defer).
- `rows.Err()` never checked after the loop.
- Adding a filter/join column with no matching index.
- Reading `EXPLAIN` against an empty or tiny local table and concluding the query is fine.

## Red Flags

- Query count in a test or log scales linearly with row count.
- `Seq Scan` on a table expected to hold more than a few thousand rows.
- A new `WHERE`/`JOIN` column with no migration adding its index.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…