Why is logging an error with slog.Error and also returning it to the caller a problem?
answer
- one failure, one report
- who else will see this error?
- the caller will log it too
- handle it or return it, never both
- context belongs in the wrap, not a log line
basics
~20 sLogging and returning reports one failure twice: the function writes a log record, then whoever called it reports the same failure again. Choose one role per error - either handle it and log it, or return it and stay silent.
solid answer
~50 sAn error in Go is a value the caller is expected to act on, so a function that both logs it and returns it has done the caller's job as well as its own. The caller usually logs too, or wraps and returns to someone who logs, so a single incident lands in the log two, three or five times with slightly different wording, and nobody reading it can tell whether one thing failed or several did. The rule is one report per failure: the layer that stops propagating the error - typically `main`, a request boundary, or a worker's top-level loop - calls `slog.Error` once, and every layer below it returns. If a lower layer knows something worth having in the log, it puts that in the error text with `fmt.Errorf("reading limit file %s: %w", path, err)` rather than in a log line of its own.
code
go · 8 linesfunc readLimit(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
slog.Error("reading limit file", "path", path, "err", err)
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(data)))
}go deeper
Be ready to say the rule in one sentence: handle the error and log it, or return it and stay quiet, never both. Know that a returned error is not a lost error.
Explain what the duplicate lines cost a reader and show the replacement: wrap with fmt.Errorf and %w so the operation and identifiers survive in the error text instead of a log record.
Show where the single report site sits in the shapes you have run - main, a request boundary, a worker loop - and name the legitimate exceptions, such as best-effort work and goroutines with no caller.
Own the convention itself: decide which packages may import a logger at all, how the rule is enforced in review or CI, and what you say to a team that adds log lines because the top-level message is unreadable.
## The rule In Go a failure is an ordinary return value. A function that fails returns a non-nil `error` and the caller decides what happens next: retry, substitute a default, wrap and return, or give up and report. **Logging the error and returning it does both jobs at once**, and that is why it is called out in almost every Go code review. ## What actually happens in the log Suppose a scheduled job reads a limit from a file, the file is missing, and every layer follows the log-and-return habit: ``` ERROR reading limit file path=/etc/job/limit err="open /etc/job/limit: no such file or directory" ERROR could not load config err="open /etc/job/limit: no such file or directory" ERROR job setup failed err="open /etc/job/limit: no such file or directory" ERROR run failed err="open /etc/job/limit: no such file or directory" ``` One missing file, four records. The person paged at 3am has to reconstruct that these are the same incident, not four. Multiply that by a service handling thousands of requests and the noise is measured in storage bills as well as in confusion. Worse, the four lines say four different things, so searching for any one of the phrasings finds only part of the story. ## Why "just in case the caller forgets" is the wrong instinct The worry behind double reporting is that the caller might swallow the error. That is a real bug, but it is the caller's bug, and hiding it behind a defensive log line makes it harder to find, not easier: now the failure appears in the log even when nothing handled it, which looks like the system coped. If a caller drops errors, fix the caller. ## Keeping the context without the log line The log line is usually there because the lower layer knows something the top does not - which file, which environment variable, which record id. That information belongs in the error value: ```go return 0, fmt.Errorf("reading limit file %s: %w", path, err) ``` The `%w` verb wraps the original error so `errors.Is` and `errors.As` still match against it further up, while the text accumulates a readable chain: `run: loading config: reading limit file /etc/job/limit: open /etc/job/limit: no such file or directory`. One log record at the top now carries everything the four records carried, in causal order. Note what `%w` does **not** do: it captures no stack trace and no local variables. Anything you want in the final message you must name in the wrap text. ## Where the one log call goes The report site is the layer that stops propagating the error - the place where the error's journey ends because someone finally decides what to do about it: - a command-line or job binary: `main` receives the error from a `run() error` function, calls `slog.Error` once and exits non-zero; - a server: the handler or middleware that turns the failure into a response logs it once; - a background worker: the top of the goroutine's loop, since there is no caller above it. ## When logging without returning is right The rule is *one* report per failure, not *never log below main*. A function that genuinely **handles** an error has nothing to return, and a log line may be exactly right: - a retry that eventually succeeded - the attempts that failed are worth a record at a lower level, and the caller only sees success; - best-effort work whose failure does not change the outcome, such as a cache write or a metrics push; - a goroutine started with no one waiting on it, which has no caller to return to; - an error deliberately discarded in a deferred call, where a log line is the only trace left. In each case the function does not return the error, so there is still exactly one report. ## The smell in review Whenever you see `slog.Error(...)` immediately followed by `return err` in the same branch, one of the two lines is wrong. Either the function handles the failure - then drop the `return err` and return something sensible - or it does not - then drop the log call and wrap the error instead.
- If you delete the log line, how do you keep the detail it was carrying?Put it in the error value. `fmt.Errorf("reading limit file %s: %w", path, err)` adds the operation and the path to the message while `%w` keeps the original error matchable by `errors.Is` and `errors.As`. The single log call at the top then prints the whole chain in causal order.
- When is it correct for a function to log an error and not return it?When it truly handles the failure: a retry that later succeeded, a best-effort cache or metrics write whose failure does not change the result, or a goroutine with no caller to return to. In each case the function returns no error, so the log line is still the only report.
- A reviewer argues that logging low down helps because the top-level message is too vague. What is your answer?That is a signal the wrapping is too thin, not that a second log line is needed. Each layer should wrap with the operation and the identifiers it knows, so the final message names the whole path. If some detail genuinely cannot fit in the text, attach it to a typed error the top can inspect with `errors.As`.
It is like an incident report filed by every person it passes on the way to the manager: four copies of one incident, each worded differently, and no way to tell how many things went wrong.
saying these in an interview costs you the question
- Logging at every layer makes debugging easier
- Log it and return it in case the caller forgets
- A logged error counts as handled
- The logger will deduplicate the repeated message
- Returning without logging means the failure is lost