|$ curl https://forge-ai.dev/api/markdown?path=docs/go/best-practices
$cat docs/go-—-best-practices-&-patterns.md
updated Recently·24 min read·published

Go — Best Practices & Patterns

GoBest PracticesAdvanced🎯Free Tools
Introduction

Go is a language of constraints: explicit error handling, a minimal type system, no generics-style ceremony for many years, and a concurrency model built around goroutines and channels. Those constraints are features. They force teams toward readable, maintainable, and predictable code — but only when the team agrees on conventions.

This guide distills production-tested Go best practices: how to lay out a project, name things consistently, handle errors without drowning in boilerplate, design interfaces that are small and composable, use concurrency safely, write useful tests, log observably, and keep performance predictable. The patterns here are drawn from the Go Proverbs, the standard library, and real-world codebases that ship at scale.

info

Go rewards consistency over cleverness. When in doubt, prefer the style used by the standard library. Code that looks like it belongs in net/http or os is usually correct.
Dos and Don'ts

These rules are heuristics, not absolute laws. They cover the most common sources of review friction: error handling, interface size, goroutine lifetimes, package naming, and mutation of shared state. Internalizing them will make your Go code idiomatic and safer by default.

AvoidPreferWhy
panic for normal errorsReturn explicit error valuesCallers decide how to recover; errors are values in Go
Empty interfaces everywhereConcrete types or small, focused interfacesType safety and compiler help disappear with interface
Huge interfacesInterfaces with one or two methodsSmall interfaces are easy to implement and mock
Starting goroutines without cleanupManaged lifetimes with context and sync.WaitGroupLeaked goroutines leak memory and hide bugs
Using init for complex setupExplicit constructors and dependency injectioninit is hard to test and impossible to skip
Global mutable stateTypes with their own state and explicit configurationGlobals make tests flaky and concurrency unsafe
Naked returnsExplicit return statementsNaked returns hurt readability in non-trivial functions
Shadowing package namesVariables named differently from imported packagesShadowing breaks tooling and confuses readers
Ignoring errors silentlyCheck or annotate every errorSilent failures are the hardest to debug
Overusing reflectionCode generation or explicit typesReflection is slow, unsafe, and hard to maintain

warning

Do not ignore errors with blank identifiers like _ = file.Close() unless you have documented why the error is safe to discard. If cleanup failure matters, log it or return it. Silence is a bug multiplier.
Project Layout

Go does not enforce a project layout, but the Go community has converged on a few patterns. For services and CLIs, the Standard Go Project Layout is the most common starting point. It separates binaries, libraries, internal packages, configuration, deployments, and test helpers.

project-layout.txt
TEXT
1myapp/
2├── cmd/
3│ ├── api/ # main entry point for the API service
4│ │ └── main.go
5│ └── worker/ # main entry point for the background worker
6│ └── main.go
7├── internal/
8│ ├── server/ # HTTP server setup (private to module)
9│ ├── storage/ # database access layer
10│ └── config/ # configuration loading
11├── pkg/
12│ └── api/ # public packages importable by others
13├── api/ # OpenAPI / protobuf definitions
14├── web/ # static assets and templates
15├── configs/ # example configuration files
16├── deployments/ # Docker, k8s, terraform
17├── scripts/ # build and migration scripts
18├── Makefile
19├── go.mod
20└── README.md

The internal/ directory is enforced by the compiler: packages inside it can only be imported by code within the module tree. Use internal/ for application-specific code that should not become a public API surface. Place reusable, stable packages under pkg/ only if external projects genuinely need to import them.

best practice

