Last updated: September 15, 2025

A Practitioner's Guide to Logging in Go with Zap

For years, Go developers faced a choice when it came to logging: use the minimalist standard log package or adopt a more powerful, third-party library.

Among the contenders, Uber's Zap quickly distinguished itself with a relentless focus on performance and structured, leveled logging.

Zap isn't just another logging library; it's an opinionated toolkit designed for high-throughput, low-latency systems where every allocation matters.

While Go 1.21 introduced the log/slog package as a new standard, Zap remains a top choice for developers who need maximum performance and fine-grained control over their logging output.

This guide will take you from the fundamentals of Zap to advanced, production-ready patterns. By the end, you'll understand how to configure Zap for different environments, add rich context to your logs, and make logging a powerful signal for observing your applications.

Let's get started!

Understanding the Zap philosophy

Zap is built around two core APIs: the high-performance zap.Logger and the more convenient, but slightly slower, zap.SugaredLogger.

The zap.Logger type is designed to maximize the performance of your logging calls. It achieves this by avoiding interface{} and reflection entirely, and requiring the use of zap.Field helpers (such as zap.String() and zap.Int()) to add structured context, which guarantees type safety and minimizes allocations.

go
12345678910111213
func main() {
logger := zap.Must(zap.NewProduction())
defer logger.Sync() // Flushes any buffered log entries
logger.Info(
"incoming request",
zap.String("method", "GET"),
zap.String("path", "/login"),
zap.String("client_ip", "127.0.0.1"),
zap.String("user_agent", "curl/8.7.1"),
)
}

On the other hand, zap.SugaredLogger provides a less verbose and loosely-typed API (such as Infof() and Errorw()) that allows you write less code in your logging calls. It's more convenient for developers who are used to printf-style logging but comes with a minor performance penalty.

go
12345678910111213
func main() {
logger := zap.Must(zap.NewProduction()).Sugar()
defer logger.Sync()
logger.Infow(
"incoming request",
"method", "GET",
"path", "/login",
"client_ip", "127.0.0.1",
"user_agent", "curl/8.7.1",
)
}

This approach lets you choose between raw performance and developer convenience, often using the SugaredLogger for general application code and dropping down to the zap.Logger in performance-critical hot paths.

However, since we're focused entirely on a production-ready configuration, we'll continue to use the zap.Logger API throughout this tutorial.

Getting started with Zap

To start logging with Zap, you need to add it to your project:

go
1
go get -u go.uber.org/zap

Unlike log/slog, Zap doesn't come with a pre-configured default logger. You must create one. Zap provides helpful presets for common environments.

For development, you'll want human-readable, colorized output that logs at the Debug level. zap.NewDevelopment() is the preset for this:

go
12345678
logger := zap.Must(zap.NewDevelopment())
defer logger.Sync()
logger.Info("User logged in",
zap.String("username", "testuser"),
zap.Int("user_id", 123),
)

The output is a console-friendly format that's much easier to read during development:

text
1
2025-09-11T11:45:24.917+0200 INFO golang-zap/main.go:12 User logged in {"username": "testuser", "user_id": 123}

In production, you'll want JSON-formatted logs at the Info level or higher. The zap.NewProduction() preset provides this out of the box:

go
12345678
logger := zap.Must(zap.NewProduction())
defer logger.Sync()
logger.Info("User logged in",
zap.String("username", "testuser"),
zap.Int("user_id", 123),
)

This sends a structured JSON log to the standard error, which is the de facto standard for modern observability pipelines:

json
12345678
{
"level": "info",
"ts": 1757583976.104625,
"caller": "golang-zap/main.go:12",
"msg": "User logged in",
"username": "testuser",
"user_id": 123
}

Log levels in Zap

The following log levels are supported in Zap:

LevelNumeric valueTypical use case
DEBUG-1For recording debugging messages
INFO0Normal application events
WARN1Potential problems worth attention
ERROR2Unexpected error conditions
DPANIC3Calls panic() in development but logs an error in
production
PANIC4Calls panic() after logging an error
FATAL5Calls os.Exit(1) after logging an error
go
1234567
logger.Debug("a debug message")
logger.Info("an info message")
logger.Warn("a warning message")
logger.Error("an error message")
logger.DPanic("a dpanic message")
logger.Panic("a panic message")
logger.Fatal("a fatal message")

The default level in Zap's production preset is set to INFO. If you desire to change this, you must create a custom logger as we'll discuss next.

Customizing Zap with zap.Config

While presets offer a great starting point, you'll eventually need more control. The zap.Config type provides a simple, declarative way to construct a logger for the most common use cases.

It's a simpler way to toggle common options, but it intentionally doesn't cover every possibility. For more advanced setups, you'll need to use the lower-level zapcore package directly.

