Introduction #
In the landscape of modern backend development, logging is not just about printing text to a terminal; it is the heartbeat of observability. As we move through 2025 and into 2026, the complexity of microservices and high-concurrency applications demands more than standard output. It demands Structured Logging.
If you are still using log.Printf or fmt.Println in your production Go applications, you are flying blind. Unstructured text logs are a nightmare to parse, difficult to query in tools like Elasticsearch or Datadog, and often lack the context required to debug race conditions or performance bottlenecks.
This guide focuses on Uber’s Zap, the de facto standard for high-performance, zero-allocation logging in the Go ecosystem. We aren’t just going to cover the basics; we will build a production-ready logging infrastructure that includes log rotation, context propagation, and performance tuning.
What You Will Learn #
- Why structured logging is non-negotiable for senior developers.
- How to implement Zap with optimal configurations.
- Handling log rotation with
lumberjack. - Injecting context and correlation IDs for distributed tracing.
- Performance pitfalls to avoid.
Prerequisites and Environment Setup #
Before we dive into the code, ensure your environment is ready. While Zap works with older Go versions, we assume you are using a modern setup to leverage recent language improvements.
- Go Version: Go 1.23 or higher (recommended for loop variable fixes and std lib enhancements).
- IDE: VS Code (with Go extension) or GoLand.
- Knowledge: Basic understanding of Go interfaces and concurrency.
Project Initialization #
Let’s create a dedicated directory for this tutorial to keep our dependencies clean.
mkdir go-logging-zap
cd go-logging-zap
go mod init github.com/yourname/go-logging-zapNow, let’s grab the necessary dependencies. We need Zap for logging and Lumberjack for file rotation (Zap does not handle file rotation internally by design).
go get -u go.uber.org/zap
go get -u gopkg.in/natefinch/lumberjack.v2Why Zap? The Landscape of Go Logging #
You might ask, “Why not use logrus or the new slog package introduced in Go 1.21?”
While slog is a fantastic addition to the standard library, Zap remains the king of performance for high-throughput systems. It achieves this by avoiding reflection and interface boxing wherever possible.
Here is a comparison of the current logging landscape:
| Feature | Standard log |
logrus |
slog (Std Lib) |
Uber zap |
|---|---|---|---|---|
| Structure | Unstructured Text | Structured (JSON/Text) | Structured | Structured (JSON/Console) |
| Performance | Fast (Simple) | Slow (Reflection heavy) | Good | Excellent (Zero-alloc) |
| Type Safety | No | Low | Medium | High (Strongly typed) |
| API DX | Simple | Developer Friendly | Modern | Verbose (for performance) |
| Production Ready | No | Yes | Yes | Yes (Battle-tested) |
Zap is designed for scenarios where every microsecond counts.
Part 1: The Zap Architecture #
To use Zap effectively, you must understand its dual-mode architecture. Zap provides two types of loggers:
zap.Logger: The high-performance logger. It requires strongly typed fields (e.g.,zap.String("key", "value")). It is faster because it avoids reflection.zap.SugaredLogger: A slightly slower (but still fast) logger that allowsprintf-style formatting and acceptsinterface{}. It “sweetens” the API for ease of use at the cost of small allocations.
Decision Flow: Which Logger to Use? #
Basic Implementation #
Let’s write a simple main.go to see the difference.
package main
import (
"time"
"go.uber.org/zap"
)
func main() {
// 1. Create a production logger (JSON output, Info level)
logger, _ := zap.NewProduction()
defer logger.Sync() // Flushes buffer, if any
// 2. The 'Sugar' logger for easy development
sugar := logger.Sugar()
sugar.Infof("This is a sugared logger: %s", "easier API")
// 3. The 'Base' logger for performance
// Notice we must use zap.String, zap.Int, zap.Duration
logger.Info("This is the base logger",
zap.String("type", "performance"),
zap.Int("attempt", 3),
zap.Duration("latency", time.Millisecond*150),
)
}Output:
{"level":"info","ts":1735689600.000,"caller":"go-logging-zap/main.go:16","msg":"This is a sugared logger: easier API"}
{"level":"info","ts":1735689600.001,"caller":"go-logging-zap/main.go:20","msg":"This is the base logger","type":"performance","attempt":3,"latency":0.15}Notice the JSON format. This is machine-readable, meaning you can easily ingest this into Splunk, ELK, or CloudWatch Logs.
Part 2: Building a Production-Ready Logger #
The default zap.NewProduction() is great, but in the real world, you need more control. You need:
- ISO 8601 Time Formatting (Human readable timestamps).
- Log Rotation (Don’t fill up the disk).
- Atomic Level Changing (Change log level without restarting).
Let’s build a custom package logger. Create a new folder pkg/logger and a file logger.go.
The logger.go Implementation
#
package logger
import (
"os"
"github.com/natefinch/lumberjack"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var Log *zap.Logger
// InitLogger initializes the global logger with custom configuration
func InitLogger() {
// 1. Define the Encoder Configuration
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "timestamp"
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder // 2026-01-01T12:00:00.000Z
encoderConfig.StacktraceKey = "" // Omit stacktrace for cleaner logs in non-error levels
// 2. Define the Encoder (JSON for prod, Console for dev can be logic-switched)
encoder := zapcore.NewJSONEncoder(encoderConfig)
// 3. Configure Log Rotation using Lumberjack
// This writes to a file, but also rotates it to prevent disk overflow
logWriter := &lumberjack.Logger{
Filename: "./logs/app.log",
MaxSize: 10, // Megabytes
MaxBackups: 5, // Number of old files to keep
MaxAge: 30, // Days
Compress: true, // Compress old log files
}
// MultiWriteSyncer allows us to write to both File and Stdout
writeSyncer := zapcore.NewMultiWriteSyncer(
zapcore.AddSync(logWriter),
zapcore.AddSync(os.Stdout),
)
// 4. Set Log Level
// In a real app, parse this from ENV variables (e.g., LOG_LEVEL=debug)
core := zapcore.NewCore(
encoder,
writeSyncer,
zapcore.InfoLevel,
)
// 5. Create the Logger
// AddCaller adds line number info
Log = zap.New(core, zap.AddCaller())
}Why Lumberjack? #
Go applications are often deployed in containers (Docker/Kubernetes). While standard practice in K8s is to write to stdout and let the container runtime handle rotation, many hybrid or bare-metal deployments still require file logging.
lumberjack.Logger implements io.Writer, making it perfectly compatible with Zap’s WriteSyncer. It handles the messy work of renaming, compressing, and deleting old log files.
Part 3: Contextual Logging and Middleware #
One of the biggest challenges in backend development is tracing a request as it moves through various layers (Controller -> Service -> Database).
To solve this, we should attach a Correlation ID (or Request ID) to every log generated during a specific HTTP request.
Middleware Implementation #
Here is how you might implement a simple HTTP middleware that injects a logger with a Request ID into the request context.
package main
import (
"net/http"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
// Import our custom logger package
"github.com/yourname/go-logging-zap/pkg/logger"
)
func main() {
logger.InitLogger()
defer logger.Log.Sync()
http.HandleFunc("/api/v1/data", withLogging(dataHandler))
logger.Log.Info("Server starting on port 8080...")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Log.Fatal("Server failed to start", zap.Error(err))
}
}
// withLogging Middleware
func withLogging(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Generate a unique Request ID
reqID := uuid.New().String()
// Create a logger instance specifically for this request
// With() creates a child logger that always includes the req_id
reqLogger := logger.Log.With(zap.String("req_id", reqID))
reqLogger.Info("Incoming Request",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr),
)
// Call the next handler
next(w, r)
reqLogger.Info("Request Completed",
zap.Duration("duration", time.Since(start)),
zap.Int("status", 200), // In real code, capture the status code wrapper
)
}
}
func dataHandler(w http.ResponseWriter, r *http.Request) {
// Simulate work
time.Sleep(100 * time.Millisecond)
w.Write([]byte("Hello, World!"))
}Pro Tip: In a real-world scenario, you shouldn’t just pass the logger around manually. You would typically store the
reqLoggerinside thecontext.Contextand retrieve it in deeper layers of your application.
Part 4: Advanced Performance Tuning #
Zap is fast, but you can make it slow if you use it incorrectly. Here are the common pitfalls senior developers spot in code reviews.
1. Avoid fmt.Sprintf Inside Logging
#
This is the most common mistake.
Bad:
// Allocates a string, then Zap allocates again to store it
logger.Info(fmt.Sprintf("User %s logged in", username))Good (Sugared):
sugar.Infof("User %s logged in", username)Best (Structured):
logger.Info("User logged in", zap.String("username", username))The structured approach allows the JSON encoder to treat “username” as a field, rather than parsing a string blob.
2. Using zap.Any Lazily
#
zap.Any relies on reflection to determine the type of the variable. While convenient, it defeats the purpose of using a zero-allocation library.
Avoid:
logger.Info("Data processed", zap.Any("count", 500))Prefer:
logger.Info("Data processed", zap.Int("count", 500))3. Conditional Logging for Expensive Computations #
If you have a debug log that requires heavy calculation, check the level first.
// Even if Level is INFO, 'heavyCalculation()' runs!
logger.Debug("Stats", zap.Int("stat", heavyCalculation()))
// Correct Optimization:
if logger.Core().Enabled(zap.DebugLevel) {
logger.Debug("Stats", zap.Int("stat", heavyCalculation()))
}Summary and Best Practices #
To wrap up, transitioning to Structured Logging with Zap is a pivotal step in maturing your Golang applications. It moves you from “guessing what happened” to “querying exactly what happened.”
Key Takeaways #
- Use
zap.Loggerfor hot paths and libraries; useSugaredLoggeronly for initialization or CLI tools where performance is less critical. - Centralize Configuration: Create a singleton or dependency-injected logger wrapper to ensure consistency (timestamp formats, output paths) across your app.
- Context is King: Always attach Request IDs or Trace IDs to your logs. A log without context is just noise.
- Rotate Logs: Never deploy to a VM or bare metal without log rotation (Lumberjack).
- Type Safety: Stick to
zap.String,zap.Int, etc., to minimize GC pressure.
Further Reading #
By adopting these patterns, you ensure your application is observable, debuggable, and performant—ready for the demands of 2026 and beyond.
Happy Coding!