Keep the root of the repository small and boring. The root should contain go.mod, README, build scripts, and directories that lead to real code. Avoid dumping dozens of *.go files at the top level.
cmd-api-main.go
GO
1// cmd/api/main.go — minimal entry point
2package main
3
4import (
5 "context"
6 "log"
7 "os"
8 "os/signal"
9 "syscall"
10
11 "github.com/example/myapp/internal/config"
12 "github.com/example/myapp/internal/server"
13)
14
15func main() {
16 cfg, err := config.Load()
17 if err != nil {
18 log.Fatalf("load config: %v", err)
19 }
20
21 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
22 defer stop()
23
24 srv := server.New(cfg)
25 if err := srv.Run(ctx); err != nil {
26 log.Fatalf("server error: %v", err)
27 }
28}

Each program in cmd/ should be a thin wrapper around packages from internal/ or pkg/. The main function is responsible for reading configuration, wiring dependencies, handling OS signals, and reporting fatal errors. It should contain almost no business logic.

Naming Conventions

Naming in Go is intentionally terse. The language relies on package names to provide context, so identifiers inside a package can be short. Clarity is measured by how well a name reads at the call site, not by how descriptive it is in isolation.

naming-conventions.go
GO
1// Good: package name provides context
2package user
3
4func NewStore(db *sql.DB) *Store { ... }
5func (s *Store) ByID(ctx context.Context, id int64) (User, error) { ... }
6
7// Call site reads naturally
8u, err := user.NewStore(db).ByID(ctx, 42)
9
10// Bad: stutter and Hungarian notation
11package user
12
13type UserStore struct{ ... }
14func NewUserStore(userDb *sql.DB) *UserStore { ... }
15func (us *UserStore) GetUserByUserID(ctx context.Context, userID int64) (User, error) { ... }
CategoryConventionExample
PackagesShort, lowercase, no underscores; singular nounnet/http, io/ioutil, config
InterfacesMethod-name plus -er suffix, or plain nounReader, Writer, Storage
ConstructorNew or NewTNewServer, NewStore
ErrorsPrefix Err for package-level errorsErrNotFound, ErrInvalidID
ConstantsCamelCase; exported if publicDefaultTimeout, maxRetries
AcronymsAll caps when exported: URL, HTTPServeHTTP, parseURL
ReceiversOne or two letters of the type namefunc (s *Store), func (r *Request)
Tests_test.go suffix; table-drivenstore_test.go

info

Use the gofmt-approved naming style. When a name feels awkward, the problem is often the API shape, not the identifier. Rename types and functions until the call site reads like a sentence.
Error Handling

Go's explicit error handling is a feature, not a bug. Every function that can fail returns an error. The caller decides whether to handle, wrap, or propagate. The key discipline is to add context at every layer so that the eventual log entry tells a complete story.

error-wrapping.go
GO
1// Bad: context-free error chain
2if err != nil {
3 return err
4}
5
6// Good: wrap with context using %w for error chaining
7if err != nil {
8 return fmt.Errorf("load user %d: %w", id, err)
9}
10
11// Good: sentinel errors for stable comparison
12var ErrNotFound = errors.New("not found")
13
14if errors.Is(err, ErrNotFound) {
15 http.Error(w, "user not found", http.StatusNotFound)
16 return
17}

Wrap errors with fmt.Errorf("...: %w", ...) when crossing package boundaries or significant abstraction layers. Use errors.Is to check against sentinels and errors.As to extract typed errors. Never compare error strings directly in production code.

typed-errors.go
GO
1package storage
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8)
9
10var ErrNotFound = errors.New("record not found")
11
12type UserStore struct {
13 db *sql.DB
14}
15
16func (s *UserStore) User(ctx context.Context, id int64) (User, error) {
17 const q = `SELECT id, email FROM users WHERE id = $1`
18 var u User
19 if err := s.db.QueryRowContext(ctx, q, id).Scan(&u.ID, &u.Email); err != nil {
20 if errors.Is(err, sql.ErrNoRows) {
21 return User{}, fmt.Errorf("user %d: %w", id, ErrNotFound)
22 }
23 return User{}, fmt.Errorf("query user %d: %w", id, err)
24 }
25 return u, nil
26}

best practice