Here's how to build a custom logger that:

  1. Logs to the standard output.
  2. Uses ISO-8601 timestamps.
  3. Sets the log level from an environment variable.
  4. Attaches caller information to each log.
go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
func createLogger() *zap.Logger {
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.TimeKey = "timestamp"
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
config := zap.Config{
Level: zap.NewAtomicLevelAt(getLogLevelFromEnv()),
Development: false,
DisableCaller: false,
DisableStacktrace: false,
Sampling: nil,
Encoding: "json",
EncoderConfig: encoderCfg,
OutputPaths: []string{
"stdout",
},
ErrorOutputPaths: []string{
"stdout",
},
InitialFields: map[string]any{
"pid": os.Getpid(),
},
}
return zap.Must(config.Build())
}
func getLogLevelFromEnv() zapcore.Level {
levelStr := strings.ToLower(os.Getenv("LOG_LEVEL"))
switch levelStr {
case "debug":
return zap.DebugLevel
case "info":
return zap.InfoLevel
case "warn":
return zap.WarnLevel
case "error":
return zap.ErrorLevel
case "dpanic":
return zap.DPanicLevel
case "panic":
return zap.PanicLevel
case "fatal":
return zap.FatalLevel
default:
return zap.InfoLevel
}
}

This example introduces the zap.Config type which provides an easy way to create a custom logger. It starts using a zap.NewProductionEncoderConfig() as a baseline, then customizes it by renaming the default ts key to timestamp and setting the time format to ISO-8601.

Next, it populates the main zap.Config struct with several options:

  • Level: The minimum log level is set dynamically from the LOG_LEVEL environment variable using zap.NewAtomicLevelAt(). This creates a level that can be changed safely at runtime.

  • Encoding: This is set to json for structured, machine-readable output suitable for production.

  • OutputPaths: A list of destinations for logs. Here, stdout directs all logs to the console.

  • DisableCaller: Setting this to false ensures that the caller field (file and line number) is included in every log entry.

  • InitialFields: A map of fields that will be added to every single log entry. This is perfect for adding global, static context like the process ID.

Finally, config.Build() is called to construct the *zap.Logger instance from these specifications. You can then use the logger as follows:

go
1234567
func main() {
logger := createLogger()
defer logger.Sync()
logger.Info("an info message")
}

Resulting in the following output:

json
1234567
{
"level": "info",
"timestamp": "2025-09-13T10:14:09.497+0100",
"caller": "golang-zap/main.go:43",
"msg": "an info message",
"pid": 39875
}

Understanding the Zapcore package

For maximum control and flexibility, you need to work directly with the zapcore package, as it provides the low-level interfaces that the main zap package uses to build its loggers. While it requires more setup than zap.Config, it unlocks the full power of Zap, allowing you to do

The zapcore package is built around three main interfaces that define a logging pipeline:

  • Encoder: This determines the format of your logs. It takes the raw log data (the message, level, timestamp, and fields) and serializes it into a specific format, like a JSON string (NewJSONEncoder) or a human-readable line for the console (NewConsoleEncoder).

  • WriteSyncer: This defines the destination of your logs. It's simply an interface that specifies where the formatted log bytes are written, such as to the console (os.Stdout), a file, or a network socket.

  • Core: This is the central component that ties everything together. A Core combines an Encoder, a WriteSyncer, and a minimum enabled log level. It's the "brain" that decides if a log entry should be processed and, if so, passes it through the pipeline.

Setting up a global logger

While passing a logger instance explicitly through dependency injection is the most testable and maintainable pattern, it can sometimes be cumbersome.

For convenience, Zap allows you to set a package-level global logger that can be accessed from anywhere in your code. You can set this global logger using the zap.ReplaceGlobals() function at the start of your application:

go
123456789
func main() {
logger := createLogger()
defer logger.Sync()
zap.ReplaceGlobals(logger)
zap.L().Info("Hello from global logger")
}

Once set, you can access the standard Logger type via zap.L() and the SugaredLogger via zap.S().

json
1234567
{
"level": "info",
"timestamp": "2025-09-15T05:52:53.892+0100",
"caller": "golang-zap/main.go:68",
"msg": "Hello from global logger",
"pid": 4485
}

Redirecting the standard library's Logger

A common problem is unifying log output from third-party libraries that still use Go's older log API. Zap provides a powerful utility, zap.RedirectStdLog(), to solve this. It captures output from the standard logger and redirects it through Zap's global instance, ensuring all logs have a consistent format:

go
1234567891011
func main() {
logger := createLogger()
defer logger.Sync()
zap.RedirectStdLog(logger)
log.Printf("This is a log message")
slog.Info("This is an log/slog message")
}

