Go prizes one obvious way. Then an AI assistant trained on a planet of Java and Python writes code that compiles but reads like a C# port: swallowed errors, fat interfaces, goroutines nobody owns, and init() doing database calls on import. The fix isn't endless re-prompting — it's a .cursorrules file that tells Cursor and Claude Code what idiomatic Go actually looks like in your repo. Eight rules, each with a before and after. Drop them in and ship.
Rule 1: Errors are values — handle them, never discard
Never write `_ = someCall()` to ignore an error.
Check every returned error and return with context via fmt.Errorf("%w", err).
Sentinel errors use errors.Is; typed errors use errors.As.
Before
f, _ := os.Open(path)
defer f.Close()
data, _ := io.ReadAll(f)
After
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
Errors wrap with %w so callers can errors.Is them. No silent data loss.
Rule 2: Accept interfaces, return concrete types
Function parameters take the smallest interface the function actually uses.
Return concrete struct pointers, not interfaces.
Interfaces are defined at the consumer, not the producer.
Before
type UserService interface { /* 14 methods */ }
func NewUserService() UserService { return &userService{} }
func Send(s UserService, id string) error { return s.SendEmail(id) }
After
type emailSender interface {
SendEmail(id string) error
}
func Send(s emailSender, id string) error { return s.SendEmail(id) }
type UserService struct{ /* ... */ }
func NewUserService() *UserService { return &UserService{} }
Tests pass a two-line fake. The producer stays flexible.
Rule 3: context.Context is the first argument, always
Every function that does I/O, crosses a goroutine boundary, or may block
takes ctx context.Context as its first parameter.
Never store ctx in a struct. Never pass context.TODO() in production code.
Before
func (r *Repo) FindUser(id string) (*User, error) {
return r.db.QueryRow("SELECT ... WHERE id=$1", id).Scan(...)
}
After
func (r *Repo) FindUser(ctx context.Context, id string) (*User, error) {
return r.db.QueryRowContext(ctx, "SELECT ... WHERE id=$1", id).Scan(...)
}
Now a cancelled HTTP request actually cancels the query. Timeouts propagate.
Rule 4: Every goroutine has an owner and a stop signal
Goroutines are spawned from a parent that knows how to stop them.
Use errgroup.Group, sync.WaitGroup, or context cancellation.
Never `go f()` at the top level of a handler without an owner.
Before
func handle(w http.ResponseWriter, r *http.Request) {
go processAsync(r.Body) // leaked if server shuts down mid-request
w.WriteHeader(http.StatusAccepted)
}
After
func handle(w http.ResponseWriter, r *http.Request) {
g, ctx := errgroup.WithContext(r.Context())
g.Go(func() error { return processAsync(ctx, r.Body) })
if err := g.Wait(); err != nil {
http.Error(w, err.Error(), 500); return
}
w.WriteHeader(http.StatusAccepted)
}
When the request ends, so does the goroutine. No leaks on shutdown.
Rule 5: Channels have a direction — declare it
Function parameters use directional channel types: `<-chan T` for receive,
`chan<- T` for send. Only the owner may close a channel.
Never close a channel on the sender side from multiple goroutines.
Before
func producer(c chan int) { for i := 0; i < 10; i++ { c <- i } }
func consumer(c chan int) { for v := range c { fmt.Println(v) } }
After
func producer(out chan<- int) {
defer close(out)
for i := 0; i < 10; i++ { out <- i }
}
func consumer(in <-chan int) {
for v := range in { fmt.Println(v) }
}
The compiler enforces the contract. Closing twice is a compile error.
Rule 6: init() is for registration, not business logic
init() may register drivers, parse embedded templates, or set up lookup tables.
init() may NOT open DB connections, read env vars, or spawn goroutines.
Configuration happens in main(). Everything is dependency-injected from there.
Before
var db *sql.DB
func init() {
db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
}
After
func NewApp(cfg Config) (*App, error) {
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
return &App{DB: db}, nil
}
Tests build an App with a test DB. Production reads env in main(). No import-time surprises.
Rule 7: Tests are table-driven with t.Run subtests
Tests use `tests := []struct { name string; ... }{}` plus `for _, tc := range tests`
and `t.Run(tc.name, func(t *testing.T) { ... })`.
Use t.Parallel() on independent subtests. Use t.Cleanup for teardown.
Before
func TestAdd(t *testing.T) {
if Add(1, 2) != 3 { t.Fatal("1+2") }
if Add(0, 0) != 0 { t.Fatal("0+0") }
if Add(-1, 1) != 0 { t.Fatal("-1+1") }
}
After
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b, want int
}{
{"positives", 1, 2, 3},
{"zeros", 0, 0, 0},
{"mixed_signs", -1, 1, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := Add(tc.a, tc.b); got != tc.want {
t.Fatalf("Add(%d,%d)=%d want %d", tc.a, tc.b, got, tc.want)
}
})
}
}
Adding a case is one line. Failure output names the failing case. -run TestAdd/zeros targets one.
Rule 8: gofmt, go vet, golangci-lint — clean or it doesn't ship
Every commit passes: gofmt -s -d, go vet, golangci-lint run.
Enable: errcheck, govet, ineffassign, staticcheck, unused, gosec, revive.
No `//nolint` without a reason comment and an issue link.
Before: developer hand-runs some of these, reviewers eyeball the rest.
After: a Makefile target plus pre-commit hook:
.PHONY: check
check:
gofmt -s -l . | tee /dev/stderr | (! read)
go vet ./...
golangci-lint run ./...
Commits that fail never land on main. Lint findings stop being PR-review filler and become compile-time errors for humans.
Drop them in and stop re-prompting
These eight rules cover where AI-written Go gets unidiomatic: error handling, interfaces, context, goroutines, channels, init, tests, and tooling. Paste them into .cursorrules at the repo root and the next function Cursor generates will already look like Go a Gopher would merge.
If you want the expanded set — these eight plus rules for generics, sync.Pool, HTTP middleware, database/sql patterns, slog structured logging, embed, and testcontainers-based integration tests — grab Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Go you'd merge.