The problem with fmt.Println
You write a small HTTP server. You sprinkle fmt.Println everywhere to track requests. It works fine locally. You deploy to a staging environment with three replicas. Suddenly your terminal fills with interleaved log lines, half-finished strings, and timestamps that drift out of sync. You try to grep for a specific user ID and get nothing. You try to parse the output into a dashboard and the regex breaks on the first quoted string.
Freeform logging feels easy until your system grows. Strings are unstructured. They force you to write parsing logic later, or worse, they force you to read logs like a detective scanning a wall of text. Structured logging solves this by treating every log line as a machine-readable record. You stop writing sentences. You start shipping key-value pairs.
What structured logging actually does
Structured logging replaces freeform text with typed fields. Instead of "User 12345 logged in from 192.168.1.1 in 1.5s", you ship a record with user_id, ip, and duration. The logger handles serialization. The output becomes JSON, console-friendly text, or a custom format your observability pipeline expects.
Think of it like a shipping manifest versus a handwritten note. The note says "fragile, keep dry, heavy." The manifest has labeled boxes for each attribute. When the warehouse system reads the manifest, it knows exactly where to look. When your log aggregation tool reads structured output, it indexes every field automatically. You can filter by status_code=500 without writing a single regular expression.
Zap is the most popular third-party logging library in Go. It was built by Uber to handle high-throughput services where every microsecond and every heap allocation matters. It separates the fast path from the convenient path. The core API gives you zero-allocation logging in hot loops. The Sugar API gives you fmt.Printf-style convenience with a small performance tax.
Your first zap logger
Here is the minimal production setup. You configure the encoder, build the logger, and flush it on exit.
package main
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func main() {
// Start with Uber's production defaults: JSON output, sampling, stack traces on panic
config := zap.NewProductionConfig()
// Override time format to ISO8601 for consistent parsing across timezones
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
// Build the logger with a caller skip to point to your code, not zap internals
logger, err := config.Build(zap.AddCallerSkip(1))
if err != nil {
panic(err)
}
// Flush buffered entries to stdout before the process terminates
defer logger.Sync()
// Log a structured event with typed fields
logger.Info("user login successful",
zap.String("user_id", "12345"),
zap.String("ip", "192.168.1.1"),
zap.Duration("duration", 1500000000),
zap.Bool("is_new_user", true),
)
}
The output is a single JSON line. Every field is typed. The timestamp is consistent. The caller skip ensures the file and line numbers point to your source code instead of the Zap wrapper.
Flush buffers on exit. Silent data loss is worse than a missing log line.
How zap moves data through your program
Zap does not write to disk or stdout immediately. Immediate I/O is slow. Instead, it batches log entries in an in-memory buffer. When the buffer fills, or when you call Sync(), it writes the batch to the configured output. This batching is what makes Zap fast enough for high-throughput services.
The pipeline has three stages. First, you construct a log entry by calling a level method like Info() or Error(). You pass a message and a slice of zap.Field values. Second, the encoder converts those fields into bytes. The encoder respects your configuration: JSON, console, or a custom format. Third, the writer receives the bytes and appends them to the buffer.
The core API avoids allocations by reusing a thread-local buffer and constructing fields through type-specific helpers. zap.String("key", "value") does not allocate a new map. It writes directly into the encoder's scratch space. The Sugar API trades that efficiency for convenience. logger.Infow("msg", "key", "value") accepts variadic arguments and converts them to fields under the hood. That conversion allocates a few small objects. In a tight loop processing thousands of requests per second, those allocations add up. In a background job running once a minute, they do not matter.
The Go community accepts explicit field construction because it makes the data shape visible at compile time. You cannot accidentally log a nil pointer or a malformed timestamp. The type system catches mistakes before they reach production.
Logging in a real HTTP handler
Real services log in context. You want to attach a request ID, a user ID, or a trace ID to every log line within that request's lifecycle. Zap provides With() for this. It returns a new logger instance that carries the extra fields. You pass that enriched logger down the call chain.
package main
import (
"net/http"
"go.uber.org/zap"
)
// HandleRequest processes an incoming HTTP request with structured logging
func HandleRequest(logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Attach request-scoped fields to create a child logger
reqLogger := logger.With(
zap.String("request_id", "abc-123"),
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
)
// Log the start of processing
reqLogger.Info("request started")
// Simulate a business operation that returns an error
err := processPayment(reqLogger, r)
if err != nil {
// Pass the error object directly; zap formats it and attaches a stack trace if configured
reqLogger.Error("payment failed", zap.Error(err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reqLogger.Info("request completed", zap.Int("status", 200))
}
}
// processPayment simulates a downstream call
func processPayment(logger *zap.Logger, r *http.Request) error {
// In a real service, this would call a database or external API
// Returning a wrapped error preserves the call chain
return nil
}
The child logger inherits the parent's configuration and output destination. It only adds the new fields. You never repeat request_id or method in every log call. This pattern keeps your code clean and your logs consistent.
Pass the enriched logger down. Repeat yourself only when the scope changes.
Where things go wrong
Structured logging introduces a few failure modes. The most common is forgetting to flush. If your program crashes or exits normally without calling Sync(), the in-memory buffer drops. You lose the last few seconds of logs. Always pair logger.Sync() with defer.
Another trap is mixing the core API and Sugar API incorrectly. The core API expects zap.Field arguments. If you pass raw strings or integers, the compiler rejects the program with cannot use "value" (untyped string constant) as zap.Field value in argument. The Sugar API expects alternating key-value pairs. If you pass an odd number of arguments, you get a runtime panic about mismatched keys and values. Stick to one style per codebase, or wrap the Sugar logger in a middleware that enforces consistency.
Allocation overhead bites in hot paths. If you log inside a loop that runs ten thousand times per second, the Sugar API's variadic conversion will trigger garbage collection pauses. Switch to the core API. Construct fields explicitly. The extra keystrokes pay for themselves in latency reduction.
Error handling follows Go conventions. You do not convert errors to strings manually. You pass the error value to zap.Error(err). Zap extracts the message and, if your config enables it, attaches a stack trace. Converting to a string yourself strips the type information and breaks error wrapping chains.
Trust the buffer. Flush on exit. Keep hot paths allocation-free.
When to reach for zap
Use the Zap core API when you log in tight loops or latency-sensitive handlers and need zero-allocation field construction. Use the Zap Sugar API when you are prototyping, writing background jobs, or prefer fmt.Printf-style variadic arguments and can tolerate minor allocation overhead. Use logger.With() when you need to attach request-scoped or transaction-scoped fields to every log line in a call chain. Use zap.Error() when you want the logger to handle error formatting and optional stack traces instead of manual string conversion. Use the standard library slog package when you want built-in Go support without third-party dependencies and your throughput requirements are moderate. Use plain fmt.Println only for throwaway scripts, local debugging, or tools that never ship to production.