Go, LLM, and the Race Against Races: How to Prompt for Idiomatic Concurrency, Explicit Errors, and Context-Aware Code

Ask an LLM to "write Go code" and you often get a Java-flavored mutant: a panic where an error should be, a sync.Mutex guarding everything, and a context that exists only in the README. The code compiles. It may even pass a happy-path test. Then it meets a network timeout, a cancelled request, or two goroutines touching the same map — and the failure is silent, intermittent, and expensive.

The difference between that output and production-grade Go is rarely the model. It is the prompt. A prompt that names Go's idioms — explicit error returns, channels over shared memory, context.Context threaded through every blocking call — consistently produces code that a Go reviewer would not immediately reject.

This roundup is built around a single criterion: if the task involves concurrency or network calls, you need prompts that specify concurrency and context requirements; if it is simple CRUD, a basic prompt with an explicit style instruction is enough. Below are 12 prompts, each with the failure it prevents and a usage example. Copy them, adapt the bracketed parts, and keep them in a snippet library.

The two modes of working with an LLM in Go

Before the prompts, a mental model. Most bad Go from LLMs falls into two buckets:

  • Style drift: the model writes Go syntax with another language's habits — exceptions instead of errors, classes instead of structs, inheritance-like embedding abuse.
  • Concurrency drift: the model adds goroutines because they look impressive, then protects them with a mutex and forgets cancellation, backpressure, and defer cleanup.

A prompt can fix style in one sentence. Concurrency needs structure: you must state the synchronization primitive, the cancellation path, and the shutdown condition. The prompts below are ordered roughly from "always useful" to "only for concurrent and networked code."

Key distinction A basic prompt tells the model what to build. A concurrency prompt tells it how to stop: who cancels the context, who closes the channel, and what happens when the caller disappears. If your prompt has no answer to those three questions, the model will invent one — usually wrong.

Prompt 1: The idiomatic baseline — explicit errors, no panics

For: any task, from a CLI tool to a service. This is the prompt you paste before anything else.

Why it works: Go's error handling is not a limitation to be worked around; it is the contract. Telling the model that panic is reserved for programmer errors and unrecoverable states removes its favorite shortcut.

Prompt (copy-paste):

Write Go code for [task]. Requirements:
- Return errors explicitly; wrap with fmt.Errorf("doing X: %w", err).
- Never use panic for expected failures (I/O, network, validation).
- Define sentinel errors with errors.New for cases callers must check.
- No global mutable state.
- Use structs with unexported fields and constructor functions.
- Include a short example of how the caller handles each returned error.

Usage example: ask for a config loader. A weak answer returns panic("config not found"). With this prompt you get return fmt.Errorf("loading config %q: %w", path, err) and a caller that checks errors.Is(err, ErrConfigMissing). That difference is the entire debuggability of your service at 3 a.m.

Prompt 2: Channels over mutexes — and when the model should push back

For: producer–consumer pipelines, fan-out workers, event buses.

Why it works: LLMs default to mutexes because they are the lowest-common-denominator synchronization primitive across languages. Forcing a channel-based design makes ownership explicit — and ownership is what prevents races.

Prompt:

Design [pipeline] using goroutines and channels. Rules:
- Prefer channels for handoff of data between goroutines.
- Use sync.Mutex only for guarding a small struct's fields; justify each use in a comment.
- State clearly which goroutine owns each channel and who closes it.
- Show the shutdown path: how does a producer stop when the consumer is gone?
- No goroutine may outlive the function that started it without a documented reason.

Usage example: a log-processing fan-out. The model will typically produce a jobs := make(chan Job), N workers, and a sync.WaitGroup. The prompt forces it to add close(jobs) in the producer and a done channel for early exit — two lines that prevent a goroutine leak.

Where people go wrong The most common LLM concurrency bug is not a data race — it is a goroutine leak. The code passes go test and then memory climbs for days. Always ask the model to show the shutdown path explicitly, and verify with go test -race and a goroutine-count check.

Prompt 3: Context threading — every blocking call gets a deadline

For: HTTP handlers, database calls, gRPC clients, anything that can hang.

Why it works: context.Context is Go's cancellation contract, but models often accept it as a parameter and never check it. The prompt makes propagation testable.

Prompt:

Implement [function] with context.Context as the first parameter.
- Propagate ctx to every blocking call (HTTP, DB, channel receive via select).
- Never store ctx in a struct.
- Add a select on ctx.Done() around every channel receive and long loop.
- Return ctx.Err() unwrapped when cancellation is the cause.
- Show a caller that sets a 2-second timeout.