Define sentinel errors at the package level when callers need to branch on them. Keep error messages lowercase and specific. Start with the operation that failed, then the subject, then the underlying cause.
custom-error-type.go
GO
1// Custom error type for richer inspection
2type ValidationError struct {
3 Field string
4 Message string
5}
6
7func (e *ValidationError) Error() string {
8 return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
9}
10
11// Caller can use errors.As
12var verr *ValidationError
13if errors.As(err, &verr) {
14 log.Printf("field=%s message=%s", verr.Field, verr.Message)
15}

Custom error types are useful when the caller needs more than a string. Keep them simple and document the intended use of errors.As. Avoid deep error hierarchies — Go is not Java, and checked exceptions do not exist.

Interfaces

Go interfaces are satisfied implicitly. This is one of the language's most powerful features, but it only works well when interfaces are small. Large interfaces create tight coupling and make testing difficult. The standard library models this perfectly: io.Reader has one method.

small-interfaces.go
GO
1// Good: small, composable interfaces
2package storage
3
4type Reader interface {
5 Read(ctx context.Context, id int64) (User, error)
6}
7
8type Writer interface {
9 Create(ctx context.Context, u User) error
10}
11
12// Compose only where needed
13type ReadWriter interface {
14 Reader
15 Writer
16}

Define interfaces on the consumer side, not the producer side. If a package needs something that can read bytes, it should define interface { Read([]byte) (int, error) } rather than importing a broad interface from elsewhere. This keeps packages loosely coupled and mocks trivial.

warning

Resist the temptation to create a single huge repository interface with every database method. Prefer small interfaces named after the behaviors they abstract. Future you and your tests will thank present you.
consumer-interfaces.go
GO
1// Consumer-side interface in the service layer
2package user
3
4import "context"
5
6type UserFinder interface {
7 ByID(ctx context.Context, id int64) (User, error)
8}
9
10type Service struct {
11 store UserFinder
12}
13
14func NewService(store UserFinder) *Service {
15 return &Service{store: store}
16}
17
18// In tests, pass a stub that implements only ByID
19type stubStore struct {
20 user User
21 err error
22}
23
24func (s stubStore) ByID(_ context.Context, _ int64) (User, error) {
25 return s.user, s.err
26}

Accept interfaces, return concrete types. A function signature should ask for the smallest surface area it needs and return the most useful concrete value it can. This maximizes flexibility for callers while keeping implementations simple.

Concurrency

Go makes concurrency easy to start and hard to get right. Goroutines are cheap, but their lifetimes must be managed. Channels are elegant, but shared memory is often simpler when ownership is clear. The Go Proverbs capture the philosophy: "Do not communicate by sharing memory; share memory by communicating."

fanout-fanin.go
GO
1// Fan-out / fan-in with cancellation
2func Process(ctx context.Context, inputs []int) ([]Result, error) {
3 ctx, cancel := context.WithCancel(ctx)
4 defer cancel()
5
6 var wg sync.WaitGroup
7 results := make(chan Result, len(inputs))
8
9 for _, in := range inputs {
10 wg.Add(1)
11 go func(n int) {
12 defer wg.Done()
13 select {
14 case <-ctx.Done():
15 return
16 case results <- work(ctx, n):
17 }
18 }(in)
19 }
20
21 go func() {
22 wg.Wait()
23 close(results)
24 }()
25
26 var out []Result
27 for r := range results {
28 if r.Err != nil {
29 cancel()
30 return nil, r.Err
31 }
32 out = append(out, r)
33 }
34 return out, nil
35}

Always pass context.Context as the first argument to functions that may run in goroutines. Use sync.WaitGroup to wait for goroutines, and close channels only from the sender side. If any worker fails, cancel siblings promptly to avoid wasted work.

danger