This is great for ensuring that all log output from your entire application and its dependencies is structured and routed through a single, configurable pipeline.

json
12
{"level":"info","timestamp":"2025-09-15T06:11:59.047+0100","caller":"golang-zap/main.go:74","msg":"This is a log message.","pid":5996}
{"level":"info","timestamp":"2025-09-15T06:11:59.047+0100","caller":"slog/handler.go:122","msg":"INFO This is an log/slog mesage","pid":5996

Contextual logging patterns

Effective logging is all about context: the additional attributes added to understand the event being logged.

As shown before, you can add context at the call site using zap.Field helpers:

go
12345
logger.Error("Failed to create user",
zap.String("username", "newuser"),
zap.Int("user_id", 12243),
zap.Error(err),
)

zap.Error(err) is a special helper that correctly formats the error message and, for production loggers, can automatically include a stack trace if DisableStacktrace is false.

If you need to repeat contextual attributes in several log records within the same scope, you can create a child logger with that context baked in though the With() method:

go
1234
requestLogger := logger.With(zap.String("request_id", "req-42"), zap.String("user_id", "usr-1234"))
requestLogger.Info("Processing request")
requestLogger.Warn("Failed to update record")

Both log messages will now automatically contain the request_id and user_id fields:

json
12
{...,"msg":"Processing request","pid":99349,"request_id":"req-42","user_id":"usr-1234"}
{...,"msg":"Failed to update record","pid":99349,"request_id":"req-42","user_id":"usr-1234"}

Context propagation with context.Context

In Go applications, context.Context is the standard way to pass request-scoped data around. Since Zap doesn't have a built-in context-aware methods like slog, it's idiomatic to embed your logger into the context.

Here's a simple middleware pattern for a web server:

go
12345678910111213141516171819202122232425262728293031323334
type loggerKey struct{}
// NewContext stores the logger in the context.
func NewContext(ctx context.Context, logger *zap.Logger) context.Context {
return context.WithValue(ctx, loggerKey{}, logger)
}
// FromContext retrieves the logger from the context.
func FromContext(ctx context.Context) *zap.Logger {
if l, ok := ctx.Value(loggerKey{}).(*zap.Logger); ok {
return l
}
// Return a no-op logger if none is found
return zap.NewNop()
}
// Middleware to inject a request-scoped logger
func LoggerMiddleware(next http.Handler, baseLogger *zap.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := uuid.New().String()
logger := baseLogger.With(zap.String("request_id", requestID))
ctx := NewContext(r.Context(), logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// In your handler
func MyHandler(w http.ResponseWriter, r *http.Request) {
logger := FromContext(r.Context())
logger.Info("Handling user request")
// ...
}

This pattern ensures that every part of your application has access to a logger that is pre-loaded with the correct request context.

In high-traffic systems, logging every single event can be prohibitively expensive and overwhelm your observability platform. Sampling allows you to log a subset of messages to reduce volume while retaining a representative picture.

Zap's built-in sampler logs the first N messages within a time window, and then one message every M subsequent messages.

go
123456789
// Log the first 5 messages per second, then 1 in every 100 after that.
samplingCore := zapcore.NewSamplerWithOptions(
baseCore, // The core to wrap
time.Second, // The time window
5, // Log the first 5 entries
100, // Then log 1 out of every 100
)
logger := zap.New(samplingCore)

This is incredibly useful for taming noisy logs (e.g., "cache miss") that might otherwise flood your system.

Triggering actions on logs with Hooks

A Hook is a function that gets called every time a log entry is written. This allows you to trigger side effects, like sending notifications for critical errors.

go
123456789101112
func notifyOnCritical(entry zapcore.Entry) error {
if entry.Level >= zapcore.ErrorLevel {
// Send a notification to Slack, PagerDuty, etc.
fmt.Printf("ALERT! Critical error: %s\n", entry.Message)
}
return nil
}
// Register the hook with the logger
logger := zap.New(core, zap.Hooks(notifyOnCritical))
logger.Error("Database connection lost") // This will now trigger the hook

Final thoughts

log/slog has rightfully become the new standard for logging in Go, providing a much-needed common API. However, Zap continues to be a powerful and relevant choice for projects that demand a higher level of performance and customization.

Its dual Logger/SugaredLogger API offers a pragmatic balance between speed and ergonomics. Its Core system provides a flexible foundation for building complex logging pipelines, and its focus on zero-allocation, structured logging makes it a perfect fit for modern, observable systems.

By treating logging as a core component of your application's architecture—with structured data, request context, and appropriate sampling—you can transform your logs from a simple debugging tool into a rich, queryable dataset that provides deep insights into your system's behavior.

Thanks for reading!

Authors
Ayooluwa Isaiah
Ayooluwa Isaiah