Usage example: an HTTP client wrapper. Without the prompt, the model writes http.Get(url) — no timeout, no cancellation. With it, you get http.NewRequestWithContext(ctx, ...) and a select that returns promptly when the caller disconnects.

Prompt 4: The race-detector contract

For: any code with shared state, before you merge.

Why it works: you cannot ask a model to "be careful." You can ask it to enumerate shared state and prove each access is synchronized.

Prompt:

Review this Go code for data races:
[code]
For each variable shared between goroutines, list:
1. Which goroutines read and write it.
2. The synchronization that protects each access (mutex, channel, atomic).
3. Any access path that is unsynchronized.
Then rewrite the code so that `go test -race` would pass, and explain the change.

Usage example: a counter incremented inside go func(). The model will identify count++ as unsynchronized and propose either atomic.Int64 or a channel-based aggregator — and, crucially, tell you which one fits the access pattern. This is also a natural companion to the bug-hunting workflow in Your Second Reviewer Never Sleeps.

Prompt 5: Error wrapping and sentinel errors for a real API

For: library or service boundaries where callers must distinguish failure modes.

Why it works: wrapping with %w preserves the chain for errors.Is and errors.As, but models often wrap with %v, silently breaking it. The prompt makes the contract explicit.

Prompt:

Define the error contract for [package]:
- Sentinel errors for conditions callers must branch on.
- Wrap with %w, never %v, when adding context.
- A custom error type with errors.As support for [validation failure].
- Show three caller examples: errors.Is, errors.As, and a generic log.

Usage example: a payment client. You get ErrInsufficientFunds, ErrCardDeclined, and a ValidationError type — so the caller does not string-match error messages, which is the classic silent breakage when wording changes.

Prompt 6: The table-driven test with a cancellation case

For: proving concurrency code actually behaves under cancellation.

Why it works: the model writes happy-path tests unless told otherwise. Requiring a cancellation case forces it to exercise ctx.Done().

Prompt:

Write table-driven tests for [function] using testing package only.
Cases must include: success, error return, context cancellation mid-flight, and timeout.
Use t.Run for subtests. For the cancellation case, cancel the context after 10ms and assert the function returns ctx.Err().

Usage example: a worker pool. The cancellation case catches the classic bug where the worker ignores ctx and blocks on a channel send forever. This prompt pairs well with the debugging prompts in The LLM Detective.

Prompt 7: Bounded concurrency — no unbounded goroutine spawn

For: fan-out over a list of URLs, rows, or messages.

Why it works: for _, x := range xs { go f(x) } is the most common LLM concurrency pattern and the fastest way to exhaust file descriptors. The prompt caps it.

Prompt:

Process [N items] concurrently with a bounded worker pool.
- Maximum K goroutines (K configurable, default 8).
- Use a semaphore (buffered channel) or errgroup with SetLimit.
- Collect the first error and cancel remaining work via context.
- Return all results in input order.

Usage example: fetching 10,000 URLs. Unbounded, this opens 10,000 connections; bounded at 8, it finishes predictably and respects rate limits. Note the trade-off: bounded concurrency is slower on a fast local network but stable in production — choose based on the downstream service's limits, not your benchmark.

Prompt 8: Graceful shutdown with signal handling

For: services, workers, CLIs that hold resources.

Why it works: shutdown is where context, channels, and defer meet. Models routinely forget signal.NotifyContext and leak connections on SIGTERM.

Prompt:

Write the main function for [service] with graceful shutdown:
- signal.NotifyContext for SIGINT and SIGTERM.
- Pass ctx to the server; on cancel, stop accepting and wait for in-flight work with a timeout.
- Close all resources with defer in reverse order.
- Log shutdown steps.

Usage example: an HTTP server. You get srv.Shutdown(ctx) with a 5-second deadline and a log line per phase — the difference between a clean deploy and dropped requests during rollout.

Prompt 9: Select with default — non-blocking and backpressure

For: queues, metrics collectors, anything that must not block the caller.

Why it works: select with default is a subtle idiom; models often omit it and create hidden blocking.

Prompt:

Implement [enqueue function] that never blocks the caller.
- Use select with default to drop or buffer when full.
- Expose a metric or counter for drops.
- Document the backpressure policy: drop, block, or grow.

Usage example: a metrics channel. The model writes select { case ch <- m: default: atomic.AddInt64(&dropped, 1) } — and you can alert on drops instead of discovering latency spikes.

