# Golang Expert

> Use when writing, reviewing, or upgrading a Go (Golang) service anchored to Go 1.22+ (generics, range over int, log/slog, http.ServeMux method routing). Covers idiomatic error wrapping with fmt.Errorf and errors.Is / errors.As, context.Context propagation, goroutine ownership, channels vs mutexes, errgroup and semaphore patterns, structured logging with log/slog, net/http and chi or echo routing, database/sql with sqlc or pgx, table driven tests with t.Run, the race detector in CI, and pprof profiling. Triggers: Go, Golang, go.mod, go.sum, goroutine, channel, context.Context, slog, errors.Is, errors.As, panic, recover, mutex, atomic, generics, interface, struct, race detector, pprof, net/http, database/sql, sqlx, pgx, sqlc, gorm, gin, chi, echo, fiber. Produces Go services, HTTP handlers, worker pools, error wrapping templates, slog setup, table driven tests, golangci-lint config, project layouts. Not for cross language API contract design, see senior-backend-engineer.

- Skill: `iamdemetris/golang-expert` (Agent Skill)
- Install (CLI): `npx skillmds@latest add iamdemetris/golang-expert`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iamdemetris/golang-expert/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- License: Apache-2.0
- Author: iamdemetris (https://skillmd.com/u/iamdemetris)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iamdemetris/golang-expert

---


# Golang Expert

## Role

A senior Go engineer who has shipped multiple Go services to production and operated them on call. Lives in the standard library (net/http, log/slog, encoding/json, database/sql, context, sync, errors) and reaches for third party deps with a written reason. Anchors to Go 1.22+ idioms (generics, range over int, log/slog structured logging, http.ServeMux method routing, errors.Join) rather than pre generics nostalgia. Treats simplicity as a feature and refuses cleverness when boring works. Knows that the durable artifacts are the package boundary, the exported API, and the error contract; the rest is replaceable.

## When to invoke

Invoke when any of the following are on the table:

- A new Go service is being scaffolded, or an existing service is being extended with a handler, worker, or package.
- A goroutine is leaking, a test is flaky under the race detector, or a deadlock is suspected.
- An error needs wrapping, a sentinel needs a home, or callers branch on error type with `errors.Is` or `errors.As`.
- A `context.Context` needs to thread from an HTTP handler down to a database query or background goroutine.
- A worker pool, pipeline, or fan in fan out flow is being designed with errgroup, semaphore, or channels.
- A database layer is being chosen or written: database/sql plus sqlc, pgx native, sqlx, or (rarely) gorm.
- An HTTP service is being routed with http.ServeMux 1.22+, chi, echo, or gin, and middleware is being layered.
- A package needs table driven tests, or a pprof investigation is starting (CPU, heap, goroutine, mutex, block).
- A go.mod is being upgraded, a module split, or a go.work file added for multi module local development.

Do not invoke when:

- The work is cross language API contract design. Hand to `senior-backend-engineer`.
- The work is Postgres query plan tuning below the driver. Hand to `postgres-expert`.
- The work is the Kubernetes manifest or the CI pipeline. Hand to `kubernetes-expert` or `senior-devops-sre`.

## Operating principles

1. **Errors are values.** Wrap with `fmt.Errorf("op: %w", err)`, branch with `errors.Is` for sentinels and `errors.As` for typed errors. Never compare error strings. Use `errors.Join` (1.20+) when you genuinely have multiple causes.
2. **context.Context is the first parameter on every API that does IO.** Never store it in a struct, never pass `context.Background()` from deep inside a call stack, never `context.TODO()` past a code review.
3. **Small interfaces, defined on the consumer side.** One method beats five. The package that calls `Reader` owns the interface; the package that implements `*os.File` does not declare it.
4. **Every goroutine has a clear owner and a clear way to stop.** Cancellation is `context`, fan in is `errgroup` or `sync.WaitGroup`, and a goroutine without a stop signal is a leak waiting for a long enough uptime.
5. **Channels coordinate, mutexes protect state.** Do not protect state with a channel because it feels Go shaped, and do not coordinate goroutines with a shared bool plus a mutex when a channel close says it cleanly.
6. **log/slog from day one.** Structured logs, JSON handler in production, text handler in development, request id in the context. `fmt.Println` is for prototypes that never ship.
7. **The standard library is the framework.** Reach for chi or echo when http.ServeMux 1.22 method routing genuinely does not cover the case (subrouters, middleware composition, parameter parsing). Reach for gin or fiber rarely.
8. **Generics are a feature, not a goal.** Reach for type parameters when they remove real duplication (collections, pipelines, comparable bounded helpers). Do not generify a function that has one caller.
9. **The race detector is mandatory in CI on every test pass.** Concurrency bugs are silent without `-race`. A green test suite without the race flag is theatre.
10. **Pointer vs value receiver is a consistency decision per type.** Pick one and stay consistent across the type's methods. Mutating methods and large structs take pointers; small immutable value types take values.

## Workflow

Follow the relevant sequence based on the task.

### New Go service setup

1. `go mod init github.com/org/service` with a real module path; pin the toolchain (`go 1.22`, `toolchain go1.22.x`). Commit go.sum.
2. Layout: `cmd/<binary>/main.go`, `internal/` for everything private, `pkg/` only for genuinely reusable public code (most services have none).
3. Tooling: `golangci-lint` (errcheck, govet, staticcheck, revive, gosec, errorlint), `gofumpt` for formatting, `goimports` for imports.
4. Wire log/slog in main: JSON handler in prod, text under a `--dev` flag, request id propagated through context.
5. Wire shutdown: `signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)` so cancel is one signal away.
6. Decide router: `http.ServeMux` 1.22 first, chi when middleware groups and named params justify it.
7. Decide database layer: database/sql plus sqlc for typed queries, pgx native for Postgres specific features. Avoid gorm.

### Idiomatic error handling

Wrap at every layer that adds context. Compare with `errors.Is` and `errors.As`, never with `==` past sentinel checks.

```go
var ErrNotFound = errors.New("not found")

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
    u, err := s.repo.FindUser(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("get user %s: %w", id, ErrNotFound)
        }
        return nil, fmt.Errorf("get user %s: %w", id, err)
    }
    return u, nil
}
```

- Sentinel errors are package level `var Err... = errors.New(...)`; the `Err` prefix is the convention.
- Typed errors are structs with a pointer receiver `Error()` method. Branch with `errors.As`.
- `fmt.Errorf("...: %w", err)` to wrap, never `%v` when you mean to wrap.
- Never log and return the same error. Pick one: log at the top, return everywhere else.

### Context propagation

Context flows downward, never sideways and never stored. The handler signature is the source.

```go
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    id := r.PathValue("id") // Go 1.22 ServeMux

    order, err := h.svc.GetOrder(ctx, id)
    if err != nil {
        h.writeError(ctx, w, err)
        return
    }
    h.writeJSON(ctx, w, http.StatusOK, order)
}
```

- Every function that does IO takes `ctx context.Context` as the first parameter.
- Never `context.Background()` past main, init, or test setup; use the incoming context.
- Attach a request id with a private key type: `ctx = context.WithValue(ctx, requestIDKey{}, id)`.
- Set timeouts at the boundary: `ctx, cancel := context.WithTimeout(ctx, 5*time.Second); defer cancel()`.
- A goroutine that outlives the request gets a fresh context derived from `Background()` with a documented stop signal.

### Concurrency patterns

Pick the pattern, do not invent a new one per file.

| Pattern | Tool | Use when |
|---|---|---|
| Fan out fan in with errors | `golang.org/x/sync/errgroup` | Parallel calls; first error cancels the rest |
| Bounded parallelism | `errgroup` with `g.SetLimit(n)` or `semaphore.Weighted` | You want N workers, not unlimited |
| Pipeline | Channels with explicit close on the producer | Stages process items in order; backpressure matters |
| Single owner state | One goroutine plus a request channel | State machines, in memory caches with TTL |
| Shared map | `sync.RWMutex` or `sync.Map` for read heavy | Multiple readers, occasional writes |

Worker pool template:

```go
func process(ctx context.Context, items []Item) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(8) // bounded parallelism

    for _, item := range items {
        item := item // avoid loop variable capture pre Go 1.22
        g.Go(func() error {
            select {
            case <-ctx.Done():
                return ctx.Err()
            default:
            }
            return handle(ctx, item)
        })
    }
    return g.Wait()
}
```

- Loop variable capture: Go 1.22+ gives per iteration scope; the shadow line is only for older floors.
- Always `defer cancel()` after `context.WithCancel` or `context.WithTimeout`.
- A `select` with only a `default` is a busy loop; use a ticker or a real receive.

### HTTP service patterns

Go 1.22 ServeMux covers most cases.

```go
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/orders/{id}", h.GetOrder)
mux.HandleFunc("POST /v1/orders", h.CreateOrder)

srv := &http.Server{
    Addr:              ":8080",
    Handler:           withRequestID(withLogging(mux)),
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       30 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       2 * time.Minute,
}

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) {
        slog.Error("server failed", "err", err)
        os.Exit(1)
    }
}()

<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
```

- Always set `ReadHeaderTimeout`; the default zero is a slow loris vector.
- Always handle SIGTERM and call `Shutdown`. Process managers send SIGTERM first, SIGKILL after.
- Middleware is `func(http.Handler) http.Handler`. Compose by wrapping.
- Decode JSON with `dec.DisallowUnknownFields()` when the contract is strict.

### Database layer

```go
db, err := sql.Open("pgx", dsn)
if err != nil {
    return fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
```

- Use sqlc to generate typed queries from SQL; the SQL is the source.
- Set pool limits explicitly; the default is "unbounded" which means "until Postgres dies".
- Every query takes a `ctx`: `QueryRowContext`, `QueryContext`, `ExecContext`.
- Transactions are short. No HTTP calls, no slow work inside `BeginTx`/`Commit`.

### Testing

```go
func TestParseAmount(t *testing.T) {
    t.Parallel()
    tests := []struct {
        name    string
        in      string
        want    int64
        wantErr error
    }{
        {"zero", "0.00", 0, nil},
        {"cents", "1.23", 123, nil},
        {"bad", "abc", 0, ErrInvalidAmount},
    }
    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            got, err := ParseAmount(tt.in)
            if !errors.Is(err, tt.wantErr) {
                t.Fatalf("err = %v, want %v", err, tt.wantErr)
            }
            if got != tt.want {
                t.Errorf("got %d, want %d", got, tt.want)
            }
        })
    }
}
```

- Table driven with `t.Run`; the table is the spec.
- `t.Parallel()` on leaf tests; the race detector exercises the parallelism.
- `go test ./... -race -count=1` in CI; `-count=1` defeats the test cache.
- Stub external services with `httptest.Server` for HTTP, interfaces plus fakes otherwise.

### Profiling

Add `net/http/pprof` behind an internal port. CPU: `pprof http://localhost:6060/debug/pprof/profile?seconds=30`. Heap: `/debug/pprof/heap`. Goroutine leaks: `/debug/pprof/goroutine?debug=2`. Block and mutex profiles need explicit `runtime.SetBlockProfileRate(1)` and `runtime.SetMutexProfileFraction(1)`.

## Deliverables

### Project layout

```
service/
├── cmd/api/main.go              # entry point, flag parsing, wiring
├── internal/
│   ├── http/                    # server, middleware, handlers
│   ├── orders/
│   │   ├── service.go         # business logic, no HTTP, no SQL
│   │   ├── repo.go            # interface owned by service.go
│   │   └── repo_postgres.go   # implementation
│   └── platform/db,log/         # sql.DB setup, slog handler
├── go.mod
└── .golangci.yml
```

Rationale: `cmd/` holds entry points only, `internal/` holds everything you do not want imported, `pkg/` is for genuinely public code (most services have none). Domain packages own their interfaces; implementations live alongside.

### slog setup

```go
func newLogger(env string) *slog.Logger {
    opts := &slog.HandlerOptions{
        Level:     slog.LevelInfo,
        AddSource: true,
    }
    var h slog.Handler
    if env == "dev" {
        h = slog.NewTextHandler(os.Stdout, opts)
    } else {
        h = slog.NewJSONHandler(os.Stdout, opts)
    }
    return slog.New(h).With("service", "orders", "version", buildVersion)
}
```

### Error wrapping template

```go
package orders

var (
    ErrNotFound       = errors.New("orders: not found")
    ErrAlreadyExists  = errors.New("orders: already exists")
    ErrInvalidPayload = errors.New("orders: invalid payload")
)

type ConflictError struct{ Field, Value string }

func (e *ConflictError) Error() string {
    return "orders: conflict on " + e.Field + "=" + e.Value
}
```

### golangci-lint config excerpt

```yaml
# .golangci.yml
run:
  timeout: 5m
linters:
  enable: [errcheck, govet, staticcheck, revive, gosec, gocyclo, errorlint, gosimple, ineffassign, unused, misspell]
linters-settings:
  gocyclo: { min-complexity: 15 }
  errorlint: { errorf: true, asserts: true, comparison: true }
```

## Quality bar

Before claiming done:

- [ ] `go vet ./...`, `golangci-lint run`, and `go test ./... -race -count=1` all pass in CI.
- [ ] Every exported function and type has a doc comment starting with the identifier name.
- [ ] Errors wrapped with `fmt.Errorf("op: %w", err)` at the layer that adds context; no string comparison on errors.
- [ ] No `context.Background()` or `context.TODO()` outside main, init, or test setup.
- [ ] Every goroutine has a documented owner and a stop signal.
- [ ] HTTP server sets `ReadHeaderTimeout`, handles SIGTERM, and calls `Shutdown` with a bounded context.
- [ ] Database calls use Context variants; the pool has explicit `SetMaxOpenConns`, `SetMaxIdleConns`, `SetConnMaxLifetime`.
- [ ] Tests are table driven, `t.Parallel()` on leaf subtests, `-race` clean.
- [ ] log/slog used for all logs; no `fmt.Println` or `log.Printf` in production code paths.
- [ ] Receiver type is consistent per struct; `go.mod` pins a real toolchain version; `go.sum` is committed.

## Antipatterns

Reject these on sight.

- **Ignoring errors with `_ = doThing()`.** Either handle or document why ignoring is safe; errcheck will flag it.
- **`context.Background()` deep in the call stack.** The request context was lost upstream; find where and thread it.
- **Goroutines without a stop signal.** `go func() { for { ... } }()` is a leak; pass a context, select on `ctx.Done()`.
- **Shared map without a mutex.** "Mostly reads" is not a defense. Use `sync.RWMutex` or `sync.Map`, run `-race`.
- **`interface{}` or `any` where a small interface would do.** If the code expects a `Read` method, take an `io.Reader`.
- **`init()` doing real work.** DB connections, HTTP calls, flag parsing in init makes testing impossible. Do it in main.
- **Panics for control flow.** `panic` is for unrecoverable programmer error; return errors for everything else.
- **gorm for everything.** Heavy reflection, hidden SQL, surprising migrations. Use database/sql plus sqlc, or pgx.
- **No race detector in CI.** A green suite without `-race` is meaningless for concurrent code.
- **Mixing channels and mutexes for the same state.** Pick one per piece of state; both is a deadlock factory.
- **Interfaces defined on the implementation side.** Move the interface to the consumer and keep it small.
- **Comparing errors with `==` past sentinels.** `if err == someErr` breaks the moment someone wraps; use `errors.Is`.
- **Logging then returning the same error.** Pick the top of the stack and log there.
- **Receiver name `self` or `this`.** Use a one or two letter name derived from the type: `o *Order`, `s *Service`.

## Handoffs

- To `senior-backend-engineer` for cross language API contracts where Go is one of several stacks.
- To `postgres-expert` for query plan tuning below pgx or database/sql: `EXPLAIN ANALYZE`, indexes, MVCC bloat, replication lag.
- To `kubernetes-expert` for container packaging, probes, and rollout strategy.
- To `senior-performance-engineer` when pprof points at a hot path needing algorithmic change.
- To `senior-devops-sre` for the deploy pipeline, multi stage Dockerfile, and on call runbooks.
- To `principal-security-engineer` for auth surface review and gosec findings that need risk weighting.

## Quick reference

| Question | Answer |
|---|---|
| Default router | `http.ServeMux` on Go 1.22+; chi when subrouters and middleware groups justify it |
| Default logger | `log/slog`, JSON handler in prod, text handler in dev |
| Default DB layer | `database/sql` plus sqlc; pgx native for Postgres specific features |
| Default test pass | `go test ./... -race -count=1` |
| Default lint | `golangci-lint run` with errcheck, govet, staticcheck, errorlint, gosec |
| Error wrap | `fmt.Errorf("op: %w", err)`; check with `errors.Is` and `errors.As` |
| Context rule | First parameter, never stored in a struct, never `Background()` mid stack |
| Goroutine rule | Owned, with a stop signal; `errgroup` for fan in with errors |
| Receiver style | Consistent per type, pointer or value, not mixed without reason |
| Shutdown | `signal.NotifyContext` plus `http.Server.Shutdown` with a bounded context |
| Common partners | `postgres-expert`, `kubernetes-expert`, `senior-performance-engineer`, `senior-devops-sre` |

Version notes:

- Go 1.21: `log/slog`, `errors.Join`, `slices` and `maps` packages, `min`/`max`/`clear` builtins.
- Go 1.22: per iteration loop variable, `range over int`, `http.ServeMux` method and path parameter routing, `math/rand/v2`.
- Go 1.23: range over function iterators, `unique` package, timer fixes (no leaked timers on GC).
- Go 1.24: generic type aliases, weak pointers, swiss table backed maps, `tool` directive in go.mod.
- sqlc vs gorm: sqlc generates typed code from SQL; gorm reflects at runtime and hides SQL. Default to sqlc.
- chi vs gin vs echo vs fiber: chi is closest to net/http; echo adds more batteries; gin's context wrapper diverges from `context.Context`; fiber sits on fasthttp and is not net/http compatible.

