go-idioms

SkillDev tools

Go stdlib, error wrapping, interfaces, goroutines, table-driven tests, gofumpt.

Available today. Use it from your connected AI after setup.

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, see references/go-patterns-and-anti-patterns.md. For logging library choice, see the logging-implementation skill. For quality commands, see the code-idioms-and-conventions rule.

Loading Guards: If the project has no go.mod, this skill does not apply.


2. When to Load References

SituationReference to Load
Starting a new project or setting up file layoutreferences/project-structure.md
Choosing packages, go.mod setup, or golangci-lint configreferences/recommended-dependencies.md
Writing code that handles I/O, concurrency, or user inputreferences/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 tool for tool dependencies, swiss table map implementation
  • 1.23+range-over-func (iterator protocol), unique package
  • 1.22+range-over-integer, loop variable per-iteration scoping (eliminates loop variable capture bugs), enhanced ServeMux with method-based routing
  • 1.21+log/slog structured logging, maps and slices stdlib packages, sync.OnceFunc / sync.OnceValue / sync.OnceValues, min / max builtins, clear builtin
  • 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

  1. Always return errors — never panic in library or business code

    • panic is reserved for truly unrecoverable states (programmer errors, nil dereference)
    • Use recover only at top-level goroutine boundaries (middleware, server startup)
  2. 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)
    
  3. 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)
    
  4. Use errors.Join for 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...)
    
  5. 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
    }
    
  6. 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
    }
    
  7. 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.
  8. Use lazy evaluation for fallback values

    • Prefer creating errors lazily only when a failure occurs to avoid unnecessary allocations.

5. Interfaces

  1. 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()
    }
    
  2. "Accept interfaces, return structs"

    • Function parameters: accept interfaces for flexibility and testability
    • Return values: return concrete structs so callers can access all methods
  3. 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
    
  4. Implicit satisfaction is a feature — don't use embedding to "implement" interfaces

    • Any type with the right method set satisfies an interface automatically
    • No implements keyword needed or wanted
  5. any vs interface{}

    • Always use any (Go 1.18+ alias) instead of interface{}.
  6. Interface compliance verification

    • Use var _ Interface = (*Struct)(nil) to guarantee a type implements an interface at compile time.
    var _ Storage = (*PostgresStorage)(nil)
    
  7. 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).
  8. 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.

  1. Always pass context.Context as 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)
    
  2. 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.Context cancellation or channel close).

    ✅ Goroutine is bounded by context cancellation

    go func() {
        for {
            select {
            case <-ctx.Done():
                return
            case item := <-ch:
                process(item)
            }
        }
    }()
    
  3. Goroutine lifecycle management with sync.WaitGroup

    var wg sync.WaitGroup
    for _, task := range tasks {
        wg.Add(1)
        go func(t Task) {
            defer wg.Done()
            process(t)
        }(task)
    }
    wg.Wait()
    
  4. Use errgroup for concurrent fan-out with error collection

    g, ctx := errgroup.WithContext(ctx)
    g.Go(func() error { return fetchUsers(ctx) })
    g.Go(func() error { return fetchOrders(ctx) })
    if err := g.Wait(); err != nil { ... }
    
  5. 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"
  6. Close channels from the sender, never the receiver

  7. context.AfterFunc (Go 1.21+)

    • Trigger an action asynchronously when a context completes.
    stop := context.AfterFunc(ctx, func() {
        cleanupResources()
    })
    defer stop()
    
  8. 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
    
  9. sync.OnceFunc / sync.OnceValue (Go 1.21+)

    • Use for lazy, thread-safe initialization.
    var initDb = sync.OnceValue(func() *sql.DB {
        // initialize DB
        return db
    })
    
  10. select with default for non-blocking operations

    select {
    case ch <- data:
        // Sent
    default:
        // Channel is full or unbuffered with no receiver, move on
    }
    
  11. 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.

  12. Structured concurrency with conc (when errgroup isn't enough)

    • errgroup covers fan-out with error collection. For additional needs — panic recovery within goroutines, bounded worker pools with panic safety — use github.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 errgroup for simple fan-out. Reach for conc when you need panic recovery or bounded concurrency with conc/pool.

7. Naming Conventions

  1. 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
    
  2. Package names: short, lowercase, no underscores, no pluralspackage taskpackage tasks (plural) ❌ package task_service (underscore)

  3. Acronyms follow Go conventions (all caps or all lowercase)userID, HTTPClientuserId, HttpClient

  4. Unexported identifiers omit the type name — if it's private, keep it terse

  5. Don't stuttertask.Task is fine; task.TaskService is not

  6. Error string formatting

    • Error strings should not be capitalized or end with punctuation.
  7. Getter naming

    • No Get prefix in Go. Use user.Name() not user.GetName().
  8. Test naming

    • Use the TestFunctionName_Scenario_Expected pattern.
    func TestCalculateDiscount_NegativeInput_ReturnsError(t *testing.T) { ... }
    
  9. Constructor naming

    • Use New prefix (NewService, NewClient).
  10. Interface naming

    • Use the -er suffix for single-method interfaces (Reader, Writer, Stringer).

8. Idiomatic Patterns

  1. 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)
    }
    
  2. Nil slice vs empty slice semantics

    • var s []int (nil slice) marshals to null in JSON.
    • s := []int{} or make([]int, 0) (empty slice) marshals to [] in JSON.
  3. Type assertions with comma-ok pattern

    if str, ok := val.(string); ok {
        fmt.Println(str)
    }
    
  4. 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")
    }
    
  5. strings.Builder for string concatenation

    • Avoid + inside loops. Use strings.Builder.
    var b strings.Builder
    b.WriteString("hello")
    b.WriteString("world")
    return b.String()
    
  6. slices and maps stdlib 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) { ... }
    
  7. Variadic append

    • Use append(s1, s2...) instead of loops.
  8. Pre-allocation

    • Use make([]T, 0, n) when the size is known to avoid reallocation overhead.
  9. sync.Pool for hot-path allocations

    • Use only for POINTER values to reduce GC pressure.
  10. time.NewTicker vs time.Tick

    • time.Tick leaks the underlying ticker. Always prefer time.NewTicker which can be stopped via ticker.Stop().
  11. 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.
  12. Early returns / guard clauses

    • Reduce nesting.
    if err != nil {
        return err
    }
    // Continue happy path
    
  13. Keep function complexity low

    • Keep cyclomatic complexity under 10 (matching Rust/TS guidelines).
  14. 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
}
  1. defer for 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)
    }
}()
  1. Avoid init() functions

    • They run implicitly and make testing harder; prefer explicit initialization in main or constructors.
  2. Use named return values only for documentation or defer-based cleanup

    • Never rely on naked returns in non-trivial functions.
  3. unique.Handle for 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.

  1. 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)
            })
        }
    }
    
  2. Use testify for assertions

    • require for fatal assertions, assert for non-fatal.
  3. Run with the race detector in CI

    • go test -race ./...
  4. Use httptest.NewRecorder() for HTTP handler tests

    • No live server needed.
  5. Test behaviour, not implementation

    • Assert on outputs and side effects, not internal field values.
  6. Test helpers with t.Helper() and testing.TB interface

    func createTestUser(t testing.TB) *User {
        t.Helper() // Corrects line numbers in failure output
        // ...
    }
    
  7. 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
    }
    
  8. 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
        })
    }
    
  9. Benchmark patterns with testing.B

    func BenchmarkProcess(b *testing.B) {
        b.ReportAllocs() // ALWAYS report allocations
        b.ResetTimer()   // Ignore setup time
        for i := 0; i < b.N; i++ {
            Process()
        }
    }
    
  10. Golden file testing

    • For large, complex outputs (like JSON or HTML), use golden files and compare against them.
  11. Test coverage non-negotiable policy

    • Every new exported function/method MUST have tests.
    • Every branch MUST be exercised.
  12. Coverage commands

    • Run go test -cover ./... and go test -coverprofile=coverage.out ./....
  13. Test Double Selection Table