Prompt 10: Refactor a mutex-heavy file to channels (or the reverse)

For: legacy code that "works" but is unmaintainable.

Why it works: conversion prompts force the model to reason about ownership, not just syntax.

Prompt:

Refactor this file from mutex-based sharing to channel-based ownership:
[code]
Preserve the public API. Explain which invariants the mutex protected and how the channel design preserves them. If the mutex is a better fit, say so and explain why — do not refactor for its own sake.

Usage example: a cache struct with a mutex and a Get/Set. The model may correctly conclude that a sync.RWMutex is the right tool and a channel would add latency — a useful, non-obvious answer that a naive prompt would never produce.

Prompt 11: The style linter prompt — enforce Go conventions in review

For: reviewing LLM output before commit.

Why it works: it turns the model into a reviewer with a checklist, catching the style drift from the introduction.

Prompt:

Review this Go code against Go conventions:
[code]
Check: error strings lowercase without punctuation; no stuttering names (user.UserID → user.ID); interfaces defined at the consumer, not the producer; no init() with side effects; defer before the resource is used; short variable names in short scopes.
Return a numbered list of violations with fixes.

Usage example: a package that returns errors.New("Failed to load."). The reviewer flags the capital and the period, and suggests errFailedToLoad. Small, but it is what makes a codebase feel like Go.

Prompt 12: The decision prompt — should this even be concurrent?

For: the beginning of any task where concurrency is tempting.

Why it works: it inverts the default. Instead of "make it concurrent," it asks the model to justify concurrency against a sequential baseline.

Prompt:

For [task], compare a sequential implementation and a concurrent one.
- Estimate whether the work is I/O-bound or CPU-bound.
- State the expected speedup and its ceiling (Amdahl's law qualitatively).
- List the new failure modes: races, leaks, deadlocks, partial failure.
- Recommend one and justify it in three sentences.

Usage example: parsing 200 small JSON files. The model may conclude that sequential parsing is fast enough and concurrency adds leak risk — saving you a week of debugging a race in code that did not need to exist.

The non-obvious insight The best concurrency prompt is often the one that talks the model out of concurrency. Go makes goroutines cheap to write and expensive to debug; a prompt that demands a justification for each goroutine prevents more incidents than one that demands more of them.

When a basic prompt is enough

Not every task needs the full battery. For a CRUD handler, a repository layer, or a CLI flag parser, Prompts 1, 5, and 11 cover 90% of the value. Adding concurrency requirements to sequential code produces over-engineered output — mutexes guarding a single-threaded struct, contexts in functions that never block. Match the prompt to the task: concurrency and network calls justify the heavy prompts; pure data transformation does not.

A compact decision table

Task type Prompts to use Main risk if skipped
Simple CRUD, CLI, parsing 1, 5, 11 Style drift, %v instead of %w
HTTP handlers, DB calls 1, 3, 6, 8 Missing timeouts, leaked connections
Fan-out over many items 2, 7, 9 Unbounded goroutines, FD exhaustion
Shared state, caches 4, 10 Data races, silent corruption
Before committing LLM output 4, 11 Races and style debt reaching main

What this looks like in practice

Imagine a service that fetches prices from three suppliers and merges the results. A naive prompt yields three goroutines, a sync.WaitGroup, a shared map written without a lock, and no timeout — a race that appears under load and a hang when one supplier is slow.

Applying Prompts 3, 4, and 7 in sequence yields: errgroup.WithContext with a limit of 3, results collected through a channel, the first error cancelling the rest, and a 2-second deadline on each HTTP call. The code is longer by about 15 lines and shorter by an entire incident.

Run go test -race on both versions. The first fails intermittently; the second passes. That is the measurable difference a prompt makes.

If you want to go deeper into the language-level trade-offs behind these patterns — including where Go's concurrency model meets systems programming constraints — see Go and Rust — Systems Programming: A Real-World Task and Pitfalls. For the broader question of turning LLM output into a reliable engineering process rather than a demo, LLM Engineering with LangChain: RAG and Agents is a useful companion.

The takeaway is simple: an LLM writes the Go you describe. Describe errors, ownership, cancellation, and shutdown — and you get code that survives production. Describe only the feature, and you get a demo that compiles. Keep these 12 prompts in your snippet library, start every Go task with Prompt 1, and add the concurrency prompts the moment a go keyword appears in your requirements.

← All posts

Ask AI about this article

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