DEV Community

Olivia Craft
Olivia Craft

Posted on

8 Cursor Rules for Go Developers — 2026 Edition

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.
Enter fullscreen mode Exit fullscreen mode

Before

f, _ := os.Open(path)
defer f.Close()
data, _ := io.ReadAll(f)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Before

type UserService interface { /* 14 methods */ }
func NewUserService() UserService { return &userService{} }
func Send(s UserService, id string) error { return s.SendEmail(id) }
Enter fullscreen mode Exit fullscreen mode

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{} }
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Before

func (r *Repo) FindUser(id string) (*User, error) {
    return r.db.QueryRow("SELECT ... WHERE id=$1", id).Scan(...)
}
Enter fullscreen mode Exit fullscreen mode

After

func (r *Repo) FindUser(ctx context.Context, id string) (*User, error) {
    return r.db.QueryRowContext(ctx, "SELECT ... WHERE id=$1", id).Scan(...)
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Before

func handle(w http.ResponseWriter, r *http.Request) {
    go processAsync(r.Body) // leaked if server shuts down mid-request
    w.WriteHeader(http.StatusAccepted)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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) } }
Enter fullscreen mode Exit fullscreen mode

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) }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Before

var db *sql.DB

func init() {
    db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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") }
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 ./...
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)