n1detect is a Go static analysis tool that detects N+1 database query patterns — one of the most common and costly performance bugs in database-driven applications.
It integrates with go vet, golangci-lint, and any tool built on the go/analysis framework.
An N+1 query happens when you fetch a list of N records and then issue one additional query per record inside a loop:
// 1 query to get all user IDs
rows, _ := db.Query("SELECT id FROM users")
for rows.Next() {
var id int
rows.Scan(&id)
// N queries — one per user! <-- n1detect flags this
db.QueryRow("SELECT * FROM orders WHERE user_id = ?", id)
}This pattern produces N+1 round-trips to the database. With 1,000 users that's 1,001 queries instead of 1. Under load it becomes the primary source of database saturation, high latency, and connection pool exhaustion.
// 1 query — always, regardless of user count
db.Query(`
SELECT u.id, o.id, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
`)
// or with an IN clause
db.Query("SELECT * FROM orders WHERE user_id IN (?)", userIDs)go install github.com/renaldid/n1detect/cmd/n1detect@latestRun on a module:
n1detect ./...go vet -vettool=$(which n1detect) ./...Add to .golangci.yml:
linters-settings:
custom:
n1detect:
path: n1detect
description: Detects N+1 database query patterns
original-url: github.com/renaldid/n1detectn1detect detects N+1 patterns across all major Go database libraries out of the box:
| Library | Detected types | Detected methods |
|---|---|---|
database/sql |
DB, Tx, Conn |
Query, QueryRow, QueryContext, QueryRowContext, Exec, ExecContext, Prepare, PrepareContext |
gorm.io/gorm |
DB |
Find, First, Last, Take, Create, Save, Delete, Updates, Update, Scan, Row, Rows, Exec |
github.com/jackc/pgx/v5 |
Conn |
Query, QueryRow, Exec, SendBatch |
github.com/jackc/pgx/v5/pgxpool |
Pool |
Query, QueryRow, Exec, SendBatch |
github.com/jmoiron/sqlx |
DB, Tx |
Query, QueryRow, QueryContext, QueryRowContext, Exec, ExecContext, Select, SelectContext, Get, GetContext |
./service/user.go:42:3: potential N+1 query: DB.QueryRow called inside loop; consider batching or using JOIN
./repository/post.go:18:4: potential N+1 query: DB.Query called inside loop; consider batching or using JOIN
./store/order.go:31:3: potential N+1 query: Tx.Exec called inside loop; consider batching or using JOIN
func loadTags(db *sql.DB, postIDs []int) {
for _, id := range postIDs {
db.Query("SELECT * FROM tags WHERE post_id = ?", id) // flagged
}
}func deletePosts(tx *sql.Tx, ids []int) {
for _, id := range ids {
tx.Exec("DELETE FROM posts WHERE id = ?", id) // flagged
}
}func loadUserProfiles(db *gorm.DB, users []User) {
for _, u := range users {
var profile Profile
db.First(&profile, "user_id = ?", u.ID) // flagged
}
}for _, id := range ids {
go func() {
db.QueryRow("SELECT * FROM t WHERE id = ?", id) // flagged
}()
}for _, dept := range departments {
for _, emp := range dept.Employees {
db.Query("SELECT * FROM salaries WHERE employee_id = ?", emp.ID) // flagged
}
}Register your own database types and methods using WithPatterns:
import "github.com/renaldid/n1detect"
var myAnalyzer = n1detect.WithPatterns(
n1detect.Pattern{
PkgPath: "github.com/myorg/mydb",
TypeName: "Client",
Methods: []string{"Find", "Query", "Exec"},
},
)Use it with multichecker:
package main
import (
"golang.org/x/tools/go/analysis/multichecker"
"github.com/renaldid/n1detect"
)
func main() {
multichecker.Main(
n1detect.WithPatterns(/* your patterns */),
)
}import "github.com/renaldid/n1detect"
// Use the default analyzer (all built-in patterns)
var Analyzer = n1detect.Analyzer
// Extend with custom patterns
var Extended = n1detect.WithPatterns(
n1detect.Pattern{
PkgPath: "github.com/myorg/cache",
TypeName: "DB",
Methods: []string{"Get", "Set"},
},
)n1detect.Analyzer implements analysis.Analyzer and works with any tool in the go/analysis ecosystem.
n1detect uses Go's type-checker — not string matching — to identify database calls:
- For each
fororrangeloop in the AST, it collects all call expressions within the loop body. - Each call is type-checked: the receiver's concrete type (e.g.
*sql.DB) is resolved viago/types. - The resolved type is matched against the pattern registry (
PkgPath + TypeName + MethodName). - A diagnostic is reported at the call site.
Because analysis is type-aware, there are no false positives from unrelated types that happen to share a method name (e.g. a custom Query() on your own struct).
Known limitation: interprocedural N+1 patterns (where the DB call is inside a helper function called from the loop) are not detected in v1. Only direct calls inside loop bodies are flagged.
n1detect only flags calls where the receiver is a known database type. These patterns are intentionally not flagged:
// Interface type — receiver is unknown at compile time
var q Querier
for _, id := range ids {
q.Query(id) // NOT flagged
}
// Batch query outside loop — safe
db.Query("SELECT * FROM users WHERE id IN (?)", ids) // NOT flagged
// Custom struct with same method name
type MyService struct{}
func (s MyService) Query() {}
for range items {
s.Query() // NOT flagged — MyService is not a registered DB type
}Issues and pull requests are welcome. Please open an issue before submitting large changes.