Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
ofershap avatar

Go Best Practices

  • 1 installs
  • 2 repo stars
  • Updated February 21, 2026
  • ofershap/go-best-practices

Provides modern Go patterns for error wrapping, structured logging with slog, context handling, concurrency, and Go 1.22+ features.

About

This skill teaches current Go best practices covering error wrapping with %w, slog logging, context, goroutine lifecycle, errgroup, and table-driven tests. A developer applies it when writing or reviewing Go code to avoid outdated patterns.

  • 12 critical rules plus functional-options and repository patterns
  • Covers Go 1.22+ range-over-int and loop-variable changes

Go Best Practices by the numbers

  • 1 all-time installs (skills.sh)
  • Ranked #79 of 98 Go skills by installs in the Skillselion catalog
  • Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ofershap/go-best-practices --skill go-best-practices

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs1
repo stars2
Last updatedFebruary 21, 2026
Repositoryofershap/go-best-practices

What it does

Provides modern Go patterns for error wrapping, structured logging with slog, context handling, concurrency, and Go 1.22+ features.

Files

SKILL.mdMarkdownGitHub ↗

When to use

Use this skill when working with Go code. It teaches you the current best practices and prevents common mistakes that AI agents make with outdated patterns.

Critical Rules

1. Always wrap errors with context using fmt.Errorf and %w

Wrong:

func createUser(name string) error {
    user, err := db.Insert(name)
    if err != nil {
        return err
    }
    return nil
}

Correct:

func createUser(name string) error {
    user, err := db.Insert(name)
    if err != nil {
        return fmt.Errorf("creating user %s: %w", name, err)
    }
    return nil
}

Why: Error chains need context at each level for debugging; unwrapped errors lose origin context.

2. Use log/slog for structured logging instead of log.Printf

Wrong:

log.Printf("user %s created with id %d", name, id)

Correct:

slog.Info("user created", "name", name, "id", id)

Why: slog is in the standard library since Go 1.21, produces structured (JSON/text) output, supports levels.

3. Pass context.Context as first parameter, never store in structs

Wrong:

type Service struct {
    ctx context.Context
}

func (s *Service) Do() error {
    return s.db.Query(s.ctx, "...")
}

Correct:

type Service struct {
    db *sql.DB
}

func (s *Service) Do(ctx context.Context) error {
    return s.db.QueryContext(ctx, "...")
}

Why: Context has a lifecycle tied to the request, not the service; storing it conflates lifecycles.

4. Prevent goroutine leaks — always ensure goroutines can exit

Wrong:

go func() {
    for {
        processItem(<-ch)
    }
}()

Correct:

go func() {
    for {
        select {
        case item, ok := <-ch:
            if !ok {
                return
            }
            processItem(item)
        case <-ctx.Done():
            return
        }
    }
}()

Why: Leaked goroutines consume memory (min 2KB stack each) and grow unboundedly.

5. Use errors.Is and errors.As for error checking, not == or type assertion

Wrong:

if err == sql.ErrNoRows {
    return nil, nil
}

Correct:

if errors.Is(err, sql.ErrNoRows) {
    return nil, nil
}

Why: errors.Is traverses the wrapped error chain; == only matches the outermost error.

6. Use range over integers (Go 1.22+) instead of C-style for loops

Wrong:

for i := 0; i < 10; i++ {
    process(i)
}

Correct:

for i := range 10 {
    process(i)
}

Why: Cleaner, less error-prone, idiomatic since Go 1.22.

7. Use table-driven tests with t.Run subtests

Wrong:

func TestAdd_Positive(t *testing.T) { ... }
func TestAdd_Negative(t *testing.T) { ... }
func TestAdd_Zero(t *testing.T) { ... }

Correct:

func TestAdd(t *testing.T) {
    tests := []struct {
        name  string
        a, b  int
        want  int
    }{
        {"positive", 2, 3, 5},
        {"negative", -1, -2, -3},
        {"zero", 0, 5, 5},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Add(tt.a, tt.b)
            if got != tt.want {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
            }
        })
    }
}

Why: DRY, easy to add cases, subtests run independently and can be filtered.

8. Return concrete types from constructors, accept interfaces

Wrong:

func NewService() ServiceInterface {
    return &service{}
}

Correct:

func NewService() *Service {
    return &Service{}
}

