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.
What You’ll Walk Away With
Section titled “What You’ll Walk Away With”- 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.RunAPI - A gRPC service prompt with interceptors and health checks
- A “When This Breaks” checklist for the failure modes AI-generated Go quietly ships
Set Up the Project Context
Section titled “Set Up the Project Context”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 serviceglobs: ["**/*.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/assertCreate CLAUDE.md at the repo root — Claude Code loads it automatically:
## Go conventions- 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/assertCodex reads the same CLAUDE.md if present, or its own AGENTS.md. Codex runs on GPT-5.5 across the CLI, IDE, and Cloud surfaces, so one rules file covers every entry point:
## Go conventions- 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/assertPin 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 Workflow
Section titled “The Workflow”1. Scaffold the HTTP server
Section titled “1. Scaffold the HTTP server”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.
2. Bound your concurrency
Section titled “2. Bound your concurrency”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.”
3. Wire up the repository and pool
Section titled “3. Wire up the repository and pool”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.
4. Generate the tests
Section titled “4. Generate the tests”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.
5. Stand up a gRPC service
Section titled “5. Stand up a gRPC service”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}Tool differences worth knowing
Section titled “Tool differences worth knowing”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.
Run the scaffolding prompts straight from the terminal:
claude "Create a Go HTTP server in cmd/server/main.go using go-chi/chi v5 with RequestID, RealIP, Recoverer, a 60s Timeout, zap logging, GET /healthz, and graceful shutdown via signal.NotifyContext + srv.Shutdown(10s)."For repeatable checks, wire a hook so go vet ./... and go test -short ./... run after every edit, and use claude -p headless in CI to review diffs. Sub-agents are handy for splitting “write the repository” and “write its tests” into parallel passes.
Codex runs on GPT-5.5 across the CLI, IDE, and Cloud. From the CLI, pass the prompt positionally and let it work in a worktree so the main branch stays clean:
codex "Create a Go HTTP server in cmd/server/main.go using go-chi/chi v5 with RequestID, RealIP, Recoverer, a 60s Timeout, zap logging, GET /healthz, and graceful shutdown via signal.NotifyContext + srv.Shutdown(10s)."Use --ask-for-approval on-request (and --full-auto once you trust the loop) so it can run go build and go test itself. The Cloud surface can pick up the same task from a GitHub or Linear issue and open a PR with the generated service.
When This Breaks
Section titled “When This Breaks”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 noctxexit path, leaks one goroutine per call. Run withgo test -raceand addgoleak.VerifyTestMain(go.uber.org/goleak) to catch leaks in CI. Follow-up prompt: “Audit everygo 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’sctx, cancellation and deadlines silently stop working. Grep forcontext.Background()outsidemainand tests. - Pool exhaustion. Forgetting
defer rows.Close(), or running queries onpoolinside a transaction that holds a separate connection, starves the pool. Symptom: requests hang atpool.Acquireunder load. SetMaxConnsdeliberately and load-test before shipping. - Pre-1.22 loop-capture artifacts. If the model emits
item := itemor afor 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 oferrors.Is. Wrapped errors (%w) breakerr == ErrNotFound. Ask the agent to useerrors.Is/errors.Aseverywhere and to define sentinels as package-level vars. - Shadowed package names. A parameter named
urlshadows thenet/urlpackage, sourl.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).
What’s Next
Section titled “What’s Next”Take the same prompt-first approach to your data layer in Database Patterns, or deploy this service with Kubernetes Patterns.
Wire the go test -race and go vet checks into a pre-commit hook, then see Kubernetes Patterns for headless deployment workflows.
Hand the deployment of this service to a Codex Cloud task — start from Kubernetes Patterns, or harden the data access with Database Patterns.