Loop variables captured by closures are a classic Go gotcha. Always pass the loop variable as a parameter to the goroutine function. Failing to do so can cause every goroutine to see the same value.
loop-variable.go
GO
1// Wrong: shares loop variable i across goroutines
2for i := 0; i < 10; i++ {
3 go func() {
4 fmt.Println(i) // unpredictable
5 }()
6}
7
8// Right: each goroutine gets its own copy
9for i := 0; i < 10; i++ {
10 go func(n int) {
11 fmt.Println(n)
12 }(i)
13}

Use mutexes for protecting shared state, but keep the critical sections small. Prefer sync.RWMutex when reads dominate. Never call out to network or disk inside a mutex, and never hold a lock while sending on a channel — that invites deadlocks.

mutex-counter.go
GO
1// Safe counter with small critical section
2type Counter struct {
3 mu sync.RWMutex
4 value int64
5}
6
7func (c *Counter) Add(n int64) {
8 c.mu.Lock()
9 c.value += n
10 c.mu.Unlock()
11}
12
13func (c *Counter) Value() int64 {
14 c.mu.RLock()
15 defer c.mu.RUnlock()
16 return c.value
17}
Testing

Go's built-in testing package is intentionally minimal. Table-driven tests are the dominant pattern. They keep related cases together, make adding new scenarios easy, and provide a clear structure for setup, execution, and assertion.

table-driven-test.go
GO
1func TestParseSize(t *testing.T) {
2 tests := []struct {
3 name string
4 input string
5 want int64
6 wantErr bool
7 }{
8 {"bytes", "10B", 10, false},
9 {"kilobytes", "2KB", 2048, false},
10 {"megabytes", "1.5MB", 1572864, false},
11 {"empty", "", 0, true},
12 {"bad unit", "5XB", 0, true},
13 }
14
15 for _, tt := range tests {
16 t.Run(tt.name, func(t *testing.T) {
17 got, err := ParseSize(tt.input)
18 if (err != nil) != tt.wantErr {
19 t.Fatalf("ParseSize(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
20 }
21 if got != tt.want {
22 t.Errorf("ParseSize(%q) = %d, want %d", tt.input, got, tt.want)
23 }
24 })
25 }
26}

Use t.Run with named subtests for clarity. Keep helpers in the same file if they are specific, or place reusable helpers in a testutil package under internal/. Use t.Helper() so failure lines point to the actual test case.

best practice

Avoid heavy assertion libraries. The standard library's testing package plus explicit comparisons produces better error messages and keeps tests readable. Use github.com/google/go-cmp/cmp only for complex structs.
test-helpers.go
GO
1// Test with helpers and parallel execution
2func TestUserStore(t *testing.T) {
3 ctx := context.Background()
4 store := newTestStore(t)
5
6 t.Run("create and read", func(t *testing.T) {
7 t.Parallel()
8 u := User{Email: "test@example.com"}
9 id, err := store.Create(ctx, u)
10 if err != nil {
11 t.Fatalf("create: %v", err)
12 }
13
14 got, err := store.User(ctx, id)
15 if err != nil {
16 t.Fatalf("read: %v", err)
17 }
18 if got.Email != u.Email {
19 t.Errorf("email = %q, want %q", got.Email, u.Email)
20 }
21 })
22}
23
24func newTestStore(t *testing.T) *UserStore {
25 t.Helper()
26 db := testdb.Open(t)
27 t.Cleanup(func() { db.Close() })
28 return &UserStore{db: db}
29}

For integration tests, use t.Cleanup and test fixtures. Mark slow tests with -short guards so developers can run a fast subset locally. Keep unit tests fast and deterministic; integration tests can be slower but must still clean up after themselves.

Logging

Logging is your primary observability tool in production. Go provides log in the standard library, and since Go 1.21, log/slog provides structured logging. Structured logs are easier to query, aggregate, and alert on than free-form strings.

slog-structured.go
GO
1package main
2
3import (
4 "log/slog"
5 "os"
6)
7
8func main() {
9 logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
10 Level: slog.LevelInfo,
11 }))
12 slog.SetDefault(logger)
13
14 slog.Info("server starting",
15 slog.String("addr", ":8080"),
16 slog.Int("workers", 4),
17 )
18}

