From Idiomatic Go to Data Race Hunting: 12 Prompts That Turn an LLM Into a Go Pair Programmer

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 range for iteration, early returns, named constants, and wrap errors with %w. Do not use interface{} — 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
}
What to check first Ask for 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).

Key distinction A channel serializes ownership transfer; a mutex serializes access. If the data has one owner at a time, use a channel; if many readers need the same struct, use 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(), and errors.Is for 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), respects ctx.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() or close(ch) case. Provide a fix using select with ctx.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 BenchmarkX using b.ReportAllocs(). Then suggest two changes that reduce allocations, and predict the ns/op and B/op direction 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 generic Cache[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 %w and how a caller distinguishes them with errors.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.

← All posts

Ask AI about this article

Ask a question about this article — ASI Biont will answer based on its text.