An open API service indexing awesome lists of open source software.

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

Awesome Lists containing this project

README

          

# n1detect

[![Go Reference](https://pkg.go.dev/badge/github.com/renaldid/n1detect.svg)](https://pkg.go.dev/github.com/renaldid/n1detect)
[![Go Version](https://img.shields.io/badge/go-%3E%3D1.22-blue)](https://golang.org/dl/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](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)

---