Pass a logger through your application rather than relying on the global default. Inject *slog.Logger into constructors. This makes tests quiet and lets different subsystems run at different log levels. Avoid logging sensitive fields like tokens, passwords, or PII.

warning

Do not log and return the same error at every layer. Either log at the top level or return the error, not both. Duplicate error logs create noise and make incident response harder.
log-once.go
GO
1// Good: return rich errors, log at the boundary
2func (s *Service) CreateOrder(ctx context.Context, req CreateOrderRequest) (Order, error) {
3 order, err := s.store.CreateOrder(ctx, req)
4 if err != nil {
5 return Order{}, fmt.Errorf("create order for customer %s: %w", req.CustomerID, err)
6 }
7 return order, nil
8}
9
10// In the handler, log once with request context
11order, err := svc.CreateOrder(ctx, req)
12if err != nil {
13 slog.ErrorContext(ctx, "create order failed",
14 slog.String("customer_id", req.CustomerID),
15 slog.Any("error", err),
16 )
17 http.Error(w, "failed to create order", http.StatusInternalServerError)
18 return
19}

Use log levels intentionally. Debug for fine-grained tracing during development, Info for normal lifecycle events, Warn for recoverable issues, and Error for failures that require attention. Reserve Fatal and Panic for program startup only.

Performance

Go is fast by default, but performance pitfalls still exist: unnecessary allocations, goroutine leaks, large interface conversions, and contention on hot paths. The first rule is to measure. Use benchmarks, profiling, and tracing before optimizing.

benchmark.go
GO
1// Benchmark example
2func BenchmarkParseSize(b *testing.B) {
3 for i := 0; i < b.N; i++ {
4 if _, err := ParseSize("1.5MB"); err != nil {
5 b.Fatal(err)
6 }
7 }
8}
9
10// Run with profiling
11// go test -bench=. -benchmem -cpuprofile=cpu.out -memprofile=mem.out
12// go tool pprof cpu.out

Prefer sync.Pool for short-lived, allocation-heavy objects on hot paths. Preallocate slices when the final size is known. Use strings.Builder for repeated string concatenation. Avoid boxing values into interface in tight loops.

performance-patterns.go
GO
1// Preallocate slices to avoid repeated growth
2func FilterEven(nums []int) []int {
3 out := make([]int, 0, len(nums)) // worst-case cap
4 for _, n := range nums {
5 if n%2 == 0 {
6 out = append(out, n)
7 }
8 }
9 return out
10}
11
12// Use strings.Builder for many concatenations
13func JoinNames(names []string) string {
14 var b strings.Builder
15 b.Grow(len(names) * 16) // estimate average length
16 for i, n := range names {
17 if i > 0 {
18 b.WriteString(", ")
19 }
20 b.WriteString(n)
21 }
22 return b.String()
23}

info

Write the obvious code first. Go's compiler and allocator are excellent. Optimize only after profiling shows a bottleneck. Premature optimization wastes time and often harms readability.

For HTTP services, set reasonable timeouts on every server and client. Go's default HTTP server has no timeouts, which makes it vulnerable to slowloris and idle-connection leaks. Always configure ReadTimeout, WriteTimeout, and IdleTimeout.

http-timeouts.go
GO
1srv := &http.Server{
2 Addr: ":8080",
3 Handler: handler,
4 ReadTimeout: 5 * time.Second,
5 WriteTimeout: 10 * time.Second,
6 IdleTimeout: 120 * time.Second,
7}
8
9client := &http.Client{
10 Timeout: 30 * time.Second,
11 Transport: &http.Transport{
12 MaxIdleConns: 100,
13 MaxIdleConnsPerHost: 10,
14 IdleConnTimeout: 90 * time.Second,
15 },
16}
Code Review Checklist

