AlgoMaster Logo

log & slog (Structured Logging)

High Priority24 min readUpdated June 6, 2026

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 Classic log Package

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.

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.

Why the Classic log Package Falls Short

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:

  • No levels. Every line is at the same priority. You can't filter "show me only warnings and errors" without parsing the message text.
  • No structured fields. A log line is one human-readable string. Searching for "all orders for customer 1001" means scraping with regular expressions across megabytes of text.
  • One output format. Everything looks like the classic log lines. Modern log pipelines (Loki, Elasticsearch, Datadog) expect JSON.
  • No handler abstraction. Adding a hook (sample logs at 1%, redact passwords, write to two destinations) requires wrapping the logger by hand.
ConcernClassic logslog (Go 1.21+)
LevelsNoneDebug, Info, Warn, Error, custom
Structured fieldsNoYes, via key/value attrs
Output formatsText onlyText, JSON, custom handlers
Context-awareNoYes, InfoContext and friends
Dynamic level changesManualslog.LevelVar
Redaction hooksNoneLogValuer interface
PerformanceAcceptableOptimized for zero allocations on the hot path

That's the gap slog closes. The rest of this chapter is slog.

slog Basics

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".

Handlers: Text vs JSON vs Custom

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:

ConstantValueTypical use
slog.LevelDebug-4Trace-level detail, off in production by default
slog.LevelInfo0Normal events: order placed, customer logged in
slog.LevelWarn4Recoverable concerns: low stock, slow query
slog.LevelError8Real 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.

Typed Attributes

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.

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.

With and WithGroup: Bound Loggers

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.

Dynamic Level Changes with LevelVar

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.

Logging with Context

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.

Implementing a Custom Handler

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.

Redacting Sensitive Fields with LogValuer

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.

Putting It Together: A Realistic Setup

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.

Quiz

log/slog Quiz

10 quizzes