ApproachWhen to Use
Hand-written fake (implement interface)Simple interface, few methods, need stateful behavior
gomock/mockgenComplex interface, need to verify call counts, argument matching
testify/mockComplex interface, prefer fluent assertion API
Table-driven testsSame logic, multiple input/output pairs
go-cmpDeep 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:

PhaseCommandPurpose
TDD / rapid iterationgo vet ./...Type-checks and correctness — fastest loop
Pre-commitgolangci-lint runAggregated linting — must pass zero warnings
Pre-commitgofumpt -l -w .Formatting — non-negotiable
Pre-commitgo test -race ./...Unit tests with race detector
Coverage verificationgo test -coverprofile=c.out ./...Verify before merging
Security auditgovulncheck ./...CVE scanning

Lint suppression policy — NEVER suppress these:

LintWhat It SignalsWhat To Do Instead
errcheckUnchecked error returnsHandle the error — use error-checked closure in defer
govet shadowVariable shadowingRename the inner variable
staticcheck SA*Correctness issuesFix the underlying bug
gosec G* (security)Security vulnerabilityFix the vulnerability
cyclop / gocognitFunction too complexDecompose into smaller functions

Acceptable suppressions (with mandatory // nolint: comment + rationale):

LintWhen Acceptable
gosec G104 (unhandled error)In test code only, with // nolint:gosec // test-only
revive exportedWhen 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.Println or log.Printf in production service code — these produce unstructured output. Use log/slog (stdlib, Go 1.21+) or the project's chosen adapter. See @.agents/skills/logging-implementation/SKILL.md for the required library and patterns.


11. Documentation

  1. 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.
  2. 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
    }
    
  3. Package doc

    • For complex packages, place package documentation in a dedicated doc.go file.
  4. 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

  1. Minimize dependency count

    • Go stdlib is highly comprehensive. Rely on it heavily before bringing in external packages.
  2. Commit go.sum always

    • Both go.mod and go.sum belong in version control to guarantee reproducible builds.
  3. go mod tidy before commits

    • Always run this to prune unused dependencies and resolve checksums.
  4. govulncheck in CI

    • Continuously scan for vulnerabilities in your module dependency tree.
  5. Module proxy and checksum database

    • Use GOPROXY and the checksum database. For private modules, configure GOPRIVATE or GONOSUMCHECK.
  6. Prefer stdlib over third-party

    • When feature parity exists (e.g., log/slog vs zap), use the stdlib.

13. Configuration and Environment

  1. Never scatter os.Getenv() calls throughout codebase

    • It makes dependencies opaque and testing difficult.
  2. Centralized config struct parsed at startup

    • Parse all configurations into a central Config struct once, then inject it (or its subsets) where needed.
  3. Fail fast

    • Fail at boot for missing required configuration, not later at the first time of use.
  4. 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