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.
go12345678910111213func main() {logger := zap.Must(zap.NewProduction())defer logger.Sync() // Flushes any buffered log entrieslogger.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.
go12345678910111213func 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:
go1go 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:
go12345678logger := 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:
text12025-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:
go12345678logger := 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:
json12345678{"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:
Level | Numeric value | Typical use case |
---|---|---|
DEBUG | -1 | For recording debugging messages |
INFO | 0 | Normal application events |
WARN | 1 | Potential problems worth attention |
ERROR | 2 | Unexpected error conditions |
DPANIC | 3 | Calls panic() in development but logs an error in |
production | ||
PANIC | 4 | Calls panic() after logging an error |
FATAL | 5 | Calls os.Exit(1) after logging an error |
go1234567logger.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:
- Logs to the standard output.
- Uses ISO-8601 timestamps.
- Sets the log level from an environment variable.
- Attaches caller information to each log.
go123456789101112131415161718192021222324252627282930313233343536373839404142434445464748func createLogger() *zap.Logger {encoderCfg := zap.NewProductionEncoderConfig()encoderCfg.TimeKey = "timestamp"encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoderconfig := 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.DebugLevelcase "info":return zap.InfoLevelcase "warn":return zap.WarnLevelcase "error":return zap.ErrorLevelcase "dpanic":return zap.DPanicLevelcase "panic":return zap.PanicLevelcase "fatal":return zap.FatalLeveldefault: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 theLOG_LEVEL
environment variable usingzap.NewAtomicLevelAt()
. This creates a level that can be changed safely at runtime. -
Encoding
: This is set tojson
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 tofalse
ensures that thecaller
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:
go1234567func main() {logger := createLogger()defer logger.Sync()logger.Info("an info message")}
Resulting in the following output:
json1234567{"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. ACore
combines anEncoder
, aWriteSyncer
, 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:
go123456789func 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()
.
json1234567{"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:
go1234567891011func 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.
json12{"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:
go12345logger.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:
go1234requestLogger := 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:
json12{...,"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:
go12345678910111213141516171819202122232425262728293031323334type 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 foundreturn zap.NewNop()}// Middleware to inject a request-scoped loggerfunc 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 handlerfunc 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.
go123456789// Log the first 5 messages per second, then 1 in every 100 after that.samplingCore := zapcore.NewSamplerWithOptions(baseCore, // The core to wraptime.Second, // The time window5, // Log the first 5 entries100, // 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.
go123456789101112func 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 loggerlogger := 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!
