Go Expert
Senior Go backend engineer for Go 1.23+ services: stdlib-first HTTP, disciplined error and context handling, leak-free concurrency, and pgx/sqlc data access.
When to Use This Skill
- Build or extend an HTTP API/service in Go (handlers, routing, middleware, graceful shutdown)
- Fix error handling: wrapping with
%w, errors.Is/errors.As, sentinel vs typed errors
- Debug or design concurrency: goroutine leaks,
errgroup, channels, worker pools, cancellation
- Add or refactor database access with pgx v5 and sqlc (transactions, pooling, migrations)
- Write table-driven tests, integration tests with testcontainers, or fix
-race failures
- Review Go code for idiom violations, over-abstraction, or misuse of generics/interfaces
- Lay out a new Go service (
cmd/, internal/, module structure)
Core Workflow
- Analyze - Read
go.mod for the Go version and existing dependencies before adding any. Check for an existing router, logger, and DB layer and match them - do not introduce chi/zap/GORM into a stdlib/slog/pgx codebase. Look for internal/ structure and follow the established package boundaries.
- Implement - Write the change following the patterns below and in the loaded references. Stdlib first: net/http ServeMux,
log/slog, database/sql semantics via pgx. Accept interfaces, return concrete types. Every function that does I/O takes ctx context.Context as its first parameter.
- Verify (vet + lint) - Run
go vet ./..., then golangci-lint run ./... if the repo has a .golangci.yml (or golangci-lint is installed). If either reports problems, fix all reported issues and re-run until clean before proceeding. Do not suppress findings with //nolint unless there is a documented false positive.
- Test - Write or update table-driven tests for the change, then run
go test -race ./.... If any test fails or the race detector reports a data race, fix all reported issues and re-run until clean. Never delete or skip a failing test to get green; fix the code or the test's expectations deliberately.
- Prove it works - Build and run the actual entrypoint (
go run ./cmd/<service> or the repo's make target) and exercise the changed behavior: curl the new endpoint, run the migration, or trigger the job. For services, confirm graceful shutdown works (Ctrl-C returns promptly, no goroutines hang). If the run fails, fix and re-run until the observed behavior matches the requirement.
Reference Guide
Load detailed guidance only when the task needs it:
| Topic |
Reference |
Load When |
| HTTP services |
references/http-services.md |
Handlers, routing, middleware, ServeMux vs chi/echo, graceful shutdown, timeouts, JSON APIs |
| Errors & context |
references/errors-and-context.md |
Error wrapping, sentinel/typed errors, errors.Is/As, context propagation, cancellation, deadlines |
| Concurrency |
references/concurrency.md |
Goroutines, channels, errgroup, worker pools, goroutine leaks, sync primitives, data races |
| Database |
references/database-pgx-sqlc.md |
Postgres access, pgx v5 pools, sqlc codegen, transactions, migrations, why not GORM |
| Testing |
references/testing.md |
Table-driven tests, subtests, testcontainers, httptest, race detector, benchmarks, fuzzing |
Key Patterns
Go 1.22+ ServeMux - method + wildcard routing, no framework needed:
mux := http.NewServeMux()
mux.HandleFunc("GET /api/orders/{id}", h.getOrder) // method-scoped since 1.22
mux.HandleFunc("POST /api/orders", h.createOrder)
mux.HandleFunc("GET /healthz", h.health)
func (h *Handler) getOrder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // wildcard extraction is stdlib now
order, err := h.store.GetOrder(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "order not found", http.StatusNotFound)
return
}
if err != nil {
h.log.ErrorContext(r.Context(), "get order", "id", id, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, order)
}
Error wrapping - %w once per level, decide with errors.Is/As:
var ErrNotFound = errors.New("not found") // sentinel: caller branches on identity
func (s *Store) GetOrder(ctx context.Context, id string) (Order, error) {
row, err := s.q.GetOrder(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return Order{}, fmt.Errorf("order %s: %w", id, ErrNotFound) // translate driver error at the boundary
}
if err != nil {
return Order{}, fmt.Errorf("get order %s: %w", id, err)
}
return fromRow(row), nil
}
errgroup - bounded, cancellable, leak-free fan-out:
g, ctx := errgroup.WithContext(ctx) // ctx cancels when any task errors
g.SetLimit(8) // bound concurrency; never spawn unbounded goroutines
for _, id := range ids {
g.Go(func() error { // Go 1.22+: loop var is per-iteration, no capture bug
return process(ctx, id) // must honor ctx or the group can't stop it
})
}
if err := g.Wait(); err != nil { // first error; all goroutines finished
return fmt.Errorf("process batch: %w", err)
}
slog - structured, context-aware logging (not fmt.Printf, not logrus):
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(log)
log.InfoContext(ctx, "order created", "order_id", o.ID, "user_id", o.UserID, "amount_cents", o.Amount)
Common Mistakes
- Reaching for chi/echo/gin by default. Since Go 1.22,
http.ServeMux handles method routing and path wildcards (GET /users/{id}, r.PathValue). Use a framework only for real needs: route groups with shared middleware stacks, path regexes, or an existing codebase already using one. Never mix two routers.
- Storing
context.Context in a struct field. Context flows through call chains as the first function parameter, full stop. A struct-held ctx outlives its request and silently breaks cancellation. The only sanctioned exception is http.Request, which predates the rule.
- Wrapping every error with
%w reflexively - or with none. Wrap with %w when callers may need to branch (errors.Is/As) or when adding operation context; use %v (or a new error) at boundaries where you want to stop callers depending on internals (e.g., hide pgx.ErrNoRows behind your own ErrNotFound). err.Error() == "..." string comparison is always wrong.
- Fire-and-forget goroutines. Every
go statement needs an answer to "how does this goroutine stop, and who sees its error?" Use errgroup.WithContext for request-scoped work and an explicit shutdown signal (ctx.Done()) + Wait for background workers. A goroutine blocked forever on an unread channel is a leak the race detector will not find.
- Generics where an interface (or nothing) belongs. Use type parameters for data structures and slice/map helpers with two-plus concrete instantiations. Do not write
func Process[T any](...) when T is only ever one type, constrain to a one-method interface (just take the interface), or build generic "repository" abstractions over sqlc.
- ORMs (GORM/ent) as the default. For Postgres services, use pgx v5 + sqlc: SQL you can read and EXPLAIN, compile-time-checked query results, no N+1 surprises. Reach for ent only when you genuinely need a graph schema with typed traversals.
- Premature
pkg/ and giant util packages. Start with cmd/<service>/main.go (wiring only) and internal/<domain> packages named by what they provide (billing, store), not by kind (models, helpers, utils). pkg/ earns its place only when an external module actually imports the code.
http.ListenAndServe(":8080", mux) in production. Zero timeouts means slow-loris and leaked connections. Configure ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout, and shut down with srv.Shutdown(ctx) on SIGTERM.
1---2name: go-expert3description: Use when writing or reviewing Go backend services - go.mod, *.go files, net/http handlers, pgx/sqlc database code, goroutines, context.Context plumbing, or failing `go test` runs. Builds HTTP APIs on the Go 1.22+ stdlib router, fixes error-wrapping and context-cancellation bugs, designs leak-free goroutine lifecycles with errgroup, and wires pgx/sqlc data layers. Invoke for HTTP routing, error handling, concurrency, database access, slog logging, project layout, or table-driven tests.4license: MIT5---67# Go Expert89Senior Go backend engineer for Go 1.23+ services: stdlib-first HTTP, disciplined error and context handling, leak-free concurrency, and pgx/sqlc data access.1011## When to Use This Skill1213- Build or extend an HTTP API/service in Go (handlers, routing, middleware, graceful shutdown)14- Fix error handling: wrapping with `%w`, `errors.Is`/`errors.As`, sentinel vs typed errors15- Debug or design concurrency: goroutine leaks, `errgroup`, channels, worker pools, cancellation16- Add or refactor database access with pgx v5 and sqlc (transactions, pooling, migrations)17- Write table-driven tests, integration tests with testcontainers, or fix `-race` failures18- Review Go code for idiom violations, over-abstraction, or misuse of generics/interfaces19- Lay out a new Go service (`cmd/`, `internal/`, module structure)2021## Core Workflow22231. **Analyze** - Read `go.mod` for the Go version and existing dependencies before adding any. Check for an existing router, logger, and DB layer and match them - do not introduce chi/zap/GORM into a stdlib/slog/pgx codebase. Look for `internal/` structure and follow the established package boundaries.242. **Implement** - Write the change following the patterns below and in the loaded references. Stdlib first: net/http ServeMux, `log/slog`, `database/sql` semantics via pgx. Accept interfaces, return concrete types. Every function that does I/O takes `ctx context.Context` as its first parameter.253. **Verify (vet + lint)** - Run `go vet ./...`, then `golangci-lint run ./...` if the repo has a `.golangci.yml` (or golangci-lint is installed). If either reports problems, fix all reported issues and re-run until clean before proceeding. Do not suppress findings with `//nolint` unless there is a documented false positive.264. **Test** - Write or update table-driven tests for the change, then run `go test -race ./...`. If any test fails or the race detector reports a data race, fix all reported issues and re-run until clean. Never delete or skip a failing test to get green; fix the code or the test's expectations deliberately.275. **Prove it works** - Build and run the actual entrypoint (`go run ./cmd/<service>` or the repo's make target) and exercise the changed behavior: `curl` the new endpoint, run the migration, or trigger the job. For services, confirm graceful shutdown works (Ctrl-C returns promptly, no goroutines hang). If the run fails, fix and re-run until the observed behavior matches the requirement.2829## Reference Guide3031Load detailed guidance only when the task needs it:3233| Topic | Reference | Load When |34|-------|-----------|-----------|35| HTTP services | `references/http-services.md` | Handlers, routing, middleware, ServeMux vs chi/echo, graceful shutdown, timeouts, JSON APIs |36| Errors & context | `references/errors-and-context.md` | Error wrapping, sentinel/typed errors, `errors.Is/As`, context propagation, cancellation, deadlines |37| Concurrency | `references/concurrency.md` | Goroutines, channels, errgroup, worker pools, goroutine leaks, sync primitives, data races |38| Database | `references/database-pgx-sqlc.md` | Postgres access, pgx v5 pools, sqlc codegen, transactions, migrations, why not GORM |39| Testing | `references/testing.md` | Table-driven tests, subtests, testcontainers, httptest, race detector, benchmarks, fuzzing |4041## Key Patterns4243**Go 1.22+ ServeMux - method + wildcard routing, no framework needed:**4445```go46mux := http.NewServeMux()47mux.HandleFunc("GET /api/orders/{id}", h.getOrder) // method-scoped since 1.2248mux.HandleFunc("POST /api/orders", h.createOrder)49mux.HandleFunc("GET /healthz", h.health)5051func (h *Handler) getOrder(w http.ResponseWriter, r *http.Request) {52 id := r.PathValue("id") // wildcard extraction is stdlib now53 order, err := h.store.GetOrder(r.Context(), id)54 if errors.Is(err, store.ErrNotFound) {55 http.Error(w, "order not found", http.StatusNotFound)56 return57 }58 if err != nil {59 h.log.ErrorContext(r.Context(), "get order", "id", id, "err", err)60 http.Error(w, "internal error", http.StatusInternalServerError)61 return62 }63 writeJSON(w, http.StatusOK, order)64}65```6667**Error wrapping - `%w` once per level, decide with `errors.Is/As`:**6869```go70var ErrNotFound = errors.New("not found") // sentinel: caller branches on identity7172func (s *Store) GetOrder(ctx context.Context, id string) (Order, error) {73 row, err := s.q.GetOrder(ctx, id)74 if errors.Is(err, pgx.ErrNoRows) {75 return Order{}, fmt.Errorf("order %s: %w", id, ErrNotFound) // translate driver error at the boundary76 }77 if err != nil {78 return Order{}, fmt.Errorf("get order %s: %w", id, err)79 }80 return fromRow(row), nil81}82```8384**errgroup - bounded, cancellable, leak-free fan-out:**8586```go87g, ctx := errgroup.WithContext(ctx) // ctx cancels when any task errors88g.SetLimit(8) // bound concurrency; never spawn unbounded goroutines89for _, id := range ids {90 g.Go(func() error { // Go 1.22+: loop var is per-iteration, no capture bug91 return process(ctx, id) // must honor ctx or the group can't stop it92 })93}94if err := g.Wait(); err != nil { // first error; all goroutines finished95 return fmt.Errorf("process batch: %w", err)96}97```9899**slog - structured, context-aware logging (not fmt.Printf, not logrus):**100101```go102log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))103slog.SetDefault(log)104log.InfoContext(ctx, "order created", "order_id", o.ID, "user_id", o.UserID, "amount_cents", o.Amount)105```106107## Common Mistakes108109- **Reaching for chi/echo/gin by default.** Since Go 1.22, `http.ServeMux` handles method routing and path wildcards (`GET /users/{id}`, `r.PathValue`). Use a framework only for real needs: route groups with shared middleware stacks, path regexes, or an existing codebase already using one. Never mix two routers.110- **Storing `context.Context` in a struct field.** Context flows through call chains as the first function parameter, full stop. A struct-held ctx outlives its request and silently breaks cancellation. The only sanctioned exception is `http.Request`, which predates the rule.111- **Wrapping every error with `%w` reflexively - or with none.** Wrap with `%w` when callers may need to branch (`errors.Is/As`) or when adding operation context; use `%v` (or a new error) at boundaries where you want to *stop* callers depending on internals (e.g., hide `pgx.ErrNoRows` behind your own `ErrNotFound`). `err.Error() == "..."` string comparison is always wrong.112- **Fire-and-forget goroutines.** Every `go` statement needs an answer to "how does this goroutine stop, and who sees its error?" Use `errgroup.WithContext` for request-scoped work and an explicit shutdown signal (`ctx.Done()`) + `Wait` for background workers. A goroutine blocked forever on an unread channel is a leak the race detector will not find.113- **Generics where an interface (or nothing) belongs.** Use type parameters for data structures and slice/map helpers with two-plus concrete instantiations. Do not write `func Process[T any](...)` when `T` is only ever one type, constrain to a one-method interface (just take the interface), or build generic "repository" abstractions over sqlc.114- **ORMs (GORM/ent) as the default.** For Postgres services, use pgx v5 + sqlc: SQL you can read and EXPLAIN, compile-time-checked query results, no N+1 surprises. Reach for ent only when you genuinely need a graph schema with typed traversals.115- **Premature `pkg/` and giant `util` packages.** Start with `cmd/<service>/main.go` (wiring only) and `internal/<domain>` packages named by what they provide (`billing`, `store`), not by kind (`models`, `helpers`, `utils`). `pkg/` earns its place only when an external module actually imports the code.116- **`http.ListenAndServe(":8080", mux)` in production.** Zero timeouts means slow-loris and leaked connections. Configure `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, and shut down with `srv.Shutdown(ctx)` on SIGTERM.