Go Best Practices
Senior engineer enforcing idiomatic Go conventions — writing clean, maintainable Go code that reads like well-structured English. This skill covers error handling, interface design, naming, package organization, and the conventions that make Go code predictable and reviewable.
TL;DR Checklist
- Return errors explicitly; never ignore returned errors without intentional handling
- Design interfaces small and consumer-specific (interface segregation)
- Name packages after their purpose, not their type (e.g.
cache, notcachetypes) - Use
context.Contextfor all operations that span request boundaries - Write table-driven tests for every exported function
- Document exported symbols with
// Package docand// Function doccomments
When to Use
Use this skill when:
- Writing new Go code and wanting to ensure idiomatic conventions
- Reviewing Go code for style, correctness, and adherence to community standards
- Onboarding a developer to a Go codebase with established conventions
- Refactoring legacy Go code to follow modern idioms (Go 1.21+)
- Establishing coding standards for a new Go project
When NOT to Use
Avoid this skill for:
- Low-level systems code where performance overrides readability (use
unsafepatterns instead) - One-off scripts or throwaway programs where conventions add unnecessary overhead
- Learning Go from scratch without context (pair with a fundamentals reference first)
Core Workflow
Evaluate the Code Structure — Check package organization, naming, and exported symbol visibility. Checkpoint: Every exported function and type has a godoc comment explaining its purpose.
Enforce Error Handling — Verify all errors are explicitly handled at call sites. No silent swallowing. Checkpoint: Every
err != nilcheck has a meaningful action (return, log with context, retry).Review Interface Design — Ensure interfaces are small, consumer-specific, and defined by consumers. Checkpoint: Interfaces should fit on one screen. If an interface has more than 3-4 methods, consider splitting.
Check Naming Conventions — Verify names follow Go's naming rules: short, precise, camelCase for exports. Checkpoint: Package names are lowercase, single words, no underscores.
Validate Testing Coverage — Ensure table-driven tests cover happy path, error paths, and edge cases. Checkpoint: Every exported function has at least one test case for normal input and one for error input.
Implementation Patterns
Pattern 1: Error Handling (❌ BAD vs ✅ GOOD)
Go's error handling must be explicit. Ignoring errors is a bug, not an optimization.
❌ BAD — Silent Error Swallowing
// ❌ BAD: ignoring an error silently — the caller has no way to know something failed
func ProcessFile(path string) error {
data, err := os.ReadFile(path)
// err is ignored — file might not exist, permissions denied, etc.
_ = err
content := strings.ToUpper(string(data))
return nil
}
What's wrong:
- Error is explicitly discarded with
_ = err, hiding failure from the caller - The function claims success (
return nil) even when the file read failed - No way for the caller to distinguish between "processed 0 bytes" and "file not found"
✅ GOOD — Explicit Error Propagation
// ProcessFile reads a file, uppercases its content, and returns the result.
// Returns os.PathError if the file cannot be read.
func ProcessFile(path string) ([]byte, error) {
if path == "" {
return nil, fmt.Errorf("process file: path must not be empty")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("process file: read %s: %w", path, err)
}
return bytes.ToUpper(data), nil
}
Why this works:
- Guard clause rejects invalid input early (Early Exit law)
- Error is wrapped with context using
%wfor error chain inspection - Return type
([]byte, error)makes success/failure explicit to the caller
Pattern 2: Interface Design (❌ BAD vs ✅ GOOD)
Go interfaces should be small, consumer-defined, and focused.
❌ BAD — Overly Broad Interface
// ❌ BAD: interface defined by the implementer, not the consumer
// Forces consumers to depend on methods they don't use
type Service interface {
// A service that does everything
CreateUser(ctx context.Context, req CreateUserRequest) (*User, error)
UpdateUser(ctx context.Context, id string, req UpdateUserRequest) error
DeleteUser(ctx context.Context, id string) error
GetUser(ctx context.Context, id string) (*User, error)
ListUsers(ctx context.Context, filter ListFilter) ([]*User, error)
SendEmail(ctx context.Context, to string, subject string, body string) error
GenerateReport(ctx context.Context, format string) ([]byte, error)
}
What's wrong:
- Single huge interface violates interface segregation principle
- Consumers of just
GetUserstill depend onSendEmailandGenerateReport - Impossible to mock without implementing all 7 methods
- Interface defined by the implementer, not by its consumers
✅ GOOD — Small, Consumer-Specific Interfaces
// UserRepository defines operations for user persistence.
// Implemented by concrete types; consumed by handlers and services.
type UserRepository interface {
GetByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
}
// EmailService defines operations for sending email.
type EmailService interface {
Send(ctx context.Context, msg EmailMessage) error
}
// ListableUsers is a constraint for types that support user listing.
type ListableUsers interface {
ListUsers(ctx context.Context, filter ListFilter) ([]*User, error)
}
Why this works:
- Each interface is small and focused on a single responsibility
- Consumers only depend on what they actually need
- Easy to mock for testing — a mock needs only 3-4 methods max
- Interfaces are defined by consumers, not by the implementation
Pattern 3: Context Usage (❌ BAD vs ✅ GOOD)
Context propagation is essential for cancellable, deadline-aware operations.
❌ BAD — Ignoring Context
// ❌ BAD: database query ignores context — cannot be cancelled or timed out
func GetUserByID(id string) (*User, error) {
row := db.QueryRow("SELECT id, name, email FROM users WHERE id = $1", id)
// no context — query runs forever if the DB is slow
var user User
err := row.Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return &user, nil
}
What's wrong:
- No context means the query cannot be cancelled on client disconnect
- No timeout — a slow query blocks the goroutine indefinitely
- Violates the principle that all operations spanning request boundaries accept context
✅ GOOD — Context-Aware Operations
// GetUserByID retrieves a user by their unique ID.
// The operation is cancellable via ctx and respects its deadline.
func GetUserByID(ctx context.Context, id string) (*User, error) {
if id == "" {
return nil, fmt.Errorf("get user by id: id must not be empty")
}
row := db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
)
var user User
if err := row.Scan(&user.ID, &user.Name, &user.Email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user by id: user %s not found", id)
}
return nil, fmt.Errorf("get user by id: %w", err)
}
return &user, nil
}
Why this works:
- Context enables cancellation and timeout propagation
QueryRowContextrespects the deadline — no hung queries- Error wrapping preserves the error chain for inspection
- Guard clause validates input before hitting the database
Constraints
MUST DO
- MUST return errors explicitly and wrap them with context using
%w - MUST define interfaces small (under 5 methods) and consumer-specific
- MUST document every exported function, type, and package with godoc comments
- MUST use
context.Contextfor all operations that span request or request-like boundaries - MUST name packages after their purpose in lowercase single words
- MUST write table-driven tests for all exported functions
- MUST use
errors.Isanderrors.Asfor error inspection instead of string comparison
MUST NOT DO
- MUST NOT ignore returned errors — not even with
_ = error blank identifier - MUST NOT define interfaces in the same package that implements them
- MUST NOT use
panic()for error handling — only for truly unrecoverable conditions at startup - MUST NOT use
interface{}— useany(Go 1.18+) for untyped values - MUST NOT embed large interfaces to create sub-interfaces — composition wins over inheritance
- MUST NOT name packages with underscores, hyphens, or plural forms
Related Skills
| Skill | Purpose |
|---|---|
modular-design |
Designs package boundaries and dependency injection architecture |
cloud-development |
Cloud-native patterns (graceful shutdown, health checks, config) |
testing-strategies |
Table-driven tests, mocks, benchmarks, and integration testing |
error-handling |
Custom error types, retry patterns, and failure recovery strategies |
Live References
Authoritative documentation links for this skill's domain. The model follows markdown links at load time to resolve external references and inline content.