Logging in Go
Go is a programming language that is often used in backend development. Backend services sometimes accept the path to a log file as a configuration parameter. In the Unix world this approach is not considered good design, because it ignores the power of the standard stdout and stderr streams available to every Unix program. Standard streams are configured by the runtime environment, and an application should know as little about that environment as possible, relying on the conventional contracts provided by it. The environment can redirect a stream to a file, a terminal, or any other logging system. You can read more about this in Logs Are Streams, Not Files by ADAM WIGGINS and Logs from The Twelve-Factor App.
My backend services usually run on Debian, so the services are managed by systemd and controlled with systemctl. The systemd configuration files (typically located in /etc/systemd/system) let you specify paths for log files:
# /etc/systemd/system/my_cool_service.service
[Unit]
Description=My Cool Service
After=network.target
[Service]
User=cool_services
Group=cool_services
ExecStart=/var/www/my_cool_service /var/www/my_cool_service/config.ini
Restart=always
# log files (append:path appends to the end of the file)
StandardOutput=append:/var/log/myapp/myapp.log
StandardError=append:/var/log/myapp/myapp.err
[Install]
WantedBy=multi-user.target
By default systemd forwards stdout and stderr to systemd-journald. Logs can be viewed with journalctl:
# specify the service name
journalctl -u my_cool_service.service
# multiple services can be listed; filtering is supported
journalctl -u nginx.service -u php-fpm.service --since 10:00 --until 16:00
systemd-journald writes logs in a special binary format and accepts write requests via a dedicated protocol, implemented in a special library that provides sd_journal_send. Physically, in the systemd world the logs produced by a Go application are therefore stored in journald binary log files by default.
Let’s now look at the options a Go application has to send data to stdout and stderr.
The fmt package provides formatting and I/O functions. For instance, Printf writes to stdout:
import "fmt"
// Print* writes to stdout
fmt.Printf("user=%s age=%d\n", user, age)
The Fprintf function explicitly specifies which stream to use, for example stderr:
import (
"fmt"
"os"
)
fmt.Fprintf(os.Stderr, "bad status=%d\n", status)
The log package exposes the Logger type, whose default instance is available via log.Default and its aliases. Logger writes to stderr by default and offers a few niceties:
- adds a date-time prefix to every line (and can be extended with a custom prefix);
- adds a trailing newline when the original message is missing one;
- is safe for concurrent use (multiple goroutines can log without interleaving lines).
package main
import (
"fmt"
"log"
)
func main() {
// a trailing \n is required to move to the next line, output goes to stdout
fmt.Print("Hello, world!\n")
// default Logger, \n is not required, output goes to stderr
log.Default().Print("Hello, world!")
// alias for the previous example
log.Print("Hello, world!")
}
Logger methods behave “in the manner of” their fmt counterparts, such as log.Printf. A Logger can be instantiated explicitly so that it writes to a buffer instead of a standard stream:
package main
import (
"bytes"
"fmt"
"log"
)
func main() {
var (
buf bytes.Buffer
// specify the buffer, the constant prefix, and the computed prefix
logger = log.New(&buf, "Logger: ", log.Ldate|log.Ltime|log.Lshortfile)
)
// prints:
// Logger: 2026/02/10 12:06:19 main.go:18: Hello, world!
// Logger: 2026/02/10 12:06:19 main.go:19: Hello, world!
logger.Print("Hello, world!")
logger.Print("Hello, world!")
fmt.Print(&buf)
}
You can override the output stream, even for the default Logger:
package main
import (
"log"
"os"
)
func main() {
// writes to stderr
log.Print("Hello, world!")
log.SetOutput(os.Stdout)
// now writes to stdout
log.Print("Hello, world!")
}
The log/slog package targets structured logging, lets you group logs by severity, and attaches key-value attributes to every record. Each slog logger is equipped with a Handler that defines how attributes are processed. By default the slog logger writes to stderr regardless of severity.
package main
import (
"log"
"log/slog"
"os"
)
func main() {
// prints to stderr: 2026/02/10 12:57:20 INFO Hello, world!
slog.Info("Hello, world!")
// prints to stderr: 2026/02/10 12:57:20 ERROR Hello, world! user=Yury
slog.Error("Hello, world!", "user", os.Getenv("USER"))
// prints to stdout: {"time":"2026-02-10T12:57:20.425778+03:00","level":"INFO","msg":"Hello, world!","user":"Yury"}
jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
jsonLogger.Info("Hello, world!", "user", os.Getenv("USER"))
// prints to stdout: time=2026-02-10T12:57:20.425+03:00 level=INFO msg="Hello, world!" user=Yury
textLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
textLogger.Info("Hello, world!", "user", os.Getenv("USER"))
// replace the standard logger with the custom one
slog.SetDefault(textLogger)
// prints to stdout: time=2026-02-10T12:57:20.425+03:00 level=INFO msg="Hello, world!"
slog.Info("Hello, world!")
// log.Logger is also replaced and prints the same line to stdout
log.Print("Hello, world!")
}
In addition, slog allows you to build custom handlers and configure logging verbosity levels. A detailed overview can be found in Ayooluwa Isaiah’s article Logging in Go with Slog: The Ultimate Guide and in the Russian translation Structured Logging in Go with Slog published by the Slurm company on Habr.
Essentially, slog provides a unified interface for Go applications so that you can swap logging backends without touching the logging calls themselves. The article mentioned above demonstrates how to use zap and slog-zerolog as backends.