The classic log package, shipped with Go since day one, gives you Print and Printf and not much else. The newer slog package, added in Go 1.21, brings levels, structured key/value attributes, and pluggable handlers that emit JSON or text. This chapter covers both, with most of the time spent on slog since that's what new services use.
The log package is the bare-minimum logger that Go has had forever. It writes to os.Stderr by default, prefixes each line with a timestamp, and offers three families of calls: Print*, Fatal*, and Panic*. There are no levels, no fields, and no built-in JSON.
The Print and Println variants format their arguments the way fmt.Print and fmt.Println do. Printf accepts the same verbs as fmt.Printf. Every call ends up on a single line, with the standard prefix at the front.
log.Fatal writes the message and then calls os.Exit(1). log.Panic writes the message and then panics. Both bypass deferred functions in subtle ways, which is why most production code avoids them.
log.Fatal is convenient for short scripts and for the very last error in main when there's truly nothing left to do. It's a bad fit anywhere else, because os.Exit skips every deferred function, including ones that flush buffered writers or close database connections. A log.Fatal deep inside a request handler can leak file descriptors, drop logs that haven't been flushed, and skip graceful shutdown.
Cost: log.Fatal calls os.Exit(1), which terminates the process immediately without running deferred functions. If your code relies on defer for cleanup (closing files, flushing buffers, releasing locks), those defers won't run. Reserve log.Fatal for top-level startup errors in main.
You can change the prefix and the flags. log.SetFlags accepts a bitmask of constants like log.Ldate, log.Ltime, log.Lmicroseconds, log.Llongfile, log.Lshortfile, and log.LUTC. The combined value log.LstdFlags is Ldate | Ltime. Setting Lshortfile adds the source file and line number to every line, which is handy when you can't tell which function logged a message.
The prefix appears before the timestamp, and Lshortfile adds the filename and line. That's about as much customization as the classic log package gives you for the default logger.
For multiple loggers (one per subsystem, one to a file, one to stderr), log.New constructs a *log.Logger you can configure independently.
Each logger is independent. They share the underlying io.Writer only if you point them at the same place. This is roughly the limit of what the classic log package wants to do.
For a small CLI tool or a script, log is fine. For a service that runs in production and gets paged on at 3 a.m., it has four problems that compound:
log lines. Modern log pipelines (Loki, Elasticsearch, Datadog) expect JSON.| Concern | Classic log | slog (Go 1.21+) |
|---|---|---|
| Levels | None | Debug, Info, Warn, Error, custom |
| Structured fields | No | Yes, via key/value attrs |
| Output formats | Text only | Text, JSON, custom handlers |
| Context-aware | No | Yes, InfoContext and friends |
| Dynamic level changes | Manual | slog.LevelVar |
| Redaction hooks | None | LogValuer interface |
| Performance | Acceptable | Optimized for zero allocations on the hot path |
That's the gap slog closes. The rest of this chapter is slog.
The slog package centers on three things: a Logger, a Handler, and a list of attributes per call. The convenience functions slog.Info, slog.Debug, slog.Warn, and slog.Error use the default logger, which the package initializes for you the first time you import it.
The first argument is the message. Everything after that is alternating keys and values: "order_id", 1001, "customer", "ana@example.com". The default handler is a text handler that emits key=value pairs.
The level appears on every line (INFO), which means a log pipeline can filter on it without parsing the message. Each field is a discrete key/value pair, so a search for customer=ana@example.com matches exactly that, not any line that happens to contain the substring. The structure is the whole point.
The four built-in level functions cover the levels you'll use most. Debug is for trace-level detail, Info for normal events, Warn for things that aren't errors but deserve attention, and Error for actual problems.
The Debug line is missing from the output because the default handler's minimum level is Info. That's the most common default in production, and slog lets you change it per-handler.
Notice also that the text handler quotes values that contain spaces ("Wireless Mouse", "card declined"). The quoting is automatic, and the format is parseable: every field is either key=bareword or key="quoted value".
Cost: the variadic ...any form of these calls (slog.Info(msg, "k", v, ...)) does some boxing of the values into interface{}. For hot paths, the typed attribute form (slog.Info(msg, slog.Int("k", v), ...)) avoids the boxing and is measurably faster under load.
A handler is the thing that turns a log record (level, time, message, attributes) into bytes written to some destination. The package ships two: slog.NewTextHandler and slog.NewJSONHandler. Both take an io.Writer (where the bytes go) and a *slog.HandlerOptions (how the bytes should look).
The diagram shows the pipeline. Application code calls slog.Info or one of its siblings, which goes through a slog.Logger. The logger hands a record to its Handler, which formats it and writes the bytes to the underlying io.Writer. Swap out the handler and you change the format. Swap out the writer and you change the destination. The logger and the application code don't need to know.
The simplest replacement is switching from text to JSON. JSON is what log pipelines (Loki, Elasticsearch, Datadog, Cloudwatch) want.
slog.SetDefault(logger) replaces the package-level default, so every subsequent slog.Info call in this process uses the JSON handler. That's how you make the switch service-wide: configure one logger at startup, set it as the default, and the rest of the code doesn't change.
HandlerOptions controls four things: the minimum level, whether source file and line are added, a custom ReplaceAttr function, and (for some configurations) the time format.
Level: slog.LevelDebug lowers the minimum level so debug records appear. AddSource: true attaches the source file, line, and function to every record. Both are knobs you'd flip during local development or a focused investigation.
The level constants are straightforward integers, and you can compare them like numbers:
| Constant | Value | Typical use |
|---|---|---|
slog.LevelDebug | -4 | Trace-level detail, off in production by default |
slog.LevelInfo | 0 | Normal events: order placed, customer logged in |
slog.LevelWarn | 4 | Recoverable concerns: low stock, slow query |
slog.LevelError | 8 | Real failures: payment failed, database unreachable |
The integer gaps between levels (4 apart) leave room for custom levels in between. You could define LevelNotice = 1 (slightly above Info) or LevelCritical = 12 (above Error) for your own taxonomy, and the handler will compare them correctly.
ReplaceAttr is a hook that runs for every attribute before it's emitted. It receives the group path and the attribute, and returns the attribute that should actually be written. It's the standard place to remove fields, rename them, or redact values.
Cost: the JSON handler does more work per record than the text handler because it has to quote and escape every key and value. For a service that writes millions of log lines per second, the difference is measurable. For typical services, both are fast enough that the format choice should be driven by what your log pipeline expects, not by raw throughput.
The variadic key, value, key, value form is convenient but has two weaknesses. First, the compiler can't catch a missing value (an odd-length list of arguments), and slog will emit a "!BADKEY" placeholder at runtime. Second, every value is passed as any, which means small values like int get boxed into an interface, which allocates.
The typed-attribute form fixes both. Each attribute is constructed with a typed helper like slog.Int, slog.String, slog.Float64, slog.Bool, slog.Time, slog.Duration, or slog.Any for arbitrary values.
Same output as the variadic form, but the call site is explicit about each value's type. Mistakes like slog.Int("order_id", "1001") (a string where an int is expected) are caught at compile time.
slog.Any is the fallback for types that don't have a dedicated constructor. Use it when the value is a struct, an interface, or anything outside the basic numeric/string set.
The JSON handler marshals the struct using encoding/json conventions. The text handler renders it with fmt.Sprintf("%+v", v). Either way, the value lands as one attribute on the record.
Cost: slog.Any boxes the value into an interface{}, which can allocate for value types like small structs and ints. On hot paths where the value's type is known, prefer the typed constructor (slog.Int, slog.String, slog.Float64) to avoid the boxing.
There's also slog.Group, which nests a set of attributes under a single key. This is the cleanest way to group related fields without polluting the top-level namespace.
The JSON output nests customer and payment as objects. The text handler renders the same record as customer.email=ana@example.com customer.city=Bangalore payment.method=card payment.amount=79.99, with dotted keys. The structure survives the format change.
Most services have a handful of fields they want on every log line in a given scope: a request ID, a user ID, a tenant. Repeating those in every call is noisy and easy to forget. Logger.With returns a new logger that carries a set of attributes on every record.
Both lines carry request_id and customer without the call sites having to mention them. The pattern is: at the top of a handler, build a request-scoped logger with With, then pass it down (or stash it in a context.Context, which we'll get to soon).
WithGroup is the same idea but nests subsequent attributes under a key.
Every subsequent attribute on httpLogger lands under the http group. If you also call httpLogger.With(slog.String("request_id", "req-7f3a")), the request ID also lands inside http. This is how you build a namespace per subsystem without manually prefixing every key.
The diagram shows the chain. Each With or WithGroup returns a new logger that wraps the previous one, layering attributes and groups. The original base is unchanged, which means multiple request handlers can build their own scoped loggers from the same base without interfering with each other.
Production services often need to bump the log level at runtime to investigate an issue, then bump it back without restarting. slog.LevelVar is the standard mechanism. It's a small wrapper around an atomic level value that you pass to HandlerOptions.Level instead of a constant.
HandlerOptions.Level is declared as slog.Leveler, an interface satisfied by both slog.Level (a constant) and *slog.LevelVar (a mutable holder). Passing the LevelVar by pointer means later level.Set(...) calls are visible to the handler immediately. The set is atomic and safe to call from any goroutine.
The usual pattern is to wire an HTTP endpoint like /debug/loglevel?level=debug that calls level.Set. Cloud providers expose similar mechanisms through their console. The key property is that the level can change without rebuilding the handler, which means no log records are lost and no goroutine has to reload anything.
A context.Context flows through most service code, carrying request-scoped data and cancellation signals. The slog package mirrors each level function with a context-aware variant: slog.InfoContext, slog.WarnContext, slog.ErrorContext, slog.DebugContext, and the method forms on *slog.Logger.
The output looks the same as the non-context form, and that's the point. The default handlers don't pull anything out of the context. The opportunity is that custom handlers (and middleware) can attach context-scoped attributes (a request ID, a trace ID) to every record by reading the context.
A common pattern is a tracing handler that pulls the trace ID out of the context and adds it as an attribute, so every log line emitted during a request automatically carries the trace ID. The application code doesn't need to know; it just calls slog.InfoContext with the same context it received.
The wrapper embeds the JSON handler, intercepts Handle, pulls the trace ID out of the context, adds it as an attribute, and delegates. Every log record now carries the trace ID without the application code mentioning it. This is the cleanest place to bridge slog with a tracing library.
The slog.Handler interface is small:
Enabled answers whether the handler cares about a given level (a cheap early exit). Handle formats and writes a record. WithAttrs and WithGroup return new handlers that carry extra attributes or open a group, used by Logger.With and Logger.WithGroup.
A minimal handler that just counts records by level looks like this:
The handler discards every record's content and just counts. The WithAttrs and WithGroup implementations return the same handler because there's nothing to inherit. A real handler would need to actually carry state through those methods, and that's where things get tricky: groups can nest, and WithAttrs can be called repeatedly, so a correct implementation has to track a stack of groups and a list of pending attributes, then apply them when a record arrives.
Most teams don't write handlers from scratch. The usual pattern is to wrap an existing handler (like the JSON one) and override Handle to do something extra, the same way the ContextHandler above does. That sidesteps the complexity of WithAttrs and WithGroup because the wrapped handler already implements them correctly. If you need a full custom output format, there are battle-tested implementations on pkg.go.dev (look for slog-zap, slog-zerolog, and similar) that you can study or use directly.
Writing a correct handler from scratch is harder than it looks. The WithAttrs and WithGroup semantics are subtle, and getting them wrong leads to attributes appearing in the wrong groups or being silently dropped. Prefer wrapping an existing handler.
Logging a password, an API key, or a credit card number, even by accident, is the kind of mistake that ends up in security incident reports. The slog.LogValuer interface lets a type control how it appears in logs, which means you can attach redaction to the type itself and stop worrying about call sites forgetting.
A value that implements LogValuer is asked to produce a slog.Value whenever it appears in a log attribute. The standard pattern is to return a redacted version.
The password literal hunter2 never reaches the output. The Password type's LogValue method runs instead, and the handler emits whatever it returned. The redaction lives with the type, not with the logger configuration or the call site, which means every place that logs a Password is automatically safe.
The same trick works for whole structs. A User type with a sensitive field can implement LogValue to emit a redacted version of itself.
The full card number is gone. The masked version makes the log line useful for debugging (you can still tell which card was used) without leaking the digits. Anyone who later logs a Customer value, anywhere in the codebase, gets the same treatment without having to remember.
A subtle point: LogValue is called lazily, only when the value actually needs to be rendered. If a handler's Enabled returns false for the level, the record is dropped before LogValue runs. This means an expensive LogValue (one that hits a cache or formats a large struct) doesn't pay its cost on filtered records.
Cost: because LogValue runs lazily, redaction has zero cost when the log level filters the record out. Don't avoid LogValuer for performance reasons; the design accounts for it.
Here's the configuration most production services end up with: a JSON handler at Info level, with the level wrapped in a LevelVar for runtime changes, a wrapper that pulls request-scoped fields out of the context, and a few types with LogValue methods for redaction.
Every record carries request_id and customer because the wrapper handler reads them from the context. The level is controlled by LevelVar, so an admin endpoint can flip it. The output is JSON, ready for a log pipeline. The application code calls slog.InfoContext and slog.WarnContext with the same context it received, and never has to think about the request ID, customer, or output format.
This is a common production pattern for Go services. The wrapper is a dozen lines, the configuration is small, and the application code stays simple.
10 quizzes