Skip to main content
  1. Languages/
  2. Golang Guides/

Mastering Structured Logging in Go: High-Performance Logging with Zap

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

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
#

  1. Why structured logging is non-negotiable for senior developers.
  2. How to implement Zap with optimal configurations.
  3. Handling log rotation with lumberjack.
  4. Injecting context and correlation IDs for distributed tracing.
  5. 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-zap

Now, 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.v2

Why 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:

  1. zap.Logger: The high-performance logger. It requires strongly typed fields (e.g., zap.String("key", "value")). It is faster because it avoids reflection.
  2. zap.SugaredLogger: A slightly slower (but still fast) logger that allows printf-style formatting and accepts interface{}. It “sweetens” the API for ease of use at the cost of small allocations.

Decision Flow: Which Logger to Use?
#

flowchart TD Start["Start Logging Implementation"] --> Q1{Is Performance<br/>Critical?} Q1 -- "Yes (Hot Paths)" --> UseLogger["Use zap.Logger<br/>(High Performance)"] Q1 -- "No (Startup/CLI)" --> UseSugar["Use zap.SugaredLogger<br/>(Convenient)"] UseLogger --> TypeCheck["Must use specific types<br/>e.g., zap.Int(), zap.String()"] UseSugar --> Flexible["Can use printf style<br/>and loose types"] TypeCheck --> Encode["Encoder<br/>(JSON / Console)"] Flexible --> Encode Encode --> Core["Zap Core"] Core --> Write["Write to Output"] style UseLogger fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px style UseSugar fill:#e3f2fd,stroke:#1565c0,stroke-width:2px style Encode fill:#fff3e0,stroke:#ef6c00,stroke-width:2px style Core fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px

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:

  1. ISO 8601 Time Formatting (Human readable timestamps).
  2. Log Rotation (Don’t fill up the disk).
  3. 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 reqLogger inside the context.Context and 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
#

  1. Use zap.Logger for hot paths and libraries; use SugaredLogger only for initialization or CLI tools where performance is less critical.
  2. Centralize Configuration: Create a singleton or dependency-injected logger wrapper to ensure consistency (timestamp formats, output paths) across your app.
  3. Context is King: Always attach Request IDs or Trace IDs to your logs. A log without context is just noise.
  4. Rotate Logs: Never deploy to a VM or bare metal without log rotation (Lumberjack).
  5. 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!