Static linking is the single feature that makes Go's container story unusually simple compared to most other languages.
A minimal multi-stage Dockerfile
# Stage 1: build
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /myapp .
# Stage 2: minimal runtime image
FROM scratch
COPY --from=builder /myapp /myapp
ENTRYPOINT ["/myapp"]
The final image contains literally nothing except your compiled binary — no shell, no package manager, no OS userland at all, since scratch is an empty base image.
Why CGO_ENABLED=0 matters here
CGO_ENABLED=0 go build -o myapp .
With cgo enabled (the default when a C toolchain is available), your binary may dynamically link against the system's C library, which means it needs a compatible C library present on the target system, breaking the "just copy the binary anywhere" story. Disabling cgo forces a fully static, pure-Go binary with zero external runtime dependencies.
Comparing image sizes, roughly
Typical Node.js app image (node:18-slim base): ~150-250 MB
Typical Python app image (python:3.12-slim base): ~150-200 MB
Typical Go app image (scratch or distroless base): ~10-30 MB, often just a few MB
Why this matters operationally
Smaller images pull faster (meaningfully speeding up deploys and autoscaling events), and a scratch-based image has essentially no attack surface for OS-level vulnerabilities, since there's no OS present at all to have vulnerabilities in. This is a significant, practical operational advantage Go has over languages that require shipping a full interpreter/runtime and its dependencies inside every container image.
Related Resources
Before slog (added in Go 1.21), structured logging in Go meant reaching for a third-party library like zap or logrus. Now it's built directly into the standard library.
Unstructured vs. structured logging
// Unstructured: hard to query reliably at scale
log.Printf("user %d logged in via %s", userID, method)
// "user 42 logged in via oauth" — good luck filtering by user_id in a log aggregator
// Structured: user_id and method are real, queryable fields
slog.Info("user logged in", "user_id", 42, "method", "oauth")
JSON output for production log aggregation
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
slog.Info("user logged in", "user_id", 42, "method", "oauth")
{"time":"2024-01-15T10:30:00Z","level":"INFO","msg":"user logged in","user_id":42,"method":"oauth"}
Most log aggregation platforms (Datadog, Elasticsearch, CloudWatch) can index this directly, letting you filter on user_id=42 reliably, something regex-parsing a free-form message string can never do robustly.
Leveled logging
Four severity levels cover most needs:
slog.Debug("cache miss", "key", cacheKey)
slog.Info("request handled", "path", r.URL.Path, "status", 200)
slog.Warn("retrying request", "attempt", 3)
slog.Error("database connection failed", "error", err)
Attaching persistent context to a logger
slog.With avoids repeating the same fields on every call:
requestLogger := slog.With("request_id", reqID, "user_id", userID)
requestLogger.Info("processing request")
requestLogger.Info("request completed", "duration_ms", 42)
slog.With returns a new logger carrying those fields on every subsequent call, so you don't have to repeat request_id/user_id manually on every log line within a single request's handling.
Why this matters in production
Structured logs are what make real observability tooling (dashboards, alerts, log-based metrics) actually work reliably. A log aggregator can build a dashboard of "errors by user_id" trivially from structured JSON fields. It essentially can't do that robustly from free-text log messages with inconsistent formatting across a codebase.
Related Resources
Most Go anti-patterns come from applying habits or patterns from other languages that don't fit Go's actual design philosophy.
Premature interface abstraction
// Anti-pattern: an interface with exactly one implementation, defined "just in case"
type UserRepository interface {
GetUser(id int) (*User, error)
}
type postgresUserRepository struct{ db *sql.DB }
func (r *postgresUserRepository) GetUser(id int) (*User, error) { ... }
If there's genuinely only one implementation and no test double need yet, this interface adds indirection with no real payoff. Go's own guidance, "accept interfaces, return structs," favors defining an interface at the consumer side. Do that when a second implementation or a test double actually becomes necessary, not speculatively ahead of time.
Silently ignoring errors
data, _ := os.ReadFile("config.json") // if this fails, data is nil, and you'll get a
// confusing downstream error instead of a clear one
json.Unmarshal(data, &cfg)
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
Overusing any where a concrete type or generic would do
func Process(items []any) { ... } // loses all type safety, forces assertions everywhere
func Process[T any](items []T) { ... } // type-safe, same flexibility
Unsynchronized global mutable state
var globalCache = map[string]string{} // no mutex — a data race waiting to happen
// the moment two goroutines touch it concurrently
Sprawling "util" or "common" packages
package util
func FormatDate(...) {}
func ValidateEmail(...) {}
func ParseConfig(...) {}
func SendEmail(...) {}
// dozens of unrelated functions dumped into one package with no clear boundary
A package like this accumulates unrelated responsibilities over time, makes it hard to reason about what actually depends on what, and often becomes a dependency magnet that couples unrelated parts of a codebase together unnecessarily.
The underlying theme
Nearly every one of these traces back to designing for hypothetical future flexibility. Go's simplicity-first philosophy doesn't ask for that, and it costs the clarity and directness the language is actually optimized for.
Related Resources
Go has no single blessed configuration approach the way some frameworks in other languages do, but a few patterns are common and idiomatic.
A typical Config struct, populated from environment variables
type Config struct {
Port int
DatabaseURL string
LogLevel string
}
func LoadConfig() (*Config, error) {
port, err := strconv.Atoi(getEnv("PORT", "8080"))
if err != nil {
return nil, fmt.Errorf("invalid PORT: %w", err)
}
cfg := &Config{
Port: port,
DatabaseURL: os.Getenv("DATABASE_URL"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
Failing fast at startup
func main() {
cfg, err := LoadConfig()
if err != nil {
log.Fatalf("invalid configuration: %v", err) // fail immediately, not mid-request
}
// ... start server using cfg ...
}
Discovering a missing required setting the first time a specific request path is hit, minutes or hours after deployment, is a far worse operational experience than the process refusing to start at all with a clear error message.
Command-line flags for local development
port := flag.Int("port", 8080, "server port")
flag.Parse()
Flags are common for quick local overrides during development, though many production deployments rely primarily on environment variables since that's what container orchestrators (Kubernetes, ECS) naturally inject.
Structured config files for larger settings
# config.yaml
database:
host: localhost
port: 5432
pool_size: 10
logging:
level: info
format: json
For services with many structured settings, a YAML/TOML file parsed into a Config struct (often via viper or plain encoding/yaml) can be clearer than dozens of flat environment variables, sometimes combined with environment variables for secrets/environment-specific overrides on top of file-based defaults.
The general principle
Whatever combination of sources you use, validate the fully assembled configuration once, at startup, and fail immediately and loudly if anything required is missing or invalid.
Related Resources
Go and Rust get compared constantly since both emerged as "better than C/C++ for this specific niche," but they optimize for genuinely different things.
The core tradeoff
| Go | Rust | |
|---|---|---|
| Memory management | Garbage collected | Ownership/borrowing, no GC |
| Learning curve | Shallow — productive within days | Steep — borrow checker takes real time to internalize |
| Compile times | Fast | Often noticeably slower, especially for large projects |
| Runtime performance | Very good, GC pauses are brief but nonzero | Typically faster, zero GC overhead |
| Concurrency model | Goroutines + channels, simple to reason about | Fearless concurrency via ownership, but more upfront complexity |
| Common use cases | Network services, CLIs, infrastructure tooling, cloud platforms | Systems programming, embedded, performance-critical libraries, WASM |
A concrete example of the difference in feel
// Go: simple, GC handles the memory
func processData(data []byte) []byte {
result := make([]byte, len(data))
copy(result, data)
return result
}
// Rust: ownership rules enforced at compile time, no GC needed
fn process_data(data: &[u8]) -> Vec<u8> {
data.to_vec()
}
The Rust version has zero runtime GC overhead, but understanding exactly why &[u8] borrows without taking ownership, and how lifetimes interact with function signatures, takes real time to learn well.
Why cloud-native infrastructure tends toward Go
Kubernetes, Docker, Terraform, and most of the cloud-native tooling ecosystem are written in Go. Go's fast compile times, simple concurrency model, and easy cross-compilation were a strong fit for building networked infrastructure tools quickly. A large pool of developers can also contribute productively without a long ramp-up.
Why Rust wins elsewhere
Rust's zero-cost abstractions and compile-time memory safety guarantees make it the stronger choice in a few situations: when GC pauses are unacceptable (real-time systems, some game engines), when you need C-level control over memory layout, or when a garbage collector's runtime isn't available or desirable at all, like embedded systems or WebAssembly modules with tight resource budgets.
The honest framing for an interview
Neither language is strictly "better." Go trades some raw performance and memory-usage control for dramatically faster developer ramp-up and simpler concurrency. Rust trades a steeper learning curve for maximum performance and compile-time-guaranteed memory safety with zero GC overhead.