Go rules for AI coding agents
Go is simple to read, which makes agent mistakes easy to miss in review: an error assigned to _, a goroutine with no way to stop, http.Get in production code with no timeout, a query built with fmt.Sprintf. The Go rules cover modules and services and are organised around errors, interfaces, concurrency, HTTP and I/O, project structure and tests.
They begin with the go directive in go.mod, because what is idiomatic depends on it: since Go 1.22 loop variables are per iteration, so the old v := v copy is noise, and since 1.21 min, max, clear and the slices and maps packages exist. For concurrency the rule is blunt: before writing go func(), decide what stops it.
Folder: rules/go/ · go.mdc, CLAUDE.md, AGENTS.md
What the Go rules tell your agent
- Version and modulesRead the
godirective in go.mod and stay within it. Since Go 1.22 each loop iteration has its own variable, sov := vcopies are unnecessary; since 1.21min,max,clearand theslices/mapspackages are available. - ErrorsReturn errors as the last value and handle them at once. No
_ =on an error unless a comment says why it is safe. - Interfaces and typesDefine interfaces where they are consumed, keep them small, and return concrete types ("accept interfaces, return structs").
- HTTP and I/ONever use
http.DefaultClientorhttp.Getfor production calls: they have no timeout. Create anhttp.Client{Timeout: 10 * time.Second}and reuse it. - Project structureKeep
mainpackages incmd/<app>/and thin: parse config, wire dependencies, start. Put non-exported library code ininternal/.
First rule of each section in go.mdc, quoted as shipped.
Mistakes these rules are written to prevent
- Every goroutine has a clear owner and exit path. Before writing
go func(), decide what stops it: acontext.Context, a closed channel, or the end of its input. - Pass
ctx context.Contextas the first parameter of any function that does I/O or may block. Do not store contexts in structs. - Use
errgroup.WithContextfor a group of tasks that can fail, andsync.WaitGroupfor fire-and-wait work that cannot.
Source: “Concurrency”.
Checks the agent runs before it says done
Example commands from the Go file (swap in your repo's own):
- Format:
gofmt -l . - Vet:
go vet ./... - Tests:
go test -race ./... - Lint:
golangci-lint run(if configured)
Run gofmt -l . (expect no output), go vet ./..., go test -race ./... and the linter if configured. Report each command's result.
Before and after: Go: error wrapping and inspection review
Prompt card 17 · Stack: Go · Type: Review and refactor · Best model: a fast small model handles single files; a frontier reasoning model for packages
What the card is for
Make errors carry context without losing their identity: wrap with %w, inspect with errors.Is and errors.As, define sentinels or typed errors for conditions callers branch on, and stop string matching.
Worked example from the card
Before
func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, email FROM users WHERE id = $1`, id)
var u User
if err := row.Scan(&u.ID, &u.Email); err != nil {
log.Printf("scan failed: %v", err)
return nil, fmt.Errorf("Failed to get user: %v", err)
}
return &u, nil
}
// handler
u, err := store.GetUser(ctx, id)
if err != nil && strings.Contains(err.Error(), "no rows") {
http.Error(w, "not found", 404); return
}
Expected AI output (abridged)
- %v drops the cause; errors.Is(err, sql.ErrNoRows) is false for callers.
- Logged and returned: the same failure appears twice in logs.
- Message starts with a capital and "Failed to"; becomes "get user 42: ..." style.
- The handler string-matches a driver message and depends on database/sql details.
Fix: export ErrNotFound from the store and translate sql.ErrNoRows at the boundary.
After
var ErrNotFound = errors.New("not found")
func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
var u User
err := s.db.QueryRowContext(ctx, `SELECT id, email FROM users WHERE id = $1`, id).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return &u, nil
}
// handler
u, err := store.GetUser(ctx, id)
switch {
case errors.Is(err, store.ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
return
case err != nil:
logger.ErrorContext(ctx, "get user", "id", id, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
Excerpt from card 17; the prompt itself is in the pack.
More Go prompt cards in the pack
- Card 16: Go: goroutine leaks and context cancellation
- Card 18: Go: HTTP client and server timeout audit
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and go.mdc in .cursor/rules/. Globs: go.mod, **/*.go.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/go.md for **/*.go, go.mod; PostToolUse hook: gofmt -w <file>.
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in the directory with go.mod. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.