Use this checklist for every Go pull request. Many checks can be automated with go vet, staticcheck, and CI. Reserve human review for design decisions, error-message quality, and concurrency safety.

CheckAutomated?Details
FormattinggofmtCode is formatted; CI rejects unformatted files
Vetgo vetCommon mistakes: printf format strings, shadowed variables
Lintstaticcheck / golangci-lintUnused code, suspicious constructs, style issues
Testsgo testUnit and integration tests pass; race detector enabled
Error handlingReviewErrors wrapped with context, none silently ignored
InterfacesReviewSmall, consumer-side; accept interfaces, return concrete types
Concurrencygo test -raceGoroutines have bounded lifetimes; no data races
NamingReviewIdiomatic names, no stutter, no shadowed imports
Dependenciesgo mod tidygo.mod and go.sum are clean and minimal

info

Run go test -race in CI. The race detector catches data races that are invisible in normal execution. It is slow, so run it on the full suite on every merge request.
Common Pitfalls

Every Go developer hits the same traps eventually. Knowing them in advance saves hours of debugging. Here are the most frequent sources of subtle bugs and how to avoid them.

common-pitfalls.go
GO
1// Pitfall 1: nil pointer through interface
2var p *MyType
3var r Reader = p // r is non-nil interface wrapping nil pointer
4if r != nil {
5 r.Read() // panics with nil pointer dereference
6}
7
8// Pitfall 2: slice aliasing
9func trimFirst(s []int) []int {
10 return s[1:] // shares backing array with original
11}
12
13// Pitfall 3: defer in a loop (argument evaluated immediately)
14for _, f := range files {
15 f := f // capture
16 defer f.Close()
17}
18
19// Pitfall 4: ignoring context cancellation
20func slow(ctx context.Context) error {
21 select {
22 case <-time.After(10 * time.Second):
23 return nil
24 case <-ctx.Done():
25 return ctx.Err() // must respect cancellation
26 }
27}

Interface nils surprise newcomers because an interface value is a tuple of (type, value). A nil concrete value inside a non-nil interface is still non-nil. For slices, understand when operations share backing arrays and copy explicitly when needed. Always consider ctx.Done() in long-running operations.

danger

Never compare a value of type error to nil after assigning it to an interface unless you understand the nil-interface semantics. This is a leading cause of panics in production Go code.
Tooling

Go's toolchain is one of its strengths. Mastering the standard tools and a few well-chosen third-party tools will make you far more productive than chasing the latest library trends.

go-tooling.sh
Bash
1# Essential commands
2go fmt ./... # format all packages
3go vet ./... # static analysis
4go test ./... # run tests
5go test -race ./... # run tests with race detector
6go test -cover ./... # coverage summary
7go test -bench=. -benchmem # run benchmarks
8
9# Recommended third-party tools
10go install honnef.co/go/tools/cmd/staticcheck@latest
11staticcheck ./...
12
13go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
14golangci-lint run
15
16# Module maintenance
17go mod tidy
18go mod verify
19go list -m all
ToolPurposeWhen to run
gofmtConsistent formattingOn save and in CI
go vetCommon mistakesBefore every commit
staticcheckAdvanced static analysisIn CI
golangci-lintConfigurable lint aggregatorIn CI and locally
go test -raceDetect data racesOn every PR
pprofCPU and memory profilingWhen optimizing
Resources

These references are the authoritative sources behind the recommendations in this guide. They are worth revisiting as the language and ecosystem evolve.

ResourceTopic
Effective Gogo.dev/doc/effective_go — idiomatic Go
Go Proverbsgo-proverbs.github.io — pithy design guidance
Standard Library Docspkg.go.dev/std — authoritative package reference
Standard Go Project Layoutgithub.com/golang-standards/project-layout
Go Code Review Commentsgithub.com/golang/go/wiki/CodeReviewComments
Google Go Style Guidegoogle.github.io/styleguide/go/
$Blueprint — Engineering Documentation·Section ID: GO-BP·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.