Go From Zero, Part 11: Best Practices for Production-Ready Go

This is Part 11 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 10: Testing, Benchmarks, and Fuzzing is the place to catch up.

In Part 10, we learned to prove our code works. But “works on my laptop” and “runs reliably in production for three years while five different people change it” are two very different things.

This post collects the habits I’ve seen make the biggest difference in real Go codebases. Some are about style, some about tooling, and some are about running a service safely. None of them are complicated. They just add up.

Production-ready Go checklist

1. Write code for the reader

Go’s culture is built around readability. Code is read far more often than it’s written, so optimise for the person who opens your file next year (often you).

Names should be as short as their scope allows. A loop index is i. A receiver is one or two letters (func (s *Server)). A package-level function deserves a descriptive name. And remember the stutter rule from Part 6: order.New, not order.NewOrder.

Initialisms keep their case. userID, ServeHTTP, parseURL, not userId or parseUrl.

Return early, keep the happy path on the left. We saw this in Part 5 and it applies everywhere.

Clear is better than clever. One of the Go proverbs, and probably the most important. A boring three-line for loop beats a “smart” one-liner nobody understands.

Comment the why, not the what. The code shows what it does. A comment should explain why it does it that way, especially if it looks odd.

// Bad: says what the code already says
// increment retries
retries++

// Good: explains a decision a reader would question
// The payment API returns 409 while a previous charge is settling,
// so a short retry here avoids a confusing "duplicate" error for users.
retries++

2. Let tools do the boring work

Set these up once and forget about them:

  • gofmt / goimports: format on save. goimports also adds and removes imports automatically.
  • **go vet**: catches suspicious code. It also runs automatically as part of go test.
  • staticcheck: a deeper linter that finds real bugs and simplifications.
  • golangci-lint: runs many linters together, including staticcheck, errcheck (unchecked errors) and more. Popular in CI.
  • govulncheck: scans your code and dependencies for known vulnerabilities, and only reports ones your code actually calls.
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

A minimal CI pipeline for any Go project:

gofmt -l . | (! grep .)   # fail if anything is unformatted
go vet ./...
staticcheck ./...
go test -race -cover ./...
govulncheck ./...

3. Design for dependency injection, without a framework

Avoid package-level global variables for things like database connections or config. They make tests painful and hide dependencies. Instead, pass dependencies in through constructors:

type OrderService struct {
    store  OrderStore    // small interface (Part 4)
    mailer Mailer
    logger *slog.Logger
}

func NewOrderService(store OrderStore, mailer Mailer, logger *slog.Logger) *OrderService {
    return &OrderService{store: store, mailer: mailer, logger: logger}
}

Then wire everything together in main. It’s explicit, it’s testable, and you can see every dependency at a glance. You don’t need a dependency-injection framework in Go.

The functional options pattern

When a constructor has many optional settings, the functional options pattern keeps it clean and backward compatible:

package main

import (
    "fmt"
    "time"
)

type Client struct {
    baseURL string
    timeout time.Duration
    retries int
}

type Option func(*Client)

func WithTimeout(d time.Duration) Option { return func(c *Client) { c.timeout = d } }
func WithRetries(n int) Option           { return func(c *Client) { c.retries = n } }

func NewClient(baseURL string, opts ...Option) *Client {
    c := &Client{baseURL: baseURL, timeout: 5 * time.Second, retries: 2} // sensible defaults
    for _, opt := range opts {
        opt(c)
    }
    return c
}

func main() {
    a := NewClient("https://api.example.com")
    b := NewClient("https://api.example.com", WithTimeout(time.Second), WithRetries(5))
    fmt.Println(a.timeout, a.retries) // 5s 2
    fmt.Println(b.timeout, b.retries) // 1s 5
}

Adding a new option later never breaks existing callers. For simpler cases, a plain Config struct works just as well. Don’t use this pattern for two settings.

4. Configuration from the environment

Read config once at startup, validate it, and fail fast with a clear message if something’s missing. Environment variables are the simplest portable option and work everywhere from your laptop to Kubernetes.