Why: Accept interfaces, return structs — keeps packages decoupled, lets consumers define interfaces they need.

9. Use errgroup for coordinated goroutine lifecycle

Wrong:

var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
for _, task := range tasks {
    wg.Add(1)
    go func(t Task) {
        defer wg.Done()
        if err := t.Run(); err != nil {
            mu.Lock()
            errs = append(errs, err)
            mu.Unlock()
        }
    }(task)
}
wg.Wait()

Correct:

g, ctx := errgroup.WithContext(ctx)
for _, task := range tasks {
    task := task
    g.Go(func() error {
        return task.Run(ctx)
    })
}
if err := g.Wait(); err != nil {
    return fmt.Errorf("task failed: %w", err)
}

Why: errgroup handles sync, error collection, and context cancellation in one.

10. Avoid init() functions — use explicit initialization

Wrong:

var db *sql.DB

func init() {
    db = connectDB()
}

Correct:

func NewApp() (*App, error) {
    db, err := connectDB()
    if err != nil {
        return nil, fmt.Errorf("connect db: %w", err)
    }
    return &App{db: db}, nil
}

Why: init() creates hidden dependencies, makes testing difficult, order is unpredictable across packages.

11. Handle the loop variable capture fix (Go 1.22+)

Wrong (pre-1.22 pattern, unnecessary in 1.22+):

for _, item := range items {
    item := item
    go func() {
        process(item)
    }()
}

Correct (Go 1.22+):

for _, item := range items {
    go func() {
        process(item)
    }()
}

Why: Go 1.22+ fixed loop variable capture; loop variables are per-iteration. Remove unnecessary re-declarations.

12. Use proper struct validation, not manual checks

Wrong:

func CreateUser(req *CreateUserRequest) error {
    if req.Name == "" {
        return errors.New("name required")
    }
    if len(req.Name) < 2 {
        return errors.New("name too short")
    }
    if req.Email == "" {
        return errors.New("email required")
    }
    // ...
}

Correct:

type CreateUserRequest struct {
    Name  string `validate:"required,min=2"`
    Email string `validate:"required,email"`
}

func CreateUser(req *CreateUserRequest) error {
    if err := validator.Validate(req); err != nil {
        return fmt.Errorf("validation: %w", err)
    }
    // ...
}

Why: Declarative, consistent, handles complex validation rules.

Patterns

Functional options pattern for configurable constructors

type Server struct {
    host string
    port int
}

type Option func(*Server)

func WithHost(host string) Option {
    return func(s *Server) { s.host = host }
}

func WithPort(port int) Option {
    return func(s *Server) { s.port = port }
}

func NewServer(opts ...Option) *Server {
    s := &Server{host: "localhost", port: 8080}
    for _, opt := range opts {
        opt(s)
    }
    return s
}

Repository pattern with interface

type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*User, error)
    Create(ctx context.Context, u *User) error
}

type userRepo struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *userRepo {
    return &userRepo{db: db}
}

func (r *userRepo) GetByID(ctx context.Context, id int64) (*User, error) {
    // ...
}

Middleware chain pattern

type Middleware func(http.Handler) http.Handler

func chain(middlewares ...Middleware) Middleware {
    return func(final http.Handler) http.Handler {
        for i := len(middlewares) - 1; i >= 0; i-- {
            final = middlewares[i](final)
        }
        return final
    }
}

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        slog.Info("request", "method", r.Method, "path", r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

Graceful shutdown with signal handling

func main() {
    srv := &http.Server{Addr: ":8080", Handler: mux}
    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            slog.Error("server failed", "err", err)
        }
    }()

    sig := make(chan os.Signal, 1)
    signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
    <-sig

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        slog.Error("shutdown failed", "err", err)
    }
}

Anti-Patterns

  • Do not use panic for expected errors — only for programmer bugs (nil dereference, out of

bounds). Return errors for recoverable failures.

  • Do not ignore errors with `_ = someFunc()` — handle or explicitly document why ignoring is

acceptable.

  • Do not use global variables for dependency injection — pass dependencies via constructors or

function parameters.

  • Do not use naked goroutines without lifecycle management — ensure every goroutine has an exit

path (context cancellation, done channel, or bounded loop).

  • Do not return interfaces from packages — return concrete types; let the consumer define the

interface they need.

Related skills

Gobackend

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.