LLMs write Go that compiles but feels wrong: C-style loops instead of range, interface{} instead of generics, manual mutexes where a channel belongs. The fix is not a better model — it's a better prompt. Below are 12 prompts I use daily, each with a real example and the task it solves.
1. Idiomatic code generation: ban C-style patterns upfront
Task: get range, early returns, and errors.Is instead of if err != nil { return err } pyramids.
Prompt:
You are a Go reviewer. Rewrite this function in idiomatic Go: use
rangefor iteration, early returns, named constants, and wrap errors with%w. Do not useinterface{}— use generics or concrete types. Show only the final code and a 3-line rationale.
Example result: for a slice-filtering function the model returns:
func Filter[T any](s []T, keep func(T) bool) []T {
out := make([]T, 0, len(s))
for _, v := range s {
if keep(v) {
out = append(out, v)
}
}
return out
}
make([]T, 0, len(s)) explicitly — without the capacity hint the model often returns var out []T, which allocates on every append.
2. Data race hunt in goroutines and channels
Task: find races before go test -race does.
Prompt:
Analyze this code for data races. For each race, name the two conflicting accesses (read/write), the goroutine that owns each, and give a fixed version using either a mutex or a channel — explain the trade-off.
Example: a counter incremented from 10 goroutines without sync. The model flags counter++ as a read-modify-write race and offers sync/atomic (fast, no blocking) vs sync.Mutex (works for structs, slower under contention).
sync.RWMutex.
3. Table-driven tests with subtests
Task: generate t.Run cases from a spec.
Prompt:
Write a table-driven test for this function. Each case: name, input, want, wantErr. Use
t.Run,t.Parallel(), anderrors.Isfor error checks. Do not use testify.
Example result:
func TestParse(t *testing.T) {
tests := []struct{ name, in string; want int; wantErr error }{
{"empty", "", 0, ErrEmpty},
{"valid", "42", 42, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := Parse(tt.in)
if !errors.Is(err, tt.wantErr) { t.Fatalf("err = %v", err) }
if got != tt.want { t.Errorf("got %d, want %d", got, tt.want) }
})
}
}
4. Context-aware error handling
Task: stop losing the request ID and deadline in error chains.
Prompt:
Refactor this handler so every error is wrapped with
fmt.Errorf("...: %w", err), respectsctx.Err()on cancellation, and logs the request ID from context. Show the call chain.
Example: the model adds if errors.Is(err, context.DeadlineExceeded) { return 504 } and wraps DB errors as fmt.Errorf("load user %d: %w", id, err).
5. Interface shrinking
Task: replace a 12-method interface with 2-3 focused ones.
Prompt:
This interface has 12 methods. Split it into minimal interfaces following the Go proverb "the bigger the interface, the weaker the abstraction". Show which consumer needs which interface.
Example: UserStore splits into UserReader (Get) and UserWriter (Save), letting tests mock only UserReader.
6. Goroutine leak audit
Task: find goroutines that outlive their function.
Prompt:
Find goroutine leaks in this code. For each, show the blocking operation and the missing
ctx.Done()orclose(ch)case. Provide a fix usingselectwithctx.Done().
Example: a worker reading from an unbuffered channel with no cancellation path — the model adds case <-ctx.Done(): return.
7. Convert callback code to channels
Task: turn a callback-based API into idiomatic Go.
Prompt:
Convert this callback API into a channel-based one. Return
<-chan Result, close it on completion, and propagate errors via a separate error channel or a struct field. Explain when to prefer each.
Example: a FetchAll(cb func(Item)) becomes FetchAll(ctx) <-chan Item with a buffered channel of size 16 to avoid blocking producers.
8. Benchmark and alloc report
Task: get a benchmark and an allocation count.
Prompt:
Write a
BenchmarkXusingb.ReportAllocs(). Then suggest two changes that reduce allocations, and predict thens/opandB/opdirection for each.
Example: replacing fmt.Sprintf with strconv.Itoa in a hot loop — the model predicts fewer B/op and shows the diff.
9. Generics instead of interface{}
Task: add type safety without code duplication.
Prompt:
Rewrite this
map[string]interface{}-based cache as a genericCache[K comparable, V any]. Keep the API compatible and show the migration for two call sites.
Example: func (c *Cache[K,V]) Get(k K) (V, bool) replaces runtime type assertions.
10. Mutex vs channel decision
Task: pick the right concurrency primitive with a rationale.
Prompt:
Given this shared state, decide between
sync.Mutex,sync.RWMutex,atomic, and a channel. List the access pattern (read-heavy, write-heavy, ownership transfer) and pick one. Show the code.
Example: a read-heavy config map gets sync.RWMutex; a job queue gets a channel.
11. Error taxonomy and sentinel errors
Task: define errors that callers can check with errors.Is.
Prompt:
Define sentinel errors for this package (
ErrNotFound,ErrConflict). Show how to wrap them with%wand how a caller distinguishes them witherrors.Is. Do not use string matching.
Example: var ErrNotFound = errors.New("not found") and return fmt.Errorf("user %d: %w", id, ErrNotFound).
12. Code review checklist
Task: a reusable review prompt for PRs.
Prompt:
Review this diff as a Go reviewer. Check: error wrapping, goroutine leaks, data races, context propagation, and interface size. Output a table: line, issue, severity, fix.
Example output: a Markdown table with `pkg/store.go:42
| missing %w | high | wrap with fmt.Errorf`.
Takeaways
A good prompt names the pattern to avoid (C-style loops, interface{}) and the primitive to use (range, generics, context). The model is not a Go expert — you are; the prompt just forces it to show its reasoning.
Start with prompts 1-3 today: they catch the most common idiomatic and race bugs before code review.