https://github.com/renaldid/n1detect
Go static analysis tool that detects N+1 database query patterns in for/range loops
https://github.com/renaldid/n1detect
database go go-vet golang golangci-lint gorm linter n-plus-one performance pgx query-optimization sqlx static-analysis
Last synced: 3 days ago
JSON representation
Go static analysis tool that detects N+1 database query patterns in for/range loops
- Host: GitHub
- URL: https://github.com/renaldid/n1detect
- Owner: renaldid
- License: mit
- Created: 2026-05-11T03:12:32.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-05-11T03:24:00.000Z (about 1 month ago)
- Last Synced: 2026-05-29T09:34:31.742Z (14 days ago)
- Topics: database, go, go-vet, golang, golangci-lint, gorm, linter, n-plus-one, performance, pgx, query-optimization, sqlx, static-analysis
- Language: Go
- Size: 11.7 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# n1detect
[](https://pkg.go.dev/github.com/renaldid/n1detect)
[](https://golang.org/dl/)
[](LICENSE)
[](https://github.com/renaldid/n1detect)
**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`](https://pkg.go.dev/golang.org/x/tools/go/analysis) framework.
---
## What is an N+1 Query?
An N+1 query happens when you fetch a list of N records and then issue one additional query **per record** inside a loop:
```go
// 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.
### Fix: batch with JOIN or IN
```go
// 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)
```
---
## Installation
### Standalone CLI
```bash
go install github.com/renaldid/n1detect/cmd/n1detect@latest
```
Run on a module:
```bash
n1detect ./...
```
### As a `go vet` plugin
```bash
go vet -vettool=$(which n1detect) ./...
```
### With `golangci-lint` (custom linter)
Add to `.golangci.yml`:
```yaml
linters-settings:
custom:
n1detect:
path: n1detect
description: Detects N+1 database query patterns
original-url: github.com/renaldid/n1detect
```
---
## Supported libraries
n1detect 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` |
---
## Example output
```
./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
```
---
## Detected patterns
### For loop
```go
func loadTags(db *sql.DB, postIDs []int) {
for _, id := range postIDs {
db.Query("SELECT * FROM tags WHERE post_id = ?", id) // flagged
}
}
```
### Range loop
```go
func deletePosts(tx *sql.Tx, ids []int) {
for _, id := range ids {
tx.Exec("DELETE FROM posts WHERE id = ?", id) // flagged
}
}
```
### GORM
```go
func loadUserProfiles(db *gorm.DB, users []User) {
for _, u := range users {
var profile Profile
db.First(&profile, "user_id = ?", u.ID) // flagged
}
}
```
### Function literal in loop
```go
for _, id := range ids {
go func() {
db.QueryRow("SELECT * FROM t WHERE id = ?", id) // flagged
}()
}
```
### Nested loops
```go
for _, dept := range departments {
for _, emp := range dept.Employees {
db.Query("SELECT * FROM salaries WHERE employee_id = ?", emp.ID) // flagged
}
}
```
---
## Custom patterns
Register your own database types and methods using `WithPatterns`:
```go
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`:
```go
package main
import (
"golang.org/x/tools/go/analysis/multichecker"
"github.com/renaldid/n1detect"
)
func main() {
multichecker.Main(
n1detect.WithPatterns(/* your patterns */),
)
}
```
---
## Programmatic API
```go
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`](https://pkg.go.dev/golang.org/x/tools/go/analysis#Analyzer) and works with any tool in the `go/analysis` ecosystem.
---
## How it works
n1detect uses Go's type-checker — not string matching — to identify database calls:
1. For each `for` or `range` loop in the AST, it collects all call expressions within the loop body.
2. Each call is type-checked: the receiver's concrete type (e.g. `*sql.DB`) is resolved via `go/types`.
3. The resolved type is matched against the pattern registry (`PkgPath + TypeName + MethodName`).
4. 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.
---
## False positives
n1detect only flags calls where **the receiver is a known database type**. These patterns are intentionally not flagged:
```go
// 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
}
```
---
## Contributing
Issues and pull requests are welcome. Please open an issue before submitting large changes.
---
## License
[MIT](LICENSE)
---