package main

import (
    "errors"
    "fmt"
    "os"
    "time"
)

type Config struct {
    Addr        string
    DatabaseURL string
    ReadTimeout time.Duration
}

func getenv(key, fallback string) string {
    if v, ok := os.LookupEnv(key); ok {
        return v
    }
    return fallback
}

func LoadConfig() (Config, error) {
    rt, err := time.ParseDuration(getenv("READ_TIMEOUT", "5s"))
    if err != nil {
        return Config{}, fmt.Errorf("READ_TIMEOUT: %w", err)
    }
    cfg := Config{
        Addr:        getenv("ADDR", ":8080"),
        DatabaseURL: os.Getenv("DATABASE_URL"),
        ReadTimeout: rt,
    }
    if cfg.DatabaseURL == "" {
        return Config{}, errors.New("DATABASE_URL is required")
    }
    return cfg, nil
}

func main() {
    cfg, err := LoadConfig()
    if err != nil {
        fmt.Println("config error:", err)
        os.Exit(1)
    }
    fmt.Printf("%+v\n", cfg)
}

Never commit secrets to your repository, and never log them.

5. Structured logging with log/slog

Since Go 1.21 the standard library has log/slog for structured logging: logs as key-value pairs instead of free text. Log tools can then search and filter them easily, like “all errors for user 42 in the last hour”.

package main

import (
    "errors"
    "log/slog"
    "os"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))

    logger.Info("server starting", "addr", ":8080", "version", "1.4.2")

    // attach fields once, reuse everywhere for this request
    reqLog := logger.With("request_id", "req-7f3a", "user_id", 42)
    reqLog.Info("order created", "order_id", 991, "amount_paise", 49900)
    reqLog.Error("payment failed", "err", errors.New("card declined"))
    reqLog.Debug("this is hidden at Info level")
}

Output, one JSON object per line:

{"time":"...","level":"INFO","msg":"server starting","addr":":8080","version":"1.4.2"}
{"time":"...","level":"INFO","msg":"order created","request_id":"req-7f3a","user_id":42,"order_id":991,"amount_paise":49900}
{"time":"...","level":"ERROR","msg":"payment failed","request_id":"req-7f3a","user_id":42,"err":"card declined"}

Logging tips:

  • Use JSON in production and slog.NewTextHandler locally if you prefer readable output.
  • Use consistent keys (user_id everywhere, not userId in one place and uid in another).
  • Remember Part 5: log an error once, at the top of the request, with full wrapped context.
  • Don’t log personal data or secrets.

6. Never run an HTTP server without timeouts

This one has caused real outages. http.ListenAndServe(":8080", handler) uses a server with no timeouts, so slow or malicious clients can hold connections open forever and exhaust your server. Always configure your own http.Server:

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       10 * time.Second,
    WriteTimeout:      15 * time.Second,
    IdleTimeout:       60 * time.Second,
}

The same goes for outgoing calls: http.DefaultClient has no timeout. Create a client with one: client := &http.Client{Timeout: 10 * time.Second}, and pass a context to requests (Part 8).

7. Shut down gracefully

When your service is redeployed, it receives a signal (usually SIGTERM). A graceful shutdown stops accepting new requests, lets in-flight ones finish, then exits. Without it, every deploy drops some users’ requests.

package main

import (
    "context"
    "errors"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("hello\n"))
    })

    srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}

    // ctx is cancelled when we receive Ctrl+C or SIGTERM
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    go func() {
        slog.Info("listening", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            slog.Error("server error", "err", err)
            os.Exit(1)
        }
    }()

    <-ctx.Done() // wait for a shutdown signal
    slog.Info("shutting down")

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

We’ll use exactly this in Part 12.

8. Performance: measure, then fix the real problem

Go is fast by default. Most performance problems in real services are not “Go is slow”; they’re an N+1 database query, a missing index, or an unbounded loop. So:

Profile before optimising. Go ships with pprof. For a web service, one import exposes profiling endpoints:

