Skip to content

Go Language Patterns

Your Go service handles 200 req/s fine in dev, then falls over the first time real traffic hits it. The goroutines you spawned per request never exit, the pgx pool starves because nothing releases connections, and a context timeout you never wired up means slow downstream calls pile up until the box OOMs. The code an AI agent generated looked idiomatic — it just wasn’t production-hardened.

This recipe is about getting Go right the first time with Cursor, Claude Code, and Codex: prompts that ask for graceful shutdown, bounded concurrency, and connection-pool limits up front, plus the follow-up prompts that catch what the first pass forgets.

  • A copy-paste prompt that scaffolds a Chi HTTP server with structured logging and graceful shutdown
  • A worker-pool prompt that bounds concurrency and never leaks goroutines
  • A pgxpool repository prompt with sane pool limits and a transaction wrapper that rolls back on panic
  • A table-driven test prompt (testify + parallel subtests) and a testcontainers integration-test prompt using the current postgres.Run API
  • A gRPC service prompt with interceptors and health checks
  • A “When This Breaks” checklist for the failure modes AI-generated Go quietly ships

Before you ask for code, give the agent a rules file so every suggestion follows your conventions. The mechanics differ per tool, but the content is identical.

Create .cursor/rules/go.mdc (the single .cursorrules file is legacy — Cursor reads the .cursor/rules/*.mdc directory now):

---
description: Go conventions for this service
globs: ["**/*.go"]
alwaysApply: true
---
- Target Go 1.25+; use range-over-func and the standard library where it fits
- Standard layout: cmd/, internal/, no global state
- Wrap errors with fmt.Errorf("context: %w", err); define sentinel errors
- Accept interfaces, return concrete structs
- Every blocking call takes a context.Context as its first arg
- Table-driven tests with t.Parallel(); use testify require/assert

Pin a current toolchain in go.mod so the agent doesn’t reach for stale APIs:

module github.com/yourorg/yourservice
go 1.25
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/jackc/pgx/v5 v5.8.0
github.com/stretchr/testify v1.10.0
go.uber.org/zap v1.27.1
golang.org/x/sync v0.11.0
)

The single biggest thing AI gets wrong on a first pass is shutdown: it starts the server in a goroutine and forgets to drain in-flight requests. Name graceful shutdown in the prompt and you get it for free.

The model returns something like this — note signal.NotifyContext instead of a hand-rolled os.Signal channel, which is the modern idiom:

func main() {
logger, _ := zap.NewProduction()
defer func() { _ = logger.Sync() }()
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
r.Route("/api/v1", func(r chi.Router) {
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
})
srv := &http.Server{Addr: ":8080", Handler: r}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Fatal("server failed", zap.Error(err))
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Fatal("shutdown failed", zap.Error(err))
}
}

How to evaluate it: check that the listen error is compared with errors.Is(err, http.ErrServerClosed) (a plain != misclassifies a wrapped error), and that shutdown uses a fresh context — using the cancelled ctx would abort the drain immediately.

A naive “process these items concurrently” prompt produces an unbounded go func() fan-out that will exhaust file descriptors or DB connections under load. Ask for a bounded worker pool or errgroup.SetLimit explicitly.

func (s *Service) ProcessBatch(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
g.Go(func() error {
if err := ctx.Err(); err != nil {
return err
}
return s.processItem(ctx, item)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("batch processing failed: %w", err)
}
return nil
}

Since Go 1.22 the for loop variable is per-iteration, so the old item := item capture line is dead code — if the agent adds it, that’s a tell it’s drawing on pre-1.22 training data. Reply: “Remove the item := item line; we’re on Go 1.25 where loop vars are per-iteration.”

Connection-pool exhaustion is the classic “works in dev, dies in prod” bug. The fix is to set pool limits in the prompt, not discover them in an incident.

func (r *Repository) WithTx(ctx context.Context, fn func(pgx.Tx) error) (err error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback(ctx)
panic(p) // re-throw after cleanup
}
if err != nil {
_ = tx.Rollback(ctx)
}
}()
if err = fn(tx); err != nil {
return err
}
return tx.Commit(ctx)
}

How to evaluate it: the named return (err error) lets the deferred rollback see the function’s error, so you don’t need a rollback call in every branch. If the model writes the rollback inline in each error path, ask it to consolidate into the deferred form — it’s easy to forget a branch otherwise.

Ask for table-driven tests with parallel subtests and testify, and name the edge cases you care about — otherwise you get happy-path-only coverage.

For integration tests, ask explicitly for the current testcontainers postgres.Run entry point — the older GenericContainer/RunContainer API is deprecated and the agent will reach for it if you don’t say otherwise.

The mapping from domain errors to status codes is the part to review — a generated handler that returns the raw err leaks internal detail to clients:

func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
user, err := s.svc.GetUser(ctx, req.GetId())
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, status.Error(codes.NotFound, "user not found")
}
return nil, status.Error(codes.Internal, "internal error")
}
return &pb.User{Id: user.ID, Name: user.Name, Email: user.Email}, nil
}

The prompts above are identical across all three tools. Where the tools diverge is how you run them:

Use Agent mode for multi-file scaffolds (server + routes + repository in one pass) and inline edits (Cmd/Ctrl+K) for surgical fixes like swapping GenericContainer for postgres.Run. Checkpoints let you roll back a bad multi-file generation in one click. For architecture-heavy prompts — full-service scaffolding, complex multi-file refactors — use Claude Fable 5 (/model fable) when velocity matters more than cost; Opus 4.8 is a solid default otherwise. Drop to Sonnet 4.6 for the mechanical test-generation passes. See model comparison for details.

Even with good prompts, AI-generated Go ships these failure modes. Watch for them in review:

  • Goroutine leaks. A go func() that blocks on a channel send no one receives, or a per-request goroutine with no ctx exit path, leaks one goroutine per call. Run with go test -race and add goleak.VerifyTestMain (go.uber.org/goleak) to catch leaks in CI. Follow-up prompt: “Audit every go func() in this file for a guaranteed exit path under context cancellation.”
  • Context not propagated. If a handler calls context.Background() deep in the stack instead of threading the request’s ctx, cancellation and deadlines silently stop working. Grep for context.Background() outside main and tests.
  • Pool exhaustion. Forgetting defer rows.Close(), or running queries on pool inside a transaction that holds a separate connection, starves the pool. Symptom: requests hang at pool.Acquire under load. Set MaxConns deliberately and load-test before shipping.
  • Pre-1.22 loop-capture artifacts. If the model emits item := item or a for i := range xs { go f(i) } with a manual copy, it’s working from stale training data. Per-iteration loop variables have been the default since Go 1.22 — strip the workaround.
  • Errors compared with == instead of errors.Is. Wrapped errors (%w) break err == ErrNotFound. Ask the agent to use errors.Is/errors.As everywhere and to define sentinels as package-level vars.
  • Shadowed package names. A parameter named url shadows the net/url package, so url.Parse(url) won’t compile. If a build fails with “type string has no field or method Parse,” check for a parameter shadowing an import and rename it (e.g. rawURL).

Take the same prompt-first approach to your data layer in Database Patterns, or deploy this service with Kubernetes Patterns.