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.

Related Content