Table of Contents
- What does Go’s standard log package do, and where does it fall short?
- What is slog, and should you use it instead?
- How do you configure log levels and output format in Go?
- How do Zerolog, Zap, and Logrus compare to the standard library?
- Golang logging best practices: what to actually log
- How do you send Go logs to an observability platform?
- Conclusion: the bottom line
- FAQ
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 restartThat’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.
| Feature | log | slog | Zap | Zerolog | Logrus |
|---|---|---|---|---|---|
| Structured logging | No | Yes | Yes | Yes | Yes |
| Performance | Good | Good | Excellent | Excellent | Good |
| Zero-allocation option | No | No | Yes | Yes | No |
| Dependency-free | Yes | Yes | No | No | No |
| Log rotation | No | No | Via extension | Via extension | Via extension |
| Hooks | No | No | Yes | Yes | Yes |
| Maintenance status | Standard library | Standard library | Actively maintained | Actively maintained | Maintenance 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.

