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.

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.goimportsalso adds and removes imports automatically.**go vet**: catches suspicious code. It also runs automatically as part ofgo 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.NewTextHandlerlocally if you prefer readable output. - Use consistent keys (
user_ideverywhere, notuserIdin one place anduidin 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.Builderto build strings in loops (we benchmarked this in Part 10). - Reuse buffers with
sync.Poolin genuinely hot paths. - Avoid unnecessary conversions between
[]byteandstring. - Batch I/O: use
bufiofor 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
panicfor normal errors (Part 5). - Huge interfaces defined next to their implementation (Part 4).
utilandcommonpackages 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/slogfor structured logs, and log each error once. - Always set server and client timeouts, and shut down gracefully.
- Profile with
pprofbefore 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
- Part 1: Why Go, Setting It Up, and Your First Program
- Part 2: Variables, Types, Control Flow, and Functions
- Part 3: Arrays, Slices, Maps, and Structs
- Part 4: Methods, Interfaces, and Composition
- Part 5: Error Handling the Go Way
- Part 6: Packages, Modules, and Project Layout
- Part 7: Goroutines, Channels, and select
- Part 8: sync, context, and Concurrency Patterns
- Part 9: Generics Without the Headache
- Part 10: Testing, Benchmarks, and Fuzzing
- Part 11: Best Practices for Production-Ready Go (you are here)
- 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.
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 moreGo 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 moreGo 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