import _ "net/http/pprof" // registers /debug/pprof/ handlers on DefaultServeMux

// serve them on a separate, internal-only port
go func() { http.ListenAndServe("localhost:6060", nil) }()
# 30-second CPU profile, then an interactive web UI with a flame graph
go tool pprof -http=:9090 http://localhost:6060/debug/pprof/profile?seconds=30

# heap (memory) profile
go tool pprof -http=:9090 http://localhost:6060/debug/pprof/heap

Never expose pprof on a public port. For tests and benchmarks, go test -cpuprofile cpu.out -bench . gives you the same views.

Once a profile points at a hotspot, the usual fixes are simple:

  • Preallocate slices and maps when you know the size: make([]T, 0, n).
  • Use strings.Builder to build strings in loops (we benchmarked this in Part 10).
  • Reuse buffers with sync.Pool in genuinely hot paths.
  • Avoid unnecessary conversions between []byte and string.
  • Batch I/O: use bufio for files, and batch database calls.

9. Ship small, reproducible binaries

Go’s single binary makes deployment simple. A multi-stage Dockerfile produces a tiny, secure image:

FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/api

FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

CGO_ENABLED=0 produces a fully static binary, -trimpath removes local paths, and -ldflags="-s -w" strips debug symbols to shrink it. The final image contains little more than your binary, so there’s very little to attack or patch.

10. A short list of things to avoid

  • Ignoring errors with _ without a comment explaining why.
  • Starting goroutines with no way to stop them (Part 7).
  • Using panic for normal errors (Part 5).
  • Huge interfaces defined next to their implementation (Part 4).
  • util and common packages that collect unrelated code (Part 6).
  • Global mutable state, including init() doing heavy setup.
  • Using floats for money. Store integer minor units like paise or cents.
  • Premature abstraction. Write the concrete thing first. Abstract on the second or third repetition.

Recap

  • Optimise for readability: short scoped names, early returns, clear over clever.
  • Automate quality: gofmt, go vet, staticcheck or golangci-lint, govulncheck, -race.
  • Inject dependencies through constructors; use functional options or config structs for settings.
  • Use log/slog for structured logs, and log each error once.
  • Always set server and client timeouts, and shut down gracefully.
  • Profile with pprof before optimising.

Try this: take any program from earlier in the series and add a golangci-lint run step. Fix everything it reports and notice which warnings surprised you.

In the final part, Part 12, we’ll put everything together and build a complete REST API using only the standard library, with routing, JSON, validation, errors, middleware, logging, tests and graceful shutdown.


Where to go from here

⬅️ Previous: Part 10: Testing, Benchmarks, and Fuzzing

➡️ Next: Part 12: Building a REST API with the Standard Library

The full Go From Zero series

  1. Part 1: Why Go, Setting It Up, and Your First Program
  2. Part 2: Variables, Types, Control Flow, and Functions
  3. Part 3: Arrays, Slices, Maps, and Structs
  4. Part 4: Methods, Interfaces, and Composition
  5. Part 5: Error Handling the Go Way
  6. Part 6: Packages, Modules, and Project Layout
  7. Part 7: Goroutines, Channels, and select
  8. Part 8: sync, context, and Concurrency Patterns
  9. Part 9: Generics Without the Headache
  10. Part 10: Testing, Benchmarks, and Fuzzing
  11. Part 11: Best Practices for Production-Ready Go (you are here)
  12. Part 12: Building a REST API with the Standard Library

If this helped you, share it with a friend who is learning Go, or subscribe to get the next posts by email. Questions are welcome in the comments.

comments powered by Disqus

Releted Posts

Go From Zero, Part 10: Testing, Benchmarks, and Fuzzing

This is Part 10 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 9: Generics Without the Headache is the place to catch up.

Read more

Go From Zero, Part 9: Generics Without the Headache

This is Part 9 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 8: sync, context, and Concurrency Patterns is the place to catch up.

Read more

Go From Zero, Part 8: sync, context, and Concurrency Patterns

This is Part 8 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 7: Goroutines, Channels, and select is the place to catch up.

Read more