1. Why you need slog
When you’re just starting to write programs, it feels like log.Println("we got here") is already the peak of engineering. And honestly: while the project is small, everything works. But as soon as the program starts doing more than one thing, and there’s more than one error, log lines turn into a “detective story without evidence”.
The problem isn’t that strings are bad. The problem is that strings are hard to search and hard to filter. You want quick answers to questions like: “which operation failed?”, “how long did it take?”, “which id?”, “how many elements were there?”, “is this an error or just a warning?”. If a log is a single line of text, you’re forced to hope that the format of that line is always the same (and it almost never is).
log/slog solves this with two ideas. The first is log levels (Debug/Info/Warn/Error) to control “noise”. The second is structure: a log is not only text, but also a set of fields (attributes) that you can add consistently and later analyze.
In this lecture we’ll focus on the foundation: what Logger and Handler are, and how levels work. We’ll talk about fields later—otherwise your brain will start doing panic: stack overflow right inside your head.
2. The slog model: Logger and Handler
When people first see slog, they often try to memorize a “magic initialization line” and then copy it from project to project. That works… until the first time you need to change something. So it’s better to understand the model.
In slog there are two key objects.
Handler is “where and how to write”. It is responsible for the format (for example, text or JSON) and the output (usually os.Stderr), and also for level-based filtering.
Logger is “what to write”. It creates records (Info, Error, …) and passes them to the Handler.
You can think of it like this:
flowchart LR
A["Your code: logger.Info(...)"] --> B["slog.Logger"]
B --> C["slog.Handler<br/>(TextHandler/JSONHandler)"]
C --> D["io.Writer<br/>(usually os.Stderr)"]
Notice how close this is to common sense: in your code you “ask to log”, and somewhere lower down it’s decided in which format and where exactly it will end up.
3. Quick start: Logger with TextHandler
When you first add slog, you need a minimal “starting point”. Ideally it’s just a few lines and requires no ritual dancing.
Here’s the simplest option: a text handler, output to stderr, and a logger on top of it.
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
logger.Info("app started")
}
Pay attention to two things. First, we explicitly use os.Stderr so we don’t mix diagnostics with the command result. Second, as the second parameter to NewTextHandler we passed nil: that means “default options”.
If you run such a program, you’ll see an entry roughly in the style of time=... level=INFO msg="app started" (the exact output depends on the handler and the version).
4. Log levels and the threshold in HandlerOptions
Beginners often get confused about levels. It seems like it’s just four ways to say “something happened”, but in practice levels are your main tool against log chaos.
The idea is simple: logs have “importance”. When everything works, you don’t want to read 10,000 lines of debug output. When something breaks, you do want more details. So levels should let you turn details on and off with one “valve”.
In slog there are standard levels: Debug, Info, Warn, Error. They’re usually read like this: Debug — details for the developer, Info — normal application lifecycle events, Warn — something suspicious but not fatal, Error — the operation failed.
Most importantly: log visibility depends on the level threshold, which is configured in HandlerOptions.
Let’s look at an example where Debug is not printed because the threshold is set to INFO.
package main
import (
"log/slog"
"os"
)
func main() {
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
logger := slog.New(slog.NewTextHandler(os.Stderr, opts))
logger.Debug("debug is hidden")
logger.Info("info is visible")
}
This is close to an ideal default “anti-noise” setup: in a normal run, for both the user and you (as the operator/developer), Info and above is enough. And when you need investigation, you raise the detail to Debug.
Now let’s enable Debug:
package main
import (
"log/slog"
"os"
)
func main() {
opts := &slog.HandlerOptions{Level: slog.LevelDebug}
logger := slog.New(slog.NewTextHandler(os.Stderr, opts))
logger.Debug("now debug is visible")
}
Notice: we didn’t change the code at the logging call sites. We only changed the handler configuration. That’s the point of levels: controlling verbosity should be centralized.
5. Handler configuration: level and formats
Why the level is set on the Handler, not on the Logger
This question sounds weird until you step on the rake: why is the level threshold set via handler options?
Because filtering is part of “where and how to write”. Today you write text to stderr and want to show only Info+. Tomorrow you write JSON to a file and want to keep everything, including Debug, because that’s a debugging environment. The day after tomorrow you write to two destinations with two different handlers (more advanced, but the idea is still logical).
If the level lives in the handler, you can reuse the same Logger call (for example, logger.Debug("...")) and just change output configuration without rewriting the application.
TextHandler and JSONHandler: one meaning, two formats
In slog there are two most popular “built-in” handlers: TextHandler and JSONHandler. And here it’s important for beginners not to fall into the trap of “JSON is always better because it’s modern”.
TextHandler is great when you read logs with your eyes: local development, learning projects, quick terminal runs. It usually provides a compact and readable format.
JSONHandler is great when logs will be read not by a human but by a system: log collector, search, filtering, aggregation. Even if you’re not using such systems yet, it’s useful to understand why this format exists.
Here’s a minimal example of JSON logs:
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
logger.Info("app started")
}
The output will be a JSON object per line (the “json lines” format). Again, the exact shape depends on the implementation, but the idea is always the same—machines have an easier time with this.
6. The default logger: slog.SetDefault
When a project is small, you can keep logger in a variable and pass it around manually. But in reality you’ll quickly see that some code calls package-level functions like slog.Info("..."), while other code uses logger.Info("..."). If these two worlds are not aligned, you’ll end up mixing formats and settings.
slog has a “default logger” mechanism: you create a logger and set it as the default. Then package-level calls like slog.Info use your configuration.
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
slog.SetDefault(logger)
slog.Info("started via default logger")
}
Treat this as a global process-level setting: you do it once at startup, and then all code that uses slog.* writes in a single style.
Developer paranoia suggests: “global is evil”. Sometimes yes, but here it’s more like centralized configuration. In the next lectures we’ll discuss where to log and how not to multiply chaos; for now it’s important to learn how to confidently assemble a “working” logger.
7. Integrating into a CLI: stdout separate, logs separate
Now let’s take a small step toward our learning CLI application (let’s call it todo). The goal: keep the command result in stdout, and diagnostics—in stderr via slog.
Imagine we have run()—a function that does useful work and returns an error. We want to log the start/finish of the command, but not mix this with result output.
The minimal “wrapper” in main could look like this:
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := run(logger); err != nil {
logger.Error("command failed")
os.Exit(1)
}
}
Here we did two things. We created a logger, and then passed it into run(logger). Yes, it’s a bit more code than “just a global logger”, but you can already feel the boundary: main assembles dependencies (including the logger), and the execution logic lives inside run.
Now let’s make run print the result to stdout (via fmt.Println) and in parallel write diagnostics to stderr (via logger.Info). This is a great example of separating streams.
package main
import (
"fmt"
"log/slog"
)
func run(logger *slog.Logger) error {
logger.Info("listing tasks")
fmt.Println("0 tasks") // 0 tasks
return nil
}
If you run the program, you’ll see “0 tasks” in stdout (that’s the result contract), and the “listing tasks” log in stderr. In the terminal they may visually mix, but for scripts, pipes, and tests these are two different worlds.
8. Dev and prod: enable Debug without rewriting code
A very common scenario: you want more details in development, and less noise in normal runs. We’re not introducing env-based configuration as a full topic yet (you’ll have it on a separate day), but technically you can demonstrate switching levels with a constant.
package main
import (
"log/slog"
"os"
)
func main() {
level := slog.LevelInfo // change to slog.LevelDebug for development
opts := &slog.HandlerOptions{Level: level}
logger := slog.New(slog.NewTextHandler(os.Stderr, opts))
logger.Debug("debug details") // in LevelInfo this won’t appear
logger.Info("start")
}
The key idea: you shouldn’t rewrite dozens of places in code to “turn on more logs”. You should change the handler configuration.
9. stdout and stderr: a CLI diagram
This idea has already come up, but now it’s especially important because slog makes the “correct” output easy by default.
flowchart TB
A["CLI command"] --> B["Business logic"]
B --> C["stdout<br/>result (table/JSON/message)"]
B --> D["slog -> Handler -> stderr<br/>diagnostics (levels, context)"]
When you start writing tests for a CLI, you’ll suddenly appreciate this discipline: stdout can be compared as the result contract, and stderr can either be ignored or analyzed separately. If you mix everything together, tests turn into a fight with noise.
10. Common mistakes when working with log/slog
Mistake #1: “I logged Debug, but nothing prints—slog is broken”.
Usually this is not a bug but configuration: the level threshold in HandlerOptions is set higher than Debug. This is normal behavior: Debug is often intentionally hidden by default so logs don’t turn into a 700-page novel.
Mistake #2: creating a new logger in every function, and then being surprised by different formats and different levels.
Technically slog.New(...) is an inexpensive operation, but organizationally it’s almost always a mistake. The logger should be assembled once (or at least centrally), otherwise you get a zoo of settings: JSON here, Text there, Debug here, Info there.
Mistake #3: writing logs to stdout “because it’s easier to see”.
This breaks the idea of “stdout as the result contract”. While you run the program manually by yourself, it seems fine. But as soon as pipes, scripts, or automated checks appear, these “convenient” logs become a problem. Better to build the habit: result—stdout, logs—stderr.
Mistake #4: mixing up the roles of Logger and Handler and trying to “set the level on the logger”.
In slog, the level filter lives in the handler. That’s not an accident but a design choice: the same logger can be connected to different handlers (formats/outputs/thresholds). If you remember this model, configuration becomes logical.
Mistake #5: choosing JSON “because it’s trendy”, and then reading it with your eyes and suffering.
JSON logs are great when they’re processed by a system. When you read logs with your eyes in a terminal, text format is often more convenient. Choosing a format is not a religion—it’s “who is reading: a human or a machine”.
GO TO FULL VERSION