Configure Zap logging in Go: fields, levels, outputs, and rotation

Use Zap typed and sugared APIs, configure JSON and dynamic levels, route events to multiple outputs, and validate file rotation and Guance collection.

Best practices
Illustration of connected application signals and engineering workflows

Zap is a structured logging library for Go with two APIs: a typed Logger for explicit fields and a SugaredLogger for convenient formatted messages or key-value pairs. Its design reduces reflection and allocation work in common logging paths, but the cost of a real log event still depends on field encoding, message construction, output, and buffering.

This guide covers production and development presets, configuration, dynamic levels, multiple destinations, and rotating files. Start with the output contract your collector needs, then measure the workload you actually run.

Choose an API and a preset

Add Zap to an existing Go module:

go get go.uber.org/zap

This complete program writes through both APIs:

package main

import (
    "fmt"
    "os"

    "go.uber.org/zap"
)

func main() {
    logger, err := zap.NewProduction()
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        return
    }
    defer func() {
        if err := logger.Sync(); err != nil {
            fmt.Fprintf(os.Stderr, "sync logs: %v\n", err)
        }
    }()

    logger.Info("Order accepted",
        zap.String("order_id", "ord-123"),
        zap.Int("items", 3),
    )
    sugar := logger.Sugar()
    sugar.Infof("Connected to %s", "inventory")
    sugar.Infow("Order accepted", "order_id", "ord-124", "items", 2)
}

Infof formats a string. Infow attaches named fields, while the typed API uses helpers such as zap.String, zap.Int, zap.Duration, and zap.Error. Choose field names and types consistently; downstream queries should not receive a numeric items field in one path and a string in another.

NewProduction writes JSON at Info and above to stderr by default. It includes caller information, error-level stack traces, and sampling for repeated messages. Its standard ts field is a floating-point number of seconds since the Unix epoch. These defaults are useful starting points, not an application-specific delivery policy.

NewDevelopment uses a human-readable console encoder, enables Debug, and also writes to stderr. It includes stack traces at lower severity and makes DPanic panic. Colored levels require selecting a color level encoder; console output does not imply color by default. See the Zap presets and API reference.

Neither preset makes Sync universally successful on a terminal: some operating systems return an unsupported-operation error when syncing stdout or stderr. Treat that separately from a real file or network flush failure. Do not suppress every sync error just because a terminal produced one during development.

Customize a complete production configuration

Starting from a preset retains useful encoder defaults. A partial EncoderConfig assembled from scratch can accidentally omit important fields or encoders.

package main

import (
    "fmt"
    "os"

    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
)

func main() {
    level := zap.NewAtomicLevelAt(zap.InfoLevel)
    cfg := zap.NewProductionConfig()
    cfg.Level = level
    cfg.Encoding = "json"
    cfg.OutputPaths = []string{"stdout"}
    cfg.ErrorOutputPaths = []string{"stderr"}
    cfg.EncoderConfig.TimeKey = "time"
    cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
    cfg.EncoderConfig.EncodeLevel = zapcore.LowercaseLevelEncoder
    cfg.Sampling = nil // Keep every enabled event in this example.

    logger, err := cfg.Build()
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        return
    }
    defer func() {
        if err := logger.Sync(); err != nil {
            fmt.Fprintf(os.Stderr, "sync logs: %v\n", err)
        }
    }()

    log := logger.With(zap.String("service", "order-service"))
    log.Debug("Hidden at the initial level")
    level.SetLevel(zap.DebugLevel)
    log.Debug("Debug logging enabled", zap.String("request_id", "req-42"))
}

This configuration sends application events to stdout and Zap's internal error output to stderr. ErrorOutputPaths does not mean “send every application event at Error level here”; multi-destination routing requires separate cores or output configuration.

Disabling sampling preserves enabled repeated events at the cost of potentially much greater volume. Production sampling ordinarily limits repeated events by level and message; variable fields do not necessarily make a repeated message distinct. If logs feed exact-count alerts, audit records, or reconciliation, decide whether sampling is compatible with that purpose. Size the destination and collector for the chosen policy.

Use With for stable context shared by a component or request. Include approved identifiers, and avoid dumping entire request or user objects when a few fields will answer the query.

Change levels at runtime

AtomicLevel is safe to change while other goroutines log. Assign it to the configuration before Build, then retain that same level controller. Constructing a new AtomicLevel later does not retarget an existing logger.

The preceding program changes the level in process. AtomicLevel also implements an HTTP handler with GET and PUT operations. If you expose it through an administrative route, provide authentication, authorization, and restricted network access around that handler. The level handler itself is not an authenticated administration service. See AtomicLevel.ServeHTTP.

Temporary debug logging needs an owner and an expiry or reset path. A process-local change may also disappear on restart and does not automatically update every replica. Track the intended fleet-wide policy separately from one instance's current value.

Send events to multiple destinations

zapcore.NewTee combines cores. Each core has its own encoder, writer, and level rule. This complete example sends Info and above to stdout and a second copy of Error and above to a file:

package main

import (
    "fmt"
    "os"

    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
)

func main() {
    file, err := os.OpenFile("errors.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0640)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        return
    }
    defer file.Close()
    enc := zap.NewProductionEncoderConfig()
    core := zapcore.NewTee(
        zapcore.NewCore(zapcore.NewJSONEncoder(enc),
            zapcore.Lock(zapcore.AddSync(os.Stdout)), zap.InfoLevel),
        zapcore.NewCore(zapcore.NewJSONEncoder(enc),
            zapcore.Lock(zapcore.AddSync(file)), zap.ErrorLevel),
    )
    logger := zap.New(core, zap.AddCaller())
    defer func() {
        if err := logger.Sync(); err != nil {
            fmt.Fprintf(os.Stderr, "sync logs: %v\n", err)
        }
    }()
    logger.Info("Service started")
    logger.Error("Inventory unavailable", zap.String("order_id", "ord-123"))
}

Each core has a separate encoder, and the writers are protected for concurrent use. Deferred calls run in reverse order, so the logger sync runs before the file closes. The error appears in both destinations because both thresholds allow it. If a collector ingests both outputs into the same dataset, that becomes a duplicate unless intentionally separated or deduplicated.

This custom core does not automatically inherit production sampling or stack-trace options merely because it uses the production encoder configuration. Add each behavior deliberately.

Rotate a managed file with lumberjack

For applications that own their log files, the lumberjack writer can rotate by size and clean up backups:

go get gopkg.in/natefinch/lumberjack.v2

The following setup fragment belongs in a function that owns the logger lifecycle:

rotator := &lumberjack.Logger{
    Filename:   "logs/app.log",
    MaxSize:    100,
    MaxBackups: 10,
    MaxAge:     30,
    Compress:   true,
}
defer rotator.Close()
core := zapcore.NewCore(
    zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
    zapcore.AddSync(rotator),
    zap.InfoLevel,
)
logger := zap.New(core)
defer func() {
    if err := logger.Sync(); err != nil {
        fmt.Fprintf(os.Stderr, "sync logs: %v\n", err)
    }
}()
logger.Info("File logging ready")

Import gopkg.in/natefinch/lumberjack.v2 along with fmt, os, Zap, and zapcore. The library calls MaxSize megabytes and implements each unit as 1,024 × 1,024 bytes. MaxBackups limits backup count; MaxAge removes sufficiently old backups. Ten backups under heavy rotation may cover much less than 30 days, so this is not a promise of 30-day retention. See lumberjack.

AddSync adds a no-op sync method when a writer lacks one; wrapping lumberjack this way does not create a durable disk-flush guarantee. Its Close still belongs in the lifecycle. Plan writer errors, rotation behavior, collector file tracking, and disk capacity separately. A rotation writer should not be shared by multiple processes writing the same file.

For containers, stdout is often a simpler ownership model: the runtime and collection pipeline manage files and shipping. Configure runtime rotation and disk limits; stdout alone does not prevent local disk exhaustion.

Understand the cost of the sugared API

The typed logger gives Zap explicit field types and can avoid work that flexible argument processing requires. The sugared API may be easier for ordinary application code, but it performs formatting or key-value validation. Do not turn the project's benchmark results into a guarantee that every event allocates zero bytes or that one library always wins.

Compare with your actual field count and types, enabled and disabled levels, encoders, and writers. Include argument construction: an expensive function passed to Debug executes before the call even when the level is disabled. Where that work matters, use the logger's checked-entry pattern or another appropriate guard.

For Infow, provide string keys and balanced key-value pairs. Malformed arguments produce Zap diagnostics and can be dropped; behavior can differ for some invalid cases in development mode. Typed fields avoid that particular class of pairing error. Test important output schemas rather than relying on a visually plausible call.

Collect Zap JSON in Guance

Configure DataKit log collection for the selected stdout stream or managed file. Parse one complete JSON event per record, then map timestamp, level, message, service, and business fields to the intended schema.

The default production timestamp is numeric; the custom configuration above uses an ISO 8601 string. Match the parser to the configuration actually deployed. Also check a repeated message if sampling is enabled, an exception or error field, and a multiline stack trace serialized inside JSON.

For application performance monitoring (APM) correlation, emit the actual active trace and span identifiers from the request context. A field named request_id remains a request identifier unless it truly represents the tracing system's trace ID. Verify one log-to-trace jump on retained data before depending on it during an incident.

Common questions

Is Zap faster than Zerolog?

The answer depends on the workload and configuration. Compare both with the same fields, output destination, and enabled levels. Measure allocations, throughput, and tail latency under realistic output pressure; a microbenchmark of encoding alone does not represent a blocked file or network sink.

Must I call Sync?

Call it at the end of a logger lifecycle when the configured sinks require flushing, and handle relevant errors. Confirm what each writer's Sync actually does. It cannot recover buffered data after every crash or guarantee that a remote backend has durably stored it.

Should an older application move from Logrus to Zap?

Migrate when the benefits justify the compatibility work. Compare field serialization, levels, hooks, caller and stack information, and output parsing. A change in library does not by itself guarantee a particular speedup, and a parser regression may cost more than the saved CPU.

Can Zap serve as a slog backend?

The go.uber.org/zap/exp/zapslog package provides a handler adapter. Check its package version and supported Go version, then verify level mapping, groups, attributes, and caller information with your application's slog usage.

For related patterns, see Go slog and contextual logging in Go.

Get a tailored plan

Contact us

Join the community

Scan with WeChat
to join the community

Try Guance

Start online and pay only for what you use.

Get started

Choose a Guance plan

Code hosting