go-idioms
SkillDev toolsGo stdlib, error wrapping, interfaces, goroutines, table-driven tests, gofumpt.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the go-idioms skill
What this skill tells your AI
The instructions your AI receives, as published by irahardianto/awesome-agv in .agents/skills/go-idioms/SKILL.md and read by ahel’s review.
Go Idioms and Patterns
1. Core Philosophy
Go favors simplicity, explicitness, and readability. The language is intentionally small — resist the urge to import patterns from other languages. If it looks boring and obvious, it's probably idiomatic Go.
Scope: This file covers Go-specific coding idioms. For file layout, see
references/project-structure.md. For detailed safety, SAST, and performance patterns, seereferences/go-patterns-and-anti-patterns.md. For logging library choice, see thelogging-implementationskill. For quality commands, see thecode-idioms-and-conventionsrule.
Loading Guards: If the project has no go.mod, this skill does not apply.
2. When to Load References
| Situation | Reference to Load |
|---|---|
| Starting a new project or setting up file layout | references/project-structure.md |
Choosing packages, go.mod setup, or golangci-lint config | references/recommended-dependencies.md |
| Writing code that handles I/O, concurrency, or user input | references/go-patterns-and-anti-patterns.md |
3. Toolchain and Go Version
Default to the latest stable Go release. As of August 2026, Go 1.24 is the baseline with the Go module system.
Key version milestones:
- 1.24+ —
go toolfor tool dependencies, swiss table map implementation - 1.23+ —
range-over-func (iterator protocol),uniquepackage - 1.22+ —
range-over-integer, loop variable per-iteration scoping (eliminates loop variable capture bugs), enhancedServeMuxwith method-based routing - 1.21+ —
log/slogstructured logging,mapsandslicesstdlib packages,sync.OnceFunc/sync.OnceValue/sync.OnceValues,min/maxbuiltins,clearbuiltin - 1.18+ — Generics, fuzz testing with
testing.F
Example go.mod configuration:
module github.com/user/project
go 1.24.0
toolchain go1.24.0
4. Error Handling
-
Always return errors — never panic in library or business code
panicis reserved for truly unrecoverable states (programmer errors, nil dereference)- Use
recoveronly at top-level goroutine boundaries (middleware, server startup)
-
Wrap errors with context using
%w- Don't re-wrap at every level. Wrap once with meaningful context where it matters.
✅ Preserves the error chain for errors.Is / errors.As
return fmt.Errorf("creating task for user %s: %w", userID, err)❌ Loses the error chain
return fmt.Errorf("creating task: %v", err) -
Error string formatting
- Error strings should be lowercase and have no trailing punctuation.
- Go convention from Code Review Comments.
✅ Correct formatting
return fmt.Errorf("failed to open file %q: %w", path, err)❌ Incorrect formatting
return fmt.Errorf("Failed to open file: %w.", err) -
Use
errors.Joinfor multi-error aggregation (Go 1.20+)var errs []error if err := doFirst(); err != nil { errs = append(errs, err) } if err := doSecond(); err != nil { errs = append(errs, err) } return errors.Join(errs...) -
Use sentinel errors for expected branch conditions
// Define in errors.go var ErrNotFound = errors.New("not found") var ErrUnauthorized = errors.New("unauthorized") // Caller checks with errors.Is if errors.Is(err, ErrNotFound) { // handle } -
Use typed errors for rich domain errors
type ValidationError struct { Field string Message string } func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message) } // Caller unwraps with errors.As var ve *ValidationError if errors.As(err, &ve) { // access ve.Field, ve.Message } -
Handle errors at the right level
- Propagate upward until you have enough context to act on them; don't swallow or re-wrap the same error twice.
-
Use lazy evaluation for fallback values
- Prefer creating errors lazily only when a failure occurs to avoid unnecessary allocations.
5. Interfaces
-
Keep interfaces small — one or two methods is ideal
✅ Focused, composable
type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) }❌ Monolithic
type FileManager interface { Read() Write() Delete() List() Stat() } -
"Accept interfaces, return structs"
- Function parameters: accept interfaces for flexibility and testability
- Return values: return concrete structs so callers can access all methods
-
Define interfaces where they are used, not where they are implemented
✅ Defined in the consumer package (task feature)
// task/storage.go type Storage interface { GetByID(ctx context.Context, id string) (*Task, error) } // postgres.go implements Storage — it does NOT define it -
Implicit satisfaction is a feature — don't use embedding to "implement" interfaces
- Any type with the right method set satisfies an interface automatically
- No
implementskeyword needed or wanted
-
anyvsinterface{}- Always use
any(Go 1.18+ alias) instead ofinterface{}.
- Always use
-
Interface compliance verification
- Use
var _ Interface = (*Struct)(nil)to guarantee a type implements an interface at compile time.
var _ Storage = (*PostgresStorage)(nil) - Use
-
When NOT to use interfaces
- Premature abstraction violates YAGNI. Do not create an interface until you have 3+ implementations (or you need to mock it for testing in test-driven design).
-
Generic interfaces with type parameters (Go 1.18+)
type Repository[T any] interface { FindByID(ctx context.Context, id string) (T, error) }
6. Goroutines and Channels
For general concurrency principles (race conditions, deadlocks, message passing), see
concurrency-and-threading-principles.md. This section covers Go-specific mechanics.
-
Always pass
context.Contextas the first parameter✅ Pass context
func (s *Service) GetTask(ctx context.Context, id string) (*Task, error)❌ No way to cancel or propagate deadlines
func (s *Service) GetTask(id string) (*Task, error) -
Never start a goroutine without knowing how it will stop (Anti-pattern: Goroutine Leak)
- Every goroutine must have a definitive exit path (e.g., via
context.Contextcancellation or channel close).
✅ Goroutine is bounded by context cancellation
go func() { for { select { case <-ctx.Done(): return case item := <-ch: process(item) } } }() - Every goroutine must have a definitive exit path (e.g., via
-
Goroutine lifecycle management with
sync.WaitGroupvar wg sync.WaitGroup for _, task := range tasks { wg.Add(1) go func(t Task) { defer wg.Done() process(t) }(task) } wg.Wait() -
Use
errgroupfor concurrent fan-out with error collectiong, ctx := errgroup.WithContext(ctx) g.Go(func() error { return fetchUsers(ctx) }) g.Go(func() error { return fetchOrders(ctx) }) if err := g.Wait(); err != nil { ... } -
Prefer channels for ownership transfer; mutexes for shared state
- Channel: "I'm handing this data to you"
- Mutex: "We're both reading/writing this shared thing"
-
Close channels from the sender, never the receiver
-
context.AfterFunc(Go 1.21+)- Trigger an action asynchronously when a context completes.
stop := context.AfterFunc(ctx, func() { cleanupResources() }) defer stop() -
Channel direction restrictions in function signatures
- Narrow channel directions for compile-time safety.
func producer(ch chan<- int) {} // Send-only func consumer(ch <-chan int) {} // Receive-only -
sync.OnceFunc/sync.OnceValue(Go 1.21+)- Use for lazy, thread-safe initialization.
var initDb = sync.OnceValue(func() *sql.DB { // initialize DB return db }) -
selectwithdefaultfor non-blocking operationsselect { case ch <- data: // Sent default: // Channel is full or unbuffered with no receiver, move on } -
context.WithoutCancel(Go 1.21+)- Create a derived context that is not cancelled when the parent is. Use for background operations that must outlive the request (e.g., async audit logging, metrics flush).
// Request-scoped context cancelled when handler returns bgCtx := context.WithoutCancel(ctx) go func() { // This work continues even after the parent request completes auditLog(bgCtx, event) }()⚠️ Use sparingly — most work should respect parent cancellation. Reserve for fire-and-forget operations where partial completion is acceptable.
-
Structured concurrency with
conc(whenerrgroupisn't enough)errgroupcovers fan-out with error collection. For additional needs — panic recovery within goroutines, bounded worker pools with panic safety — usegithub.com/sourcegraph/conc.
// conc.WaitGroup recovers panics and re-panics on Wait() wg := conc.NewWaitGroup() wg.Go(func() { process(item1) }) wg.Go(func() { process(item2) }) wg.Wait() // Re-panics if any goroutine panicked- Prefer
errgroupfor simple fan-out. Reach forconcwhen you need panic recovery or bounded concurrency withconc/pool.
7. Naming Conventions
-
Receiver names: short, consistent, and the first letter of the type ✅
func (s *Service) Create(...) {}❌
func (svc *Service) Create(...) {} // too verbose func (self *Service) Create(...) {} // not Go -
Package names: short, lowercase, no underscores, no plurals ✅
package task❌package tasks(plural) ❌package task_service(underscore) -
Acronyms follow Go conventions (all caps or all lowercase) ✅
userID,HTTPClient❌userId,HttpClient -
Unexported identifiers omit the type name — if it's private, keep it terse
-
Don't stutter —
task.Taskis fine;task.TaskServiceis not -
Error string formatting
- Error strings should not be capitalized or end with punctuation.
-
Getter naming
- No
Getprefix in Go. Useuser.Name()notuser.GetName().
- No
-
Test naming
- Use the
TestFunctionName_Scenario_Expectedpattern.
func TestCalculateDiscount_NegativeInput_ReturnsError(t *testing.T) { ... } - Use the
-
Constructor naming
- Use
Newprefix (NewService,NewClient).
- Use
-
Interface naming
- Use the
-ersuffix for single-method interfaces (Reader,Writer,Stringer).
- Use the
8. Idiomatic Patterns
-
Range-over-func iterators (Go 1.23+)
// Definition func Count(n int) iter.Seq[int] { return func(yield func(int) bool) { for i := 0; i < n; i++ { if !yield(i) { return } } } } // Usage for i := range Count(5) { fmt.Println(i) } -
Nil slice vs empty slice semantics
var s []int(nil slice) marshals tonullin JSON.s := []int{}ormake([]int, 0)(empty slice) marshals to[]in JSON.
-
Type assertions with comma-ok pattern
if str, ok := val.(string); ok { fmt.Println(str) } -
Type switches for polymorphic dispatch
switch v := val.(type) { case string: fmt.Println("String:", v) case int: fmt.Println("Int:", v) default: fmt.Println("Unknown type") } -
strings.Builderfor string concatenation- Avoid
+inside loops. Usestrings.Builder.
var b strings.Builder b.WriteString("hello") b.WriteString("world") return b.String() - Avoid
-
slicesandmapsstdlib packages (Go 1.21+)- Use the built-in generic functions for sorting, reversing, filtering, and checking elements.
slices.Sort(mySlice) if slices.Contains(mySlice, target) { ... } -
Variadic append
- Use
append(s1, s2...)instead of loops.
- Use
-
Pre-allocation
- Use
make([]T, 0, n)when the size is known to avoid reallocation overhead.
- Use
-
sync.Poolfor hot-path allocations- Use only for POINTER values to reduce GC pressure.
-
time.NewTickervstime.Ticktime.Tickleaks the underlying ticker. Always prefertime.NewTickerwhich can be stopped viaticker.Stop().
-
Struct embedding for composition (not inheritance)
- Use struct embedding to reuse fields and methods where it forms an "is-a" relationship, not for general code reuse.
-
Early returns / guard clauses
- Reduce nesting.
if err != nil { return err } // Continue happy path -
Keep function complexity low
- Keep cyclomatic complexity under 10 (matching Rust/TS guidelines).
-
Functional options for optional configuration
type Option func(*Service)
func WithTimeout(d time.Duration) Option {
return func(s *Service) { s.timeout = d }
}
func NewService(store Storage, opts ...Option) *Service {
s := &Service{store: store, timeout: 30 * time.Second}
for _, o := range opts { o(s) }
return s
}
deferfor cleanup — always use error-checked closures
Every deferred cleanup call that returns an error MUST check and log the error.
Never use bare defer X.Close() — the discarded error hides resource leak failures.
❌ NEVER: Error silently discarded
defer rows.Close()
✅ ALWAYS: Error-checked closure with structured logging
rows, err := db.QueryContext(ctx, query)
if err != nil { return fmt.Errorf("querying tasks: %w", err) }
defer func() {
if err := rows.Close(); err != nil {
slog.Warn("failed to close rows", "error", err, "operation", "ListTasks")
}
}()
Transaction rollback: ❌ NEVER
defer tx.Rollback()
✅ ALWAYS: Guard against sql.ErrTxDone (already committed)
defer func() {
if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
slog.Error("failed to rollback transaction", "error", err, "operation", "CreateOrder")
}
}()
HTTP response body: ❌ NEVER
defer resp.Body.Close()
✅ ALWAYS: Drain then close (prevents connection reuse issues)
defer func() {
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
slog.Warn("failed to drain response body", "error", err)
}
if err := resp.Body.Close(); err != nil {
slog.Warn("failed to close response body", "error", err)
}
}()
-
Avoid
init()functions- They run implicitly and make testing harder; prefer explicit initialization in
mainor constructors.
- They run implicitly and make testing harder; prefer explicit initialization in
-
Use named return values only for documentation or
defer-based cleanup- Never rely on naked returns in non-trivial functions.
-
unique.Handlefor string interning (Go 1.23+)- Deduplicate frequently repeated values (user IDs, status strings) to reduce memory.
import "unique" h := unique.Make("active") // h.Value() returns "active" // Two handles with the same underlying value compare as equal h1 := unique.Make("active") fmt.Println(h == h1) // true — same canonical representation- Use when you have many copies of the same string in memory (e.g., status enums parsed from JSON). Don't use for unique values — the interning overhead is wasted.
9. Testing
Test file naming and pyramid proportions are defined in
testing-strategy.md. This section covers Go-specific tooling only.
-
Table-driven tests are the default pattern
func TestCalculateDiscount(t *testing.T) { tests := []struct { name string input float64 expected float64 wantErr bool }{ {"zero items", 0, 0, false}, {"negative input", -1, 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := calculateDiscount(tt.input) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tt.expected, got) }) } } -
Use
testifyfor assertionsrequirefor fatal assertions,assertfor non-fatal.
-
Run with the race detector in CI
go test -race ./...
-
Use
httptest.NewRecorder()for HTTP handler tests- No live server needed.
-
Test behaviour, not implementation
- Assert on outputs and side effects, not internal field values.
-
Test helpers with
t.Helper()andtesting.TBinterfacefunc createTestUser(t testing.TB) *User { t.Helper() // Corrects line numbers in failure output // ... } -
t.Cleanup()for resource teardown- Automatically cleans up resources without needing defers across multiple helper functions.
func setupDB(t *testing.T) *sql.DB { db := connect() t.Cleanup(func() { db.Close() }) return db } -
Fuzz testing with
testing.F(Go 1.18+)func FuzzParse(f *testing.F) { f.Add("valid_input") f.Fuzz(func(t *testing.T, input string) { Parse(input) // ensure it never panics }) } -
Benchmark patterns with
testing.Bfunc BenchmarkProcess(b *testing.B) { b.ReportAllocs() // ALWAYS report allocations b.ResetTimer() // Ignore setup time for i := 0; i < b.N; i++ { Process() } } -
Golden file testing
- For large, complex outputs (like JSON or HTML), use golden files and compare against them.
-
Test coverage non-negotiable policy
- Every new exported function/method MUST have tests.
- Every branch MUST be exercised.
-
Coverage commands
- Run
go test -cover ./...andgo test -coverprofile=coverage.out ./....
- Run
-
Test Double Selection Table
| Approach | When to Use |
|---|---|
| Hand-written fake (implement interface) | Simple interface, few methods, need stateful behavior |
gomock/mockgen | Complex interface, need to verify call counts, argument matching |
testify/mock | Complex interface, prefer fluent assertion API |
| Table-driven tests | Same logic, multiple input/output pairs |
go-cmp | Deep equality comparison with custom options |
Prefer hand-written fakes for core domain traits.
10. Formatting and Static Analysis
All of the following must pass with zero warnings/errors before any commit. See code-idioms-and-conventions.md for the full checklist.
Feedback Loop — Development Workflow:
| Phase | Command | Purpose |
|---|---|---|
| TDD / rapid iteration | go vet ./... | Type-checks and correctness — fastest loop |
| Pre-commit | golangci-lint run | Aggregated linting — must pass zero warnings |
| Pre-commit | gofumpt -l -w . | Formatting — non-negotiable |
| Pre-commit | go test -race ./... | Unit tests with race detector |
| Coverage verification | go test -coverprofile=c.out ./... | Verify before merging |
| Security audit | govulncheck ./... | CVE scanning |
Lint suppression policy — NEVER suppress these:
| Lint | What It Signals | What To Do Instead |
|---|---|---|
errcheck | Unchecked error returns | Handle the error — use error-checked closure in defer |
govet shadow | Variable shadowing | Rename the inner variable |
staticcheck SA* | Correctness issues | Fix the underlying bug |
gosec G* (security) | Security vulnerability | Fix the vulnerability |
cyclop / gocognit | Function too complex | Decompose into smaller functions |
Acceptable suppressions (with mandatory // nolint: comment + rationale):
| Lint | When Acceptable |
|---|---|
gosec G104 (unhandled error) | In test code only, with // nolint:gosec // test-only |
revive exported | When the exported symbol is intentionally part of API design |
Rule of thumb: If you're about to write //nolint:, stop and ask if you're suppressing a real design problem.
//nolint:errcheck is NEVER acceptable. If a function returns an error, handle it — even in defer. Use an error-checked closure. This is the #1 source of audit findings.
Logging: Never use
fmt.Printlnorlog.Printfin production service code — these produce unstructured output. Uselog/slog(stdlib, Go 1.21+) or the project's chosen adapter. See@.agents/skills/logging-implementation/SKILL.mdfor the required library and patterns.
11. Documentation
-
Godoc conventions
- Package-level doc comment should exist for all exported packages.
- All exported symbols (functions, structs, interfaces, vars, consts) MUST have a documentation comment starting with the name of the symbol.
-
Example functions
- Use
func ExampleFunction()with// Output:comments at the end of the function body for runnable examples in Godoc.
func ExampleHello() { fmt.Println("Hello") // Output: Hello } - Use
-
Package doc
- For complex packages, place package documentation in a dedicated
doc.gofile.
- For complex packages, place package documentation in a dedicated
-
Document the WHY, not the WHAT
- The code shows what is happening. Comments explain why it is done this way (design rationale, edge cases, workarounds).
12. Dependency Management
-
Minimize dependency count
- Go stdlib is highly comprehensive. Rely on it heavily before bringing in external packages.
-
Commit
go.sumalways- Both
go.modandgo.sumbelong in version control to guarantee reproducible builds.
- Both
-
go mod tidybefore commits- Always run this to prune unused dependencies and resolve checksums.
-
govulncheckin CI- Continuously scan for vulnerabilities in your module dependency tree.
-
Module proxy and checksum database
- Use
GOPROXYand the checksum database. For private modules, configureGOPRIVATEorGONOSUMCHECK.
- Use
-
Prefer stdlib over third-party
- When feature parity exists (e.g.,
log/slogvszap), use the stdlib.
- When feature parity exists (e.g.,
13. Configuration and Environment
-
Never scatter
os.Getenv()calls throughout codebase- It makes dependencies opaque and testing difficult.
-
Centralized config struct parsed at startup
- Parse all configurations into a central
Configstruct once, then inject it (or its subsets) where needed.
- Parse all configurations into a central
-
Fail fast
- Fail at boot for missing required configuration, not later at the first time of use.
-
Example with struct tags and validation
type Config struct { Port int `env:"PORT" envDefault:"8080"` Database string `env:"DATABASE_URL" envRequired:"true"` } func LoadConfig() (*Config, error) { // load and validate }
14. Safety, Security, and Performance
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 156
- Forks
- 53
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
go-idioms- Source
- github.com/irahardianto/awesome-agv