1. Why you shouldn’t log in every function
If you’ve ever debugged a program by the method “let’s put Println everywhere”, you already know the feeling: at first it seems like you’re a genius of observability, and 10 minutes later the console turns into a text soup where everything important drowns. Logs work the same way. If you log “everywhere”, logs stop being a navigation system and turn into noise — like if a GPS told you every second: “You are still driving.”
The main problem isn’t that there are a lot of logs. The problem is that they start to duplicate each other and make it hard to understand where the “decision point” is. In Go, this is especially noticeable because errors are typically returned upward through the call stack, adding context via wrapping. If you also log at every level, you’ll get 3–7 similar messages describing the same problem in different words — and that doesn’t help; it gets in the way.
For logging to be useful, we need discipline: a log is a side effect, and it needs an owner. In a normal application, the “owner of logs” sits at the boundary, where we decide what to do with an error: show it to the user, return an HTTP response, terminate the process, retry, and so on.
2. The application boundary: what it is and why we usually log there
The term “boundary” sounds grand, as if we’re defending a state from a bug invasion. In practice it’s simpler: a boundary is the place where your code meets the outside world. For a CLI, that’s main() and command handlers that read arguments and print a result. For HTTP, it’s a handler that reads a request and writes a response. For file import, it’s the function that opens the file and writes a report. For a library package, it’s a public API function called by someone else’s code.
Why is the boundary so important? Because only at the boundary is it clear “what we do next”. Inside business logic (say, AddTask), we usually don’t want to decide whether to print something to the user, whether to log an error as Error, or whether to treat it as an “expected” situation. An internal function should honestly say: “I couldn’t do it; here’s the error” — and return an error. Then the boundary decides what that means for the user and for the logs.
It helps to keep a simple “layer” scheme in mind (even if your project is still small):
flowchart TD
A[CLI: main/command] --> B[app layer: use cases]
B --> C[storage/adapter]
C --> D[OS/FS/Network]
A:::border
D:::outside
classDef border fill:#f2f8ff,stroke:#1c6dd0,stroke-width:2px;
classDef outside fill:#fff7f2,stroke:#d0671c,stroke-width:2px;
The boundary here is on the left (CLI) and on the right (interacting with the OS/network as the “outside world”). But from the point of view of logging, we’re primarily interested in the left boundary: it should decide how much and how to write to the logs.
3. CLI as a boundary: one final log per error
Now we’ll continue developing our training app — a simple todo manager. Let’s say we have an add command that adds a task, and a list command that prints the list. The user expects stdout to be script-friendly (table/JSON), and stderr to be for errors and diagnostics.
The healthiest CLI structure in Go looks like this: main() creates dependencies, calls run(), and then decides what to do with the error. run() returns an error rather than calling log.Fatal inside — otherwise you start “killing the process” deep in the code and lose control (and you also risk skipping defer if it mattered).
Minimal skeleton:
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := run(logger, os.Args[1:]); err != nil {
logger.Error("command failed", slog.Any("err", err))
os.Exit(1)
}
}
Notice the meaning: the error is logged once — where we made the decision “this is fatal for the command” (exit code 1). That is the boundary.
Now an example of how easy it is to “break” the situation if you log inside:
package main
import (
"log/slog"
)
func doSomething(logger *slog.Logger) error {
logger.Error("failed to do something") // bad: logging here...
return errSomething // ...and also returning the error
}
If main() also logs err, you’ll get a duplicate. The user will see two Error entries for one problem. And if storage logs too — that’s three.
For a CLI, it’s useful to keep this rule in mind: internal functions return an error; the boundary decides whether to log.
4. Add context to the error, not to the log
A typical reason a beginner starts logging in every function is fear of losing context. “If I just return err, nobody up top will understand where it happened!” That’s a normal fear, but it’s solved not with logs, but with the Go convention of wrapping errors and adding context via fmt.Errorf("...: %w").
That is, inside layers we don’t log; we do this instead:
package storage
import (
"fmt"
"os"
)
func loadFile(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read tasks file %q: %w", path, err)
}
return b, nil
}
The idea “an error rises upward with added context” is one of Go’s core patterns, and it’s explicitly supported by the standard library: you wrap the error, preserving the cause, while adding your own “frame of meaning”.
The key effect for logging: inside you added context, outside (at the boundary) you logged the final error once. You get clarity and no duplicates.
5. Where to log in a CLI: start, finish, and duration
Now let’s imagine we want to see in the logs how long the list command takes (for example, reading a file can be slow). That’s real diagnostics, not “talking for the sake of talking”. And it’s appropriate to log it at the boundary: the command started, the command finished, we have a duration.
Example of “careful” command execution logging:
package main
import (
"log/slog"
"time"
)
func run(logger *slog.Logger, args []string) error {
start := time.Now()
err := runList(args) // assume inside we return error but do not log
dur := time.Since(start)
if err != nil {
logger.Error("list failed", slog.String("operation", "list"), slog.Duration("duration", dur), slog.Any("err", err))
return err
}
logger.Info("list ok", slog.String("operation", "list"), slog.Duration("duration", dur))
return nil
}
Yes, logging happens inside run(), but run() in our app is part of the boundary (CLI orchestration). It’s where we manage the command scenario, not “the internal domain”.
But if runList starts writing logger.Info("opened file"), logger.Info("parsed JSON"), logger.Info("sorted tasks"), you’ll quickly end up with a situation where the “list” command produces 20 log lines even in the normal case. As a result, when a real error happens, it will be harder to notice.
6. HTTP as a boundary: the handler logs, not the domain
Although our main app right now is a CLI, it’s important to get used to one idea in advance: for HTTP the rule is exactly the same, except the “user” is on the network. The boundary is your handler: it reads the request, calls the logic, writes the response.
In the classic article about error handling in Go web apps, the idea is formulated in a very “Go-like” way: a handler can return an “error for the developer” and a “message for the user”, and then the outer layer decides what to log and what to send to the client.
Mini illustration (simplified, without diving into HTTP API design):
package httpapi
import (
"log/slog"
"net/http"
)
func handler(logger *slog.Logger, app App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := app.Do(r.Context()); err != nil {
logger.Error("request failed", slog.String("operation", "do"), slog.Any("err", err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
Here you can see the rule again: app.Do(...) does not log. It returns an error. And the boundary (handler) decides: we log, we respond with 500, we don’t expose details to the client.
One more important consequence: not every HTTP error should be an “Error” in the logs. For example, the user sent an invalid parameter. That’s 400, but from the server’s point of view it can be a normal situation. Often Info or Warn is enough (or even no logs at all if it’s a frequent noisy case). Again, the boundary makes the decision, because only it knows what counts as “normal” for your product.
7. A no-duplicates policy: one problem — one Error log
Now let’s bring everything together into one clear idea. Duplicates appear when:
- an internal function logged the problem;
- returned the error;
- the upper layer logged it again.
To prevent that, it helps to choose a simple policy for the project:
Policy: an Error log is written exactly where we “made the decision about the error.”
For a CLI, that’s usually main()/run() (we set an exit code).
For HTTP, that’s handler/middleware (we chose the status code and response body).
And what should internal layers do? Internal layers do two things: return an error and add context via wrapping.
A comparison of “how it would be bad” and “how it’s better” can be shown with the same example.
Bad version (duplicates):
package storage
import (
"log/slog"
"os"
)
func Load(logger *slog.Logger, path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
logger.Error("read failed") // duplicate
return nil, err
}
return b, nil
}
Good version (context via the error):
package storage
import (
"fmt"
"os"
)
func Load(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load %q: %w", path, err)
}
return b, nil
}
And then at the boundary:
package main
import (
"log/slog"
)
func run(logger *slog.Logger) error {
_, err := storage.Load("tasks.json")
if err != nil {
logger.Error("command failed", slog.Any("err", err))
return err
}
return nil
}
As a result, you have one clear Error log, but inside it you already have the whole chain of context: “command failed: load "tasks.json": open ...: no such file”.
Mini table: what to log and where
To avoid keeping everything in your head, it’s useful to capture a simple agreement. You can literally put it in the project README.
| Event | Where it happens | What we do | Level |
|---|---|---|---|
| Command started (add/list) | CLI boundary (run) | “start” log with operation | Info (moderate) |
| Command completed successfully | CLI boundary | “ok” log + duration | Info |
| Invalid arguments | CLI boundary | print usage to stderr, optional log | often without Error |
| Failed to read file/DB | boundary decides | return error upward; at the boundary: one Error | Error |
| “Expected” problem (not found, empty) | app/storage | return a domain error/result | often without Error |
| HTTP request ended with 500 | HTTP boundary | one Error with operation, duration, err | Error |
| HTTP request ended with 400 | HTTP boundary | usually without Error; can be Info/Warn | Info/Warn |
Important: the table doesn’t say “what to write in the message.” It fixes the place of responsibility. That’s the key to keeping logs from turning into an echo chamber.
8. When logging inside layers is still acceptable
The phrase “log only at the boundary” is a great default rule, but it’s not a law of physics. Sometimes a log inside a layer is justified. The problem is that a beginner usually starts with “sometimes” and ends with “always” — and now we’re back in the world of text soup.
Logging inside a layer is most often justified when an important event happens that isn’t reflected in an error, and it’s genuinely useful for diagnostics. For example, you enabled a cache and want to know whether it was a hit or a miss. Or you do retries and want to know there was a second attempt. Or you intentionally “swallow” an error (rare, but it happens) and want to leave a trace that it occurred.
But even in these cases you should keep discipline: internal logs should almost always be Debug level (or sometimes Info), because if you consider an event serious enough to be an Error, it probably should be an error that needs to be returned upward.
Log level configuration helps here: in local development you can enable Debug, and in normal runs keep Info. Then the internal “seams” of the system are visible only when you’re truly debugging, not all the time.
9. Common mistakes
Mistake #1: logging in every function “just in case”.
Usually it starts with good intentions: “I’m afraid of losing context.” But as a result you lose the main thing — the ability to quickly see where exactly the error became fatal for a command or request. Add context by wrapping errors, and keep the Error log at the boundary where the decision is made.
Mistake #2: duplicating the same error at multiple levels.
A very common scenario: storage logged “cannot open file”, app logged “load failed”, main logged “command failed”. You end up with three Error entries about one thing. The rule “one problem — one Error log” almost completely fixes this: internal layers return the error; the boundary logs it.
Mistake #3: trying to “explain to the user” via logs.
Logs are for developers. Users need short and clear messages in stderr (CLI) or in the HTTP response (HTTP). If you mix these worlds, you’ll either leak internal details or add “noise” to user output that breaks scripts and tests. And yes, if you’ve ever printed JSON to stdout and then added a log line like “processing...”, you know how painful that is.
Mistake #4: “fixing” missing context with extra logger.Error(...) instead of wrapping.
If at the top level it’s unclear what exactly went wrong, that’s not a reason to insert a log in the middle. It’s a reason to add context to the returned error (fmt.Errorf("...: %w", err)). This approach scales for both CLI and HTTP and doesn’t turn the system into a duplicate generator.
Mistake #5: logging all 4xx as Error in HTTP.
Invalid client input is often not a “system failure” but normal life. If you write every user mistake as an Error, your real outages (500) will drown in a stream of “client sent nonsense”. Separate the meaning: where it’s a client error and where it’s a server error, and choose the level at the boundary.
GO TO FULL VERSION