Anatomy of a log/slog logger

April 1, 2026

Unlike the older log package, which provides a single *log.Logger type as its primary interface, log/slog has a two-tiered architecture. This is roughly the same architecture used by the database/sql package: One interface implements a handler (or “driver” for database/sql), and another interface is consumed. The package itself provides the intermediate translation.

This is essentially a localized example of ports-and-adaptors or hexagonal architecture.

Here’s how the GoDoc for the package explains it:

Overview

Package slog provides structured logging, in which log records include a message, a severity level, and various other attributes expressed as key-value pairs.

It defines a type, Logger, which provides several methods (such as Logger.Info and Logger.Error) for reporting events of interest.

Each Logger is associated with a Handler. A Logger output method creates a Record from the method arguments and passes it to the Handler, which decides how to handle it. There is a default Logger accessible through top-level functions (such as Info and Error) that call the corresponding Logger methods.

As a consumer of the package (the application doing things that should be logged), you’ll primarily concern yourself with the slog.Logger interface.

If, on the other hand, you want to write your logs in some novel way (translated to Pig Latin?), or to a specialized logging backend (your custom columnar database?), you’d likely be writing a custom implementation of the slog.Handler interface.

As a log/slog consumer, generally your only interaction with a handler is with instantiation:

logger := slog.New(MyCustomHandler())

Pretty simple… unless you are actually writing a log handler. And we’ll get there in this series. Eventually. 😉


Share this

Direct to your inbox, daily. I respect your privacy .

Unsure? Browse the archive .

Related Content


When not to use context values

Storing values in a context is quite flexible, but comes without strict type safety, and obscures the API. So what can we do about this? First, for type safety, we’re limited to runtime assertions: userID := ctx.Value(userIDKey).(string) But this can panic if we ever get an unexpected value (or even nil). So to make it safer, we can use the two-variable assignment form, which yields a bool indicating success: userID, ok := ctx.


Values

TIL Logger.LogAttrs is a thing! But what is that thing?? Yesterday I mentioned that using an slog.Attr can be marginally more efficient than using naked key/value pairs in a log call. While true, that glosses over what is likely to be a much more impactful performance consideration in certain applications… Attrs and Values … The value part of an Attr is a type called Value. Like an [any], a Value can hold any Go value, but it can represent typical values, including all numbers and strings, without an allocation.


Attrs

We’ve been talking about key/value pairs. The log/slog package has a name for these: Attr (short for “attribute”). And there’s more than one way to build an attribute: Attrs and Values An Attr is a key-value pair. The Logger output methods accept Attrs as well as alternating keys and values. The statement slog.Info("hello", slog.Int("count", 3)) behaves the same as slog.Info("hello", "count", 3) There are convenience constructors for Attr such as Int, String, and Bool for common types, as well as the function Any for constructing Attrs of any type.

Get daily content like this in your inbox!

Subscribe