Skip to content
gohacks.dev
Basics

Log Errors Once in Go and Stop Duplicating Them Everywhere

Log an error once in Go, at the layer that handles it: what duplicate logging looks like, the handle-or-return rule, exceptions, and log levels.

6 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

Look at a log from a service that has been running for a week and you will often see the same failure three or four times, a few lines apart, in slightly different words. The database returned a timeout. The repository logged it. The service logged it again with more context. The handler logged it a third time and then returned a 500. One event, three log lines, and an on-call engineer counting three incidents where there was one.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The cause is a habit worth recognising: logging an error and also returning it. This article explains why that duplicates, what the rule is instead, and where the exceptions are. It belongs to the error handling guide. Everything ran on Go 1.25.6.

What duplication looks like

Here is a three-layer call with a failure at the bottom, and every layer logs "to be helpful":

main.goGo
package main

import (
	"errors"
	"fmt"
	"log/slog"
	"os"
)

func newLogger() *slog.Logger {
	return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))
}

var log = newLogger()

func queryUser(id int) error {
	err := errors.New("connection timed out")
	log.Error("query failed", "err", err)
	return err
}

func getUser(id int) error {
	if err := queryUser(id); err != nil {
		log.Error("get user failed", "err", err, "id", id)
		return fmt.Errorf("get user %d: %w", id, err)
	}
	return nil
}

func handle(id int) {
	if err := getUser(id); err != nil {
		log.Error("request failed", "err", err)
	}
}

func main() {
	handle(7)
}
Text
level=ERROR msg="query failed" err="connection timed out"
level=ERROR msg="get user failed" err="connection timed out" id=7
level=ERROR msg="request failed" err="get user 7: connection timed out"

Three lines of output for one timeout. The first has no idea which user. The second has the ID but not the request. The third has the full chain, which is the only one anybody needs. Someone searching the logs for the user ID finds two of the three, counting the timeout twice in any dashboard that counts error lines, and an alert on "error rate" fires at three times the real rate.

The rule: handle, or return

Every error is handled at exactly one layer. To handle an error is to do one of three things:

  1. Recover from it. Use a default, try another source, retry. The failure stops there, and if it matters, it is logged there, usually at a lower level.
  2. Translate it. Turn it into a response, an exit code or a different error the caller understands. A handler maps ErrNotFound to a 404.
  3. Log it and stop. The error stops at this layer, which writes it to the log and does not return it. This is what the top of the program does.

Everything below the layer that handles an error returns it, with context, and logs nothing. The fix for the example is to delete two lines:

main.goGo
package main

import (
	"errors"
	"fmt"
	"log/slog"
	"os"
)

var log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
	ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
		if a.Key == slog.TimeKey {
			return slog.Attr{}
		}
		return a
	},
}))

func queryUser(id int) error {
	return errors.New("connection timed out")
}

func getUser(id int) error {
	if err := queryUser(id); err != nil {
		return fmt.Errorf("get user %d: %w", id, err)
	}
	return nil
}

func handle(id int) {
	if err := getUser(id); err != nil {
		log.Error("request failed", "err", err, "user_id", id)
	}
}

func main() {
	handle(7)
}
Text
level=ERROR msg="request failed" err="get user 7: connection timed out" user_id=7

One line, with the chain in err and the ID as a separate field. The layers in the middle did their job by adding context, and the handler did its job by recording the outcome. The log has exactly one event for one failure, and a filter on user_id=7 finds it.

Why the rule holds

The caller always knows more. The function that hit the error knows what failed. The function that called it knows why it was calling, and the layer above that knows which request it belongs to. By the time the error reaches the top, the context that makes the log line useful has been added. A log line written at the bottom is the least informative version of the event.

Only the top knows if it matters. A not found from a cache lookup might be the normal path (fall back to the database) or a real fault (the record should exist). The function that returns it cannot tell. If it logs at Error level, it has decided for every caller, and most of them will disagree. Returning the error and letting the handler choose keeps the decision with the code that has the information.

Counting works. Alerts, dashboards and SLOs count error log lines. One line per failure makes the numbers mean something.

Where the exceptions are

The rule is about errors that are returned. A few legitimate cases look like exceptions.

Errors you cannot return. A background goroutine, a deferred cleanup or an HTTP handler's Close call has no caller to hand an error to. Handle it where it occurs: log it, and note why it is not returned. A goroutine's top-level function is a handler in the sense of the rule (returning errors from goroutines covers the alternatives).

Errors you recover from. A retry loop that swallows five failures and then succeeds has handled them. Log each attempt at Debug or Warn with the attempt number, so the retries are visible when someone looks and invisible in the error count. If the loop gives up, return the last error with context and let the caller log that once (retry with backoff).

Audit events. A line that says "payment declined for order 8812" is a business event, not an error report. Log it as one, with its own level and message, whether or not an error is also returned. The test is whether the line describes what the user did or what the code failed to do.

Levels follow consequence

A line's level should say what the reader must do about it. A rough scale that works in practice:

  • Debug: details for a developer; the retry attempts, the cache misses.
  • Info: normal events worth recording, including expected failures such as a 404 for an unknown path.
  • Warn: something unusual that was handled; a fallback was used, a retry succeeded.
  • Error: something failed and was not recovered; someone may need to look.

The common mistake is logging every returned error at Error, including the expected ones. A service that returns 404 for a missing resource and logs it at Error fills its alerts with traffic from crawlers. Translate first, then choose the level from the result: 5xx is an error, 4xx is usually info.

Put the context where the log is

The fields that help a reader belong on the log call, not in the error string. The handler has the request ID, the user, the route and the duration; the deep function has none of them. Passing them down just so they can be formatted into an error would clutter every signature. slog supports this directly: build a logger with the request-scoped attributes once (log.With("request_id", id)) and use it at the point of handling. The error carries the chain of what happened, and the logger carries who it happened to (adding context to errors draws the line between them).

For the logging API itself, the log/slog documentation covers handlers, levels and attributes, and the Go blog's Structured Logging with slog introduces the design.

Also inDevOps
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.