# The Go mistakes language models actually make

> Source: https://learn-go.org/review/failure-modes/
> Part of Learn Go, free to read.

Go's compiler eliminates most of what makes generated code in other languages risky. What survives clusters almost entirely in one area — concurrency — plus a handful of idiom mistakes that come from training data older than the current standard library.

That is a short list, which is the good news. It is also a list of the hardest bugs to find, which is the bad news.

## Concurrency

### 1. Goroutine leaks

```go
func fetchAll(urls []string) []Result {
    ch := make(chan Result)
    for _, u := range urls {
        go func(u string) { ch <- fetch(u) }(u)   // blocks forever if nobody reads
    }
    var out []Result
    for i := 0; i < 3; i++ {                       // reads 3, started len(urls)
        out = append(out, <-ch)
    }
    return out
}
```

Every unread goroutine blocks on the send and never exits. Under load the process accumulates goroutines until it dies. Nothing fails in a test.

**Correct:** buffer the channel to the number of sends, or use `errgroup` with a context, and always have a way for a goroutine to be cancelled.

**Catch it with:** `go.uber.org/goleak` in `TestMain`. Three lines, and it turns every leak into a test failure.

```go
func TestMain(m *testing.M) { goleak.VerifyTestMain(m) }
```

### 2. Unbuffered channel deadlock

```go
ch := make(chan int)
ch <- 1          // deadlock: nothing is receiving yet
fmt.Println(<-ch)
```

Generated code frequently reaches for an unbuffered channel where a buffered one or a `sync.WaitGroup` is meant. **Catch it with:** `go test -race` and a timeout — `go test -timeout 30s` turns a deadlock into a failure with a full goroutine dump.

### 3. WaitGroup misuse

`wg.Add(1)` inside the goroutine instead of before it; `defer wg.Done()` missing; passing a `WaitGroup` by value so each goroutine gets a copy. All three appear regularly and all three produce a program that either exits early or hangs.

### 4. Unsynchronised shared state

```go
counter := 0
for i := 0; i < 100; i++ {
    go func() { counter++ }()      // data race
}
```

**Catch it with:** `go test -race`. Always. Put it in `make check` and never take it out — the race detector is the single highest-value tool in Go and it finds this class of bug reliably.

### 5. Context not propagated, or ignored

```go
func (s *Service) Get(ctx context.Context, id string) (*User, error) {
    return s.db.Query("SELECT ...")        // ctx dropped; no cancellation, no timeout
}
```

The generated function accepts a context because the signature convention says so, then never uses it. Cancellation and timeouts silently stop working, and a request that the client abandoned keeps a database connection busy.

**Catch it with:** `go vet`'s `lostcancel`, plus `contextcheck` in `golangci-lint`, plus grep: `ctx context.Context` in a signature with no `ctx` in the body is always worth a look.

## Errors

### 6. Wrapping with `%v` instead of `%w`

```go
return fmt.Errorf("load user: %v", err)    // breaks errors.Is / errors.As
```

The error message looks identical, so nothing appears wrong until a caller's `errors.Is(err, sql.ErrNoRows)` stops matching. **Catch it with:** `errorlint` in `golangci-lint`.

### 7. Errors ignored with `_`

Explicit, greppable, and still generated — most often on `defer f.Close()` for a writable file, where the close error is the one that tells you the write failed.

```bash
git diff | grep -nE '_ = |_, _ ='
```

**Catch it with:** `errcheck` (included in `golangci-lint`).

### 8. Sentinel errors compared with `==`

`if err == ErrNotFound` fails as soon as anything in the chain wraps it. Use `errors.Is`. `errorlint` catches this too.

## Types and nil

### 9. The nil interface trap

```go
func find() error {
    var e *MyError = nil
    return e              // non-nil interface holding a nil pointer
}

if find() != nil { /* this runs */ }
```

The canonical Go gotcha, well represented in training data as an *example of a bug* — which means it also gets reproduced as code. **Correct:** return `nil` literally, never a typed nil pointer.

### 10. Slice aliasing after `append`

```go
a := []int{1, 2, 3, 4, 5}
b := a[:2]
b = append(b, 99)         // overwrites a[2] — b shares a's backing array
```

Generated slicing code assumes copies. Use `slices.Clone`, or a full slice expression `a[:2:2]` to force reallocation.

### 11. Map iteration order

Deliberately randomised in Go. Generated code that builds output by ranging a map produces results that differ between runs — flaky tests, non-reproducible files. Sort the keys.

## Stale idioms

Models trained on older Go write older Go. None of these are wrong, but they are worth correcting in `AGENTS.md` if your codebase has moved on.

| Generated | Current |
|---|---|
| `ioutil.ReadFile` | `os.ReadFile` (ioutil deprecated since 1.16) |
| `interface{}` | `any` |
| `rand.Seed(time.Now()...)` | unnecessary since 1.20; `math/rand/v2` |
| hand-rolled `min`/`max`/`contains` | builtins and `slices` package |
| `x := x` loop shadowing | unnecessary since 1.22 |
| `logrus`, `zap` by reflex | `log/slog` is stdlib |
| `github.com/pkg/errors` | `fmt.Errorf` with `%w` |

## The config that catches most of this

```yaml .golangci.yml
linters:
  enable:
    - errcheck        # ignored errors
    - errorlint       # %w, errors.Is
    - govet           # lostcancel, copylocks, printf
    - staticcheck     # the big one
    - contextcheck    # dropped contexts
    - bodyclose       # unclosed response bodies
    - rowserrcheck
    - noctx           # http requests without a context
    - gosec
```

```makefile
check:
	gofmt -l -w .
	golangci-lint run
	go test -race -count=1 -timeout 60s ./...
```

`-count=1` disables test caching, which matters when an agent is iterating — a cached pass is not a pass.

:::verdict The short version
In Go, the compiler and `golangci-lint` handle nearly everything except concurrency. So put `-race` and `goleak` in your default test command and spend your review attention on the goroutines. That is where the bugs are.
:::

:::promo hetzner
:::

## Common questions

### Is generated Go safer than generated Python?

For everything the compiler and vet can see, substantially — unused variables, type confusion, unhandled imports, silently swallowed errors are all structurally harder. Concurrency is the exception, and it is exactly where Go's own difficulty is concentrated, so the residual risk is narrow but sharp.

### Do I really need `-race` on every test run?

Yes. It roughly doubles test time and finds a class of bug that is otherwise found in production, at 3am, non-reproducibly. If your suite is too slow with it, that is an argument for a faster suite rather than for turning it off.

### Why does generated Go use deprecated stdlib functions?

Because `ioutil.ReadFile` appears in a decade of training data and `os.ReadFile` in three years of it. It is harmless as such — the functions still work — but it is a useful signal about how current the rest of the generated code is likely to be. A line in `AGENTS.md` fixes it.
