"Idiomatic Go: Error Handling, Interfaces, and Production Patterns"
Introduction
Go was designed for clarity, not cleverness. The language's philosophy is baked into every design decision: simplicity over abstraction, readability over brevity, composition over inheritance. Rob Pike's famous maxim — "a little copying is better than a little dependency" — isn't just folklore; it's a practical constraint that shapes how production Go code is written.
This post is for engineers who already know Go syntax but want to write Go that feels like Go. We'll cover error handling, interface design, composition patterns, HTTP server idioms, concurrency coordination, testing, and production readiness — the patterns that separate Go-shaped code from Java-or-Python-shaped code written in Go.
Error Handling
Errors Are Values
Go has no exceptions. Errors are values returned from functions — plain values you inspect, wrap, and propagate. This isn't an oversight; it's a deliberate design choice that makes error paths as explicit as success paths.
// Bad: ignoring errors
f, _ := os.Open("file.txt")
// Good: explicit error handling
f, err := os.Open("file.txt")
if err != nil {
return fmt.Errorf("open file: %w", err)
}The if err != nil pattern is not boilerplate — it's a forcing function. Every error path
is visible at the call site. No hidden exception stack unwinding, no catch blocks three
levels up.
Sentinel Errors, Error Types, and Opaque Errors
Three strategies exist for communicating errors across package boundaries:
Sentinel errors — predefined error values like io.EOF or sql.ErrNoRows. Use them
sparingly. They create a dependency between caller and callee, and they cannot carry
context. Prefer them only for truly terminal, well-known conditions.
var ErrNotFound = errors.New("resource not found")Error types — structs implementing the error interface. Use when the caller needs
to inspect structured fields (e.g., a retryable flag, a status code).
type HTTPError struct {
StatusCode int
Message string
}
func (e *HTTPError) Error() string { return e.Message }Opaque errors — the default. Return fmt.Errorf("...: %w", err) and let the caller
decide whether to inspect. Most errors should be opaque. This is the strongest contract:
"something went wrong, but the internals are none of your business."
errors.Is and errors.As
Use errors.Is to match sentinel errors, errors.As to extract error types from the
wrapping chain. Both respect the wrapping chain introduced by %w.
if errors.Is(err, io.EOF) { /* handle end of file */ }
var httpErr *HTTPError
if errors.As(err, &httpErr) {
log.Printf("status %d: %s", httpErr.StatusCode, httpErr.Message)
}Panic and Recover
Panics are for programmer bugs — nil pointer dereferences, out-of-bounds access,
invalid invariants. Not for user input validation, not for network timeouts, not for
"file not found." Use recover only in top-level goroutine cleanup (e.g., closing
connections, flushing buffers). Never use recover for control flow — that's what errors
are for.
// Acceptable: top-level server goroutine cleanup
defer func() {
if r := recover(); r != nil {
log.Error("server panicked", "recover", r)
conn.Close()
}
}()Interface Design
Accept Interfaces, Return Structs
This is the single most cited Go idiom. Accepting interfaces decouples your function from its callers' concrete types. Returning structs avoids forcing callers to depend on an interface that may grow over time.
// Accept interface, return struct
func Process(ctx context.Context, storer Storage) *Result { ... }Small Interfaces
The standard library's most powerful interfaces are one method each:
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type Closer interface { Close() error }Small interfaces are naturally satisfied. A type that can read, write, and close satisfies
three separate contracts without declaring any of them. This is the opposite of
enterprise Java's sprawling AbstractFactoryManager hierarchies.
Consumer-Side Interface Definition
Define interfaces where they are consumed, not where they are produced. The package
that needs a Storage interface defines it; the package that implements it just exports
its concrete type. This eliminates circular dependencies and keeps packages decoupled.
// consumer/storage.go — defined by the consumer
type Storage interface {
Get(ctx context.Context, key string) ([]byte, error)
Put(ctx context.Context, key string, data []byte) error
}
// producer/s3.go — no knowledge of the interface
type S3Client struct { /* ... */ }
func (c *S3Client) Get(ctx context.Context, key string) ([]byte, error) { ... }
func (c *S3Client) Put(ctx context.Context, key string, data []byte) error { ... }The Empty Interface and Type Assertions
any (formerly interface{}) is a last resort. It surrenders compile-time type safety.
When you must use it, pair it with a type switch to recover concrete behavior:
func format(v any) string {
switch x := v.(type) {
case string:
return x
case int:
return strconv.Itoa(x)
case fmt.Stringer:
return x.String()
default:
return fmt.Sprintf("%v", x)
}
}Type assertions (x.(T)) are a code smell outside of type switches. If you're asserting
a concrete type, your interface is probably too broad.
Struct and Composition Patterns
Embedding Is Not Inheritance
Go has no class hierarchy. Embedding a struct promotes its methods to the outer type, but
there is no polymorphism, no virtual dispatch, no super().
type Base struct{ ID string }
func (b *Base) String() string { return b.ID }
type User struct {
Base // promoted: User.String() exists
Name string
}Use embedding for has-a relationships, not is-a. If you find yourself overriding embedded methods, you're fighting the language — use an interface instead.
Functional Options
The options pattern solves the problem of configuring a struct with many optional parameters without exploding into constructor overloads:
type Server struct {
addr string
timeout time.Duration
logger *slog.Logger
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func WithLogger(l *slog.Logger) Option {
return func(s *Server) { s.logger = l }
}
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second}
for _, opt := range opts {
opt(s)
}
return s
}This is the standard. It's extensible without breaking callers, and it avoids the constructor-overload problem endemic to languages without default parameters.
HTTP Server Patterns
Handlers and Middleware
http.Handler is a single-method interface. http.HandlerFunc is a function adapter.
Middleware is a function that takes a Handler and returns a Handler:
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start))
})
}Chain them naturally: Logging(Auth(RateLimit(handler))).
Graceful Shutdown
Use signal.NotifyContext to propagate OS signals through the request lifecycle:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)This pattern gives in-flight requests a deadline to complete before the process exits.
Concurrency Coordination
errgroup
golang.org/x/sync/errgroup extends sync.WaitGroup with error propagation and context
cancellation. Use it for bounded goroutine sets where one failure should cancel the rest:
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
url := url
g.Go(func() error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()
// process response...
return nil
})
}
if err := g.Wait(); err != nil {
// first error from any goroutine, context cancelled for all
}sync.Once and sync.Pool
sync.Once guarantees a function executes exactly once, even across goroutines. Use it
for lazy singleton initialization:
var conn *sql.DB
var once sync.Once
func DB() *sql.DB {
once.Do(func() {
conn = openConnection()
})
return conn
}sync.Pool caches allocated but idle objects, reducing GC pressure. Use it for
short-lived, expensive-to-allocate objects like bytes.Buffer in request handlers.
Never assume a pooled object retains its state between Get and Put.
Testing Idioms
Table-Driven Tests
Table-driven tests are the canonical Go testing pattern. Each row in the table is a test case with inputs and expected outputs:
func TestParseDuration(t *testing.T) {
tests := []struct {
name string
input string
want time.Duration
err bool
}{
{name: "seconds", input: "30s", want: 30 * time.Second},
{name: "minutes", input: "5m", want: 5 * time.Minute},
{name: "invalid", input: "xyz", err: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := time.ParseDuration(tc.input)
if (err != nil) != tc.err {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}Subtests (t.Run) give you independent setup/teardown per case, readable output, and
the ability to run a single case with go test -run TestParseDuration/seconds.
Golden Files and Test Fixtures
For complex output (generated code, serialized data, HTTP responses), use golden files:
func TestRender(t *testing.T) {
got := renderPage()
golden := filepath.Join("testdata", "page.golden")
if *update {
os.WriteFile(golden, got, 0644)
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Errorf("output mismatch")
}
}Use a -update flag to regenerate golden files when the output intentionally changes.
Integration Tests with Build Tags
Isolate integration tests behind //go:build integration tags. Run them separately from
unit tests in CI:
//go:build integration
package db_test
func TestPostgresConnection(t *testing.T) {
// requires running Postgres
}go test -tags=integration ./... runs the full suite; go test ./... skips it.
Production Readiness
Structured Logging with log/slog
Go 1.21 introduced log/slog — a structured logging API that replaces ad-hoc fmt.Println
in production code. Use slog.Info, slog.Error, and always pass key-value pairs:
slog.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"duration_ms", time.Since(start).Milliseconds(),
)Configure a JSON handler for production, a text handler for development:
var handler slog.Handler
if os.Getenv("ENV") == "production" {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
} else {
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
}
slog.SetDefault(slog.New(handler))Configuration from Environment
Twelve-factor app style: configuration comes from environment variables, not config files.
Use os.Getenv with defaults, or a lightweight library like envconfig:
type Config struct {
Port int `env:"PORT" default:"8080"`
Timeout time.Duration `env:"TIMEOUT" default:"30s"`
DB string `env:"DATABASE_URL" required:"true"`
}Middleware for Observability
Every HTTP handler should be wrapped in middleware that provides request ID, latency, and error tracking. This keeps individual handlers clean and observability centralized:
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.New().String()
}
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}fmt vs log/slog
fmt.Println and log.Printf are for throwaway scripts. In production code, use
slog for structured output that log aggregators (Datadog, Grafana Loki, Splunk) can
parse. Structured logs are searchable, filterable, and machine-readable. Unstructured
logs are noise at scale.
Conclusion
Idiomatic Go is not about following a style guide — it's about embracing the language's constraints as design virtues. Errors are values, not exceptions. Interfaces are small and consumer-defined. Composition replaces inheritance. Concurrency is a first-class citizen, not an afterthought.
The patterns here are not rules carved in stone. They are conventions that have emerged from a decade of production Go at Google, Stripe, Cloudflare, and elsewhere. Follow them until you have a concrete reason not to. Your future self — and your team — will thank you.
"Clear is better than clever." — Go Proverbs