Golang Logging: A Comprehensive Guide for Developers

Go's log package has no levels and no structured fields. Here's when to reach for log/slog, when Zap or Zerolog are worth it, and what to log.

Golang Logging: A Comprehensive Guide for Developers

Contents

Table of Contents

Go’s built-in log package is fine for a quick script, but it has no log levels and no structured fields, which becomes a real problem the moment you need to search or filter production logs. For anything beyond a hobby project, use log/slog, part of the standard library since Go 1.21, with no external dependency required, or reach for Zap or Zerolog if you need the extra performance and features a high-throughput service benefits from. Logrus remains a reasonable choice if your team already knows it, though it’s in maintenance mode and slog covers most of the same ground natively now.

What does Go’s standard log package do, and where does it fall short?

Go’s log package is the simplest way to get output on screen:

package main
import "log"
func main() {
log.Println("This is a log message")
log.Printf("Hello, %s!", "Gopher")
log.Fatal("This is a fatal error")
}

It writes plain text lines with a timestamp and nothing else. There’s no concept of a log level (log.Fatal calls os.Exit(1) after printing, it doesn’t mark severity), no structured fields, and no easy way to attach context like a request ID. That’s enough for a CLI tool or a small script, but it breaks down fast once you need to grep a log stream for “every failed request from user 4521” instead of reading it top to bottom.

What is slog, and should you use it instead?

Go 1.21 added log/slog to the standard library, bringing structured logging without a third-party dependency:

package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("User logged in",
"username", "gopher",
"user_id", 123,
"login_attempt", 1,
)
}

This outputs a single JSON line per event:

{"time":"2026-08-31T10:30:00Z","level":"INFO","msg":"User logged in","username":"gopher","user_id":123,"login_attempt":1}

slog gives you real log levels (Debug, Info, Warn, Error), structured key-value fields, and pluggable output handlers (JSON or human-readable text), all without adding a dependency to your go.mod. Go 1.26, released in February 2026, added slog.NewMultiHandler, which lets one logger write to several handlers at once, useful when you want structured JSON going to your observability platform and a readable text stream on stdout at the same time, without wiring that up yourself.

If you’re starting a new project on a recent Go version and don’t have a specific reason to reach for a third-party library, slog is the right default. It removes the “which logging library do I pick” decision for most services.

How do you configure log levels and output format in Go?

The standard log package has no levels at all, so this is a slog question. slog ships four levels — Debug (-4), Info (0), Warn (4), and Error (8) — and a handler emits only the records at or above its configured minimum:

opts := &slog.HandlerOptions{Level: slog.LevelDebug}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))

To change the level while the process is running rather than only at startup, pass a slog.LevelVar instead of a fixed level:

var lvl slog.LevelVar // zero value is Info
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &lvl}))
lvl.Set(slog.LevelDebug) // turn on debug output without a restart

That’s worth wiring to a signal handler or an admin endpoint, so you can turn on debug logging for a few minutes during an incident and turn it back off, instead of shipping a build.

Output format is the handler’s job, not the logger’s. slog.NewJSONHandler writes one JSON object per line, which is what a log pipeline wants. slog.NewTextHandler writes key=value pairs, which is easier to read in a terminal. Swapping one for the other changes nothing about the logging calls in your code, so a common setup is text locally and JSON in production.

How do Zerolog, Zap, and Logrus compare to the standard library?

Third-party libraries still earn their place when raw throughput or specific features matter more than staying dependency-free.

FeaturelogslogZapZerologLogrus
Structured loggingNoYesYesYesYes
PerformanceGoodGoodExcellentExcellentGood
Zero-allocation optionNoNoYesYesNo
Dependency-freeYesYesNoNoNo
Log rotationNoNoVia extensionVia extensionVia extension
HooksNoNoYesYesYes
Maintenance statusStandard libraryStandard libraryActively maintainedActively maintainedMaintenance mode

Zap (Uber’s logger) and Zerolog both prioritize allocation-free logging, which matters at high request volumes where garbage collection pressure from logging itself becomes measurable. A minimal Zap setup:

package main
import "go.uber.org/zap"
func main() {
logger, err := zap.NewProduction()
if err != nil {
panic(err)
}
defer func() { _ = logger.Sync() }()
logger.Info("This is a log message", zap.String("library", "zap"))
}

Zerolog reaches the same place through a chained builder instead of typed field constructors:

package main
import (
"os"
"github.com/rs/zerolog"
)
func main() {
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
logger.Info().
Str("library", "zerolog").
Msg("This is a log message")
}

Logrus was the default choice for structured logging in Go for years and is still widely deployed, but its own maintainers have marked it feature-complete rather than actively evolved, and most of what made it attractive (structured fields, hooks) is now available in slog without the dependency.

Golang logging best practices: what to actually log

The fields matter more than the library. A log line with a message and nothing else is barely more useful than no log line at all. At minimum, capture:

  • A request ID on every line tied to one request, so you can pull the full trail of a single request across every log statement it touched.
  • Timing for anything that can be slow — a database call, an external API request — so a performance regression shows up in logs before someone has to reproduce it manually.
  • The specific error type, not a generic message. “Authentication failed” and “database connection pool exhausted” need very different responses, even when both return the same HTTP status to the caller.
  • Never sensitive data. Passwords, tokens, and API keys should never reach a log line. Sanitize before logging rather than after.

Here’s a scenario we run into often. A Go authentication service returns intermittent 503s on a small fraction of requests, with no clear pattern by time or by traffic volume. The original logging captures only that authentication failed, with no context on why:

func handleAuthentication(w http.ResponseWriter, r *http.Request) {
user, err := authenticateUser(r)
if err != nil {
log.Printf("Authentication failed: %v", err)
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
return
}
_ = user
}

