How to Implement Log Levels in Go
You deploy your Go service to production. The monitoring dashboard screams red. You SSH in to check the logs, but the output is a waterfall of DEBUG messages from a retry loop. The actual ERROR that caused the crash is buried three thousand lines back. You need a way to filter noise without rewriting the logging calls.
Go's standard library used to offer a bare-bones log package with no concept of severity. That changed in Go 1.21 with the introduction of slog. The slog package brings structured logging and log levels to the standard library. You can filter output by severity, attach key-value attributes, and switch between text and JSON formats without pulling in external dependencies.
The concept: filtering by severity
Log levels are a filter. They let you decide how much detail you want to see based on the environment. Think of a radio with a static knob. In development, you turn the knob all the way up. You want every crackle, every pop, every whisper of data moving through your app. In production, you turn it down. You only want the clear signals: warnings, errors, and critical failures.
Each log level maps to a numeric value. The logger compares the message level against a threshold. If the message level is greater than or equal to the threshold, the logger prints the message. Otherwise, it drops the message silently. This comparison happens before any string formatting or I/O occurs. The cost of a filtered log call is a single integer comparison.
Log levels are a filter, not a formatter. Set the threshold and let the logger drop the noise.
Minimal example with slog
Here's the simplest way to set a log level using slog. The code creates a handler with a threshold and wraps it in a logger.
package main
import (
"log/slog"
"os"
)
func main() {
// Set threshold to Info. Debug messages get dropped.
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
logger := slog.New(handler)
logger.Debug("This message disappears because Debug < Info")
logger.Info("This message prints because Info >= Info")
logger.Error("This message prints because Error > Info")
}
The HandlerOptions.Level field controls the filter. slog.LevelInfo is the default threshold. Messages with a level lower than Info are discarded. The Debug call returns immediately without printing anything. The Info and Error calls pass the check and produce output.
What happens under the hood
When you call logger.Debug, the logger checks the level before doing any work. slog defines levels as integers. slog.LevelDebug is -4. slog.LevelInfo is 0. slog.LevelWarn is 4. slog.LevelError is 8. The handler compares -4 against the threshold 0. Since -4 is less than 0, the handler returns early.
This short-circuiting matters for performance. If you have thousands of debug calls in a hot loop, the level check ensures you don't pay the cost of building the log string when the level is disabled. The slog package checks the level before formatting arguments. If the level is disabled, the arguments are never evaluated. You can pass expensive functions as arguments to debug calls without worrying about performance in production, provided the function is not called before the logger sees it.
The numeric scale is fixed for the built-in levels, but you can define custom levels with any integer value. Lower numbers mean more verbose output. Higher numbers mean more severe output.
Realistic setup: environment variables
In a real app, you usually control the level via an environment variable so you can change it without recompiling. This lets you start with DEBUG during local testing and switch to WARN in production by changing a config flag.
Here's a logger setup that reads the level from the environment.
package main
import (
"log/slog"
"os"
)
// setupLogger configures logging based on the LOG_LEVEL environment variable.
func setupLogger() *slog.Logger {
levelStr := os.Getenv("LOG_LEVEL")
if levelStr == "" {
levelStr = "INFO"
}
// ParseLevel handles "DEBUG", "INFO", "WARN", "ERROR".
level, err := slog.ParseLevel(levelStr)
if err != nil {
level = slog.LevelInfo
}
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
return slog.New(handler)
}
func main() {
logger := setupLogger()
logger.Info("Server starting", "port", 8080)
}
The slog.ParseLevel function converts strings like "DEBUG" or "info" to the corresponding slog.Level value. It is case-insensitive. If the environment variable contains an invalid string, ParseLevel returns an error. The code falls back to Info rather than crashing. Configuration errors should never take down the service.
Configuration drives logging. Read the level from the environment, never hardcode it in production.
Pitfalls and compiler errors
The old log package has no levels. If you see code using log.Println, you have to build a wrapper or switch to slog. Mixing log and slog causes confusion. The log package always prints. It ignores any level configuration.
If you try to pass a string directly to the Level field, the compiler catches the type mismatch. The Level field expects a slog.Level, which is a named integer type.
The compiler rejects Level: "INFO" with cannot use "INFO" (untyped string constant) as slog.Level value in struct literal. You must use slog.LevelInfo or parse the string with slog.ParseLevel.
Another pitfall is immutability. slog handlers are immutable. You cannot change the level of an existing logger by modifying the handler struct. If you need to change the level at runtime, you must create a new handler with the new level and swap the logger. Many applications use a global variable or a dependency injection pattern to allow swapping the logger instance.
slog handlers are immutable. Swap the handler to change the level, don't try to mutate the struct.
Custom levels
The built-in levels cover most use cases, but some domains need finer granularity. You can define custom levels by creating a constant of type slog.Level.
Here's how to add a Trace level for extremely verbose output.
package main
import (
"log/slog"
"os"
)
// LevelTrace is a custom level for extremely verbose output.
const LevelTrace slog.Level = -8
func main() {
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: LevelTrace,
})
logger := slog.New(handler)
// Use Log to attach the custom level to the record.
logger.Log(nil, LevelTrace, "Tracing the request path")
logger.Info("Normal info message")
}
The logger.Log method takes a context and a level as arguments. You use Log when the level is not one of the built-in constants. The nil context is acceptable here for a simple example, but in real code you should pass a context.Context. The convention is to pass ctx as the first parameter to functions that might log, so the logger can attach request IDs or trace IDs to the output.
Custom levels are rare. Stick to the built-in levels unless you have a specific need that Debug cannot satisfy.
Decision matrix
Use slog with HandlerOptions.Level when you are on Go 1.21+ and want standard library support for levels and structured attributes.
Use a third-party library like zap or zerolog when you need extreme performance or advanced features like sampling and dynamic level reloading without recreating the logger.
Use the old log package with a custom io.Writer when you are maintaining legacy code on Go 1.20 or earlier and cannot upgrade yet.
Use plain fmt.Println for throwaway scripts where log levels add unnecessary complexity.
Standard library wins when it does the job. Pick the tool that matches your Go version.