Adding structured fields, specifically a request ID, the error type, and timing for the database call, turns an unreproducible mystery into a short investigation:

logger := baseLogger.With(
"request_id", requestID,
"method", r.Method,
)
start := time.Now()
user, err := authenticateUser(r, logger)
if err != nil {
logger.Error("Authentication failed",
"error", err,
"duration", time.Since(start),
)
if errors.Is(err, ErrDatabaseTimeout) {
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
} else {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
return
}

Note the errors.Is check rather than err == ErrDatabaseTimeout. If authenticateUser wraps its errors with %w, a direct comparison silently fails and every timeout gets classified as an ordinary auth failure, which is the exact mistake the extra logging is meant to catch.

With the error type and timing visible per request, the pattern becomes findable: if every 503 correlates with a database timeout, the cause sits upstream of the auth code, in something like a batch job holding connections open and exhausting the pool. None of that is visible from the original one-line log. It only becomes diagnosable once the log lines carry the context that matters.

How do you send Go logs to an observability platform?

Structured JSON output from slog, Zap, or Zerolog is already in the shape most log pipelines expect, the remaining step is getting it there. The current standard approach is OpenTelemetry, which has an official Go SDK and lets you export logs alongside traces and metrics using the same pipeline instead of a separate, one-off shipping mechanism per signal type.

Last9 is OpenTelemetry-native and correlates Go application logs with the metrics and traces from the same request, so a slow or failing request shows up with the full context attached instead of a log line you have to manually cross-reference against a separate dashboard.

Conclusion: the bottom line

Start with log/slog unless you have a concrete reason not to. It’s in the standard library, structured by default, and covers what most services need. Reach for Zap or Zerolog when allocation-free performance at high volume genuinely matters, and instrument for request IDs, timing, and specific error types rather than generic messages, since the fields you capture matter more than which library wrote them.

If you’re running enough Go services that manually correlating logs, metrics, and traces across dashboards has stopped scaling, that’s usually the point to bring in a platform built to do that correlation automatically. Last9 unifies OpenTelemetry-instrumented logs, metrics, and traces from Go services in one place, without a sampling trade-off at high cardinality.

Running a mixed-language stack? The same principles, structured fields over plain strings, real log levels, sanitizing before you log, apply almost identically in Python and Node.js, just with different libraries doing the work.

FAQ

What’s the difference between log and log/slog in Go?

The log package writes plain text lines with a timestamp and no structure. log/slog, added to the standard library in Go 1.21, adds real log levels (Debug, Info, Warn, Error), structured key-value fields, and pluggable JSON or text output, without requiring a third-party dependency.

How do you set the log level in Go?

Pass a slog.HandlerOptions with a Level when you construct the handler: slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}). To change the level while the process is running, pass a *slog.LevelVar instead of a fixed level and call Set on it, which lets you turn debug logging on during an incident without redeploying.

Which Go logging library is fastest, Zap or Zerolog?

Both are built around minimizing allocations and perform very similarly in most benchmarks, the difference between them in practice usually comes down to API style rather than raw speed. Zap uses a more traditional method-chaining API; Zerolog leans further into a fluent, zero-allocation builder pattern. Either is a reasonable choice for a high-throughput Go service; slog is usually fast enough unless you’ve measured logging itself as a bottleneck.

Do I need a third-party logging library if I’m using Go 1.21 or later?

Not by default. log/slog covers structured logging, levels, and custom output handlers without adding a dependency, and is a solid default for most new services. A third-party library like Zap or Zerolog becomes worth the dependency specifically when you need maximum allocation-free performance at high log volume, or a specific feature like built-in log rotation that slog doesn’t provide natively.

How do you add structured fields to Go logs?

With slog, pass key-value pairs directly to the logging call: logger.Info("message", "key", value). With Zap, use typed field constructors like zap.String("key", value) or zap.Duration("key", value). With Zerolog, chain field methods before .Msg(), for example .Str("key", value).Msg("message"). All three output the fields as structured JSON rather than embedding them in a formatted string.

What’s the safest way to avoid logging sensitive data in Go?

Sanitize before the log call is made, not after. Never pass raw passwords, API keys, or tokens as log fields or into a formatted message string, even at Debug level, since debug logs get left on in more places than intended. If a struct might contain sensitive fields, log specific safe fields explicitly rather than logging the whole struct, so a new sensitive field added later doesn’t silently start appearing in logs.

What did Go 1.26 add to log/slog?

Go 1.26, released in February 2026, added slog.NewMultiHandler, which creates a handler that fans out to multiple other handlers at once. This makes it straightforward to send structured logs to an observability platform and a human-readable stream to stdout from the same logger call, without writing custom fan-out logic.

How do you send Go application logs to an observability platform?

The current standard path is OpenTelemetry: instrument your Go service with the OpenTelemetry Go SDK, which can export logs alongside traces and metrics through the same pipeline. Platforms like Last9 that are OpenTelemetry-native then correlate the three signals automatically, so a log line, the trace it belongs to, and the metrics from the same time window show up connected rather than requiring manual cross-referencing.

About the authors
Prathamesh Sonpatki

Prathamesh Sonpatki

Prathamesh works as an evangelist at Last9, runs SRE stories - where SRE and DevOps folks share their stories, and maintains o11y.wiki - a glossary of all terms related to observability.

Preeti Dewani

Preeti Dewani

Technical Product Manager at Last9

Last9 logo and enter key

Start observing for free. No lock-in.

OpenTelemetry · Prometheus

Just update your config. Start seeing data on Last9 in seconds.

Datadog · New Relic · Others

We've got you covered. Bring over your dashboards & alerts in one click.

Built on Open Standards

100+ integrations. OTel native, works with your existing stack.