1. Logs in a CLI: diagnostics and the output contract
When you’re writing a console utility (for example, our training todo), it’s very tempting to print everything “just in case” via fmt.Println. That’s fine… right up until the moment when your todo list is supposed to output a table to stdout, and you suddenly shoved in "DEBUG: open file ok". Scripts break, tests break, the user gets sad.
The correct model is: the command result is a contract (usually stdout), and logs are diagnostics (usually stderr). The log package is historically tuned exactly for diagnostics: the standard logger writes to standard error (stderr) and adds date/time to each line.
You can think of it as two “pipes”:
flowchart LR
App[todo CLI application]
STDOUT["stdout (command result)"]
STDERR["stderr (errors/logs)"]
User[User/script]
App --> STDOUT --> User
App --> STDERR --> User
Once you internalize this model, many weird “why won’t my JSON output parse?” bugs will disappear on their own.
The simplest log: Print, Printf, Println
Now let’s gently get familiar with the log package without “configuring every single thing”. Often it’s enough to mark key points in the program’s life: start, parameters, success, error.
A minimal example (in our style: the todo utility starts and prints what mode it’s running in):
package main
import "log"
func main() {
log.Println("todo started") // 2009/01/23 01:23:23 todo started
log.Printf("mode=%s", "dev") // 2009/01/23 01:23:23 mode=dev
}
Key observations (not as a “list of rules”, but as common sense):
The logger adds a newline for you if you forget. It tries to print “one record per line” so logs can be read by humans and processed by tools. The second thing is that the standard logger already has date and time enabled by default, and that’s often more useful than it seems when you’re writing the code: “when exactly did this happen?” is a question you’ll start asking yourself regularly.
Where log writes and why it’s almost always stderr
If you’re writing a CLI command, by default you almost always have two types of output.
The first type is what the user “ordered”: a task list, a JSON result, the string "OK". That should be printed to stdout, because it can be redirected to a file, piped to another command, and so on.
The second type is what you (as the developer) need to understand what’s going on: "opened file", "read 15 tasks", "failed to parse date", "disk write error". Those are logs. The standard log package says it plainly: the standard logger writes to standard error.
Let’s show this with a mini snippet where the contract result goes to stdout, and diagnostics go to stderr:
package main
import (
"fmt"
"log"
)
func main() {
fmt.Println("OK") // stdout: OK
log.Println("printed OK to user") // stderr: 2009/01/23 ... printed OK to user
}
If you’ve ever hit the situation “I saved the command output to a file and it contains garbage debug lines” — congrats, you already understand why we split output like this.
2. Configuring the logger: SetOutput, SetPrefix, SetFlags
When there are more logs, they start to look like a chat between two people who forgot what they were talking about. So you can configure the logger a bit: where to write, what prefix to add, and what technical fields to print (date/time/file/line).
Let’s configure it “like in a real CLI”, where we want to:
- write to stderr (this is the default anyway, but let’s make it explicit),
- have the prefix "todo " (to distinguish our app’s logs from environment logs),
- print the standard date/time (that’s log.LstdFlags).
package main
import (
"log"
"os"
)
func main() {
log.SetOutput(os.Stderr)
log.SetPrefix("todo ")
log.SetFlags(log.LstdFlags)
log.Println("started") // todo 2009/01/23 01:23:23 started
}
Effectively, we “stuck a badge” onto every line: now it’s harder to confuse them with something else.
Log format: logger flags
In log, the header format is controlled by flags. You’ll memorize the full “zoo” over time, but it’s better to catch the idea now: flags are just switches that add meta information to the beginning of the line.
A small table (handy as a cheat sheet):
| Flag | What it adds | When it’s useful |
|---|---|---|
|
the date | when logs are written for days and you don’t want to guess “which day was that?” |
|
the time | almost always |
|
microseconds | when debugging performance, but it can be noisy |
|
|
when you need to quickly find the location in code |
|
full path + line | when the project is big and there are many files |
|
|
the default; usually fine |
Formally, the log package describes that the standard logger prints date and time and writes to stderr.
If you enable Lshortfile, you’ll get a hint “which line this came from”. That’s useful… until you enable it everywhere and end up with logs that look like “War and Peace”, except with paths.
log.New: a separate logger per component
As an app grows, there’s a natural desire to split the world into “parts”: the CLI parser, storage, business logic. Even if you still have a single module, in your head you already distinguish “where we read the file” and “where we render output for the user”.
In log, there is a Logger type that you can create manually via log.New(out, prefix, flags). This lets you make a logger for a subsystem (for example, the storage layer) and give it its own prefix.
Let’s imagine we have functions to read tasks from a file. We’ll create a separate logger storageLog for them:
package main
import (
"log"
"os"
)
func main() {
storageLog := log.New(os.Stderr, "todo storage ", log.LstdFlags)
storageLog.Println("open tasks file") // todo storage 2009/01/23 01:23:23 open tasks file
}
Why is this convenient? Because when you later see “todo storage ...” in stderr, you instantly understand: this is not the command output, it’s an internal layer. Also, you won’t have to keep appending "storage: ..." by hand — the logger does it for you.
3. Why log.Fatal is not a universal “error handling”
Now comes the moment where many beginners (and some experienced folks) say: “Oh, so I can just do log.Fatal(err) and not worry!”
Technically — you can. Practically — you almost always shouldn’t.
The point is that Fatal is not “log the error nicely”. Fatal is logging + immediate process exit.
The log documentation says it bluntly: Fatal is equivalent to Print and then calls os.Exit(1). And os.Exit terminates the program immediately, and deferred functions do not run.
That combination is the reason for half of the “weird” bugs related to Fatal.
Demonstrating the problem: defer won’t run
Let’s make a short example where we rely on defer as a “janitor”, but log.Fatal breaks everything:
package main
import (
"log"
)
func main() {
defer log.Println("cleanup: should run, but...") // won't get a chance
log.Fatal("fatal: cannot continue")
}
Logically, you expected: “the program will exit now, but defer should still run”. However, Fatal goes to os.Exit(1), and that terminates the process without running defers.
For contrast: if this were a panic, then defers execute while the stack unwinds — that’s the core mechanism of panic/defer/recover. But Fatal is not a panic. And that’s exactly why it’s so “dangerously convenient”.
How this hits resources especially hard
In our todo (and in any application) there are resources that must be closed carefully:
- files (otherwise some data won’t be written, and file descriptors won’t be released),
- buffered writers (otherwise there will be no Flush),
- mutexes/locks (if you had them),
- temporary files (if you do reliable writes via temp+rename).
And very often, cleanup/release is placed in defer. If you use log.Fatal in the middle of the call stack, you cut off this whole careful mechanism.
4. Why log.Fatal is harmful in library code
The most important idea of the lecture: a library has no right to decide that the process must die right now.
Imagine your todo grows over time (or you simply move a piece of logic into a package) and someone uses your code as a dependency:
- in another CLI utility,
- in tests,
- in a long-running service (which absolutely should not die from a single file-read error),
- in a GUI app (completely different UX rules).
If inside a “library” you used log.Fatal, you:
- Break control flow: instead of return err you do “thanks everyone, you’re dismissed”. Fatal calls os.Exit(1).
- Break resource cleanup: os.Exit does not run deferred functions.
- Break testability: a test can’t “assert the error” because the test process exited.
- Break the responsibility contract: the library should report an error, and the application should decide what to do with it.
This directly continues what we’ve already practiced earlier: “internal code returns an error; the top level decides the reaction”.
Bad example: the library kills the process
Suppose you wrote a function to read tasks and decided “well, if the file can’t be opened — that’s fatal”:
package storage
import (
"log"
"os"
)
func MustReadAll(path string) []byte {
b, err := os.ReadFile(path)
if err != nil {
log.Fatal(err) // ❌ will kill the whole process
}
return b
}
This function looks “convenient”, but in practice it makes your library toxic: it can’t be safely reused.
Good example: return error, log at the top
It’s better to return the error upward:
package storage
import (
"fmt"
"os"
)
func ReadAll(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %q: %w", path, err)
}
return b, nil
}
And now, in main (or in the CLI command handler) you decide: whether to log, what to show the user, what exit code to set. Logging is convenient exactly where you make the decision “continue or exit”.
5. How to integrate log into todo without breaking output
Let’s show a neat template: “initialize logger → run → log the error once”.
Let’s say we have a function run() error that does useful work:
package main
import (
"log"
"os"
)
func run() error {
// command logic will be here: add/list/done
return nil
}
func main() {
log.SetOutput(os.Stderr)
log.SetPrefix("todo ")
log.SetFlags(log.LstdFlags)
if err := run(); err != nil {
log.Printf("command failed: %v", err)
}
}
What matters here: run doesn’t “fatal”. It returns an error. And main decides what to do. That makes the code predictable.
Yes, you can object: “But main didn’t exit immediately!” — yes, and that’s fine. You can finish by return from main. If you need an exit code — that’s a separate topic of CLI contracts, but even there people more often do os.Exit(code) only at the very top level, understanding the consequences. Because os.Exit does not call defer.
6. log.Fatal vs panic vs return err
It’s very easy to confuse “hard stop the program” with “report an error”. So keep a simple mental model.
return err means “I’m telling the caller something went wrong; you decide what to do”.
panic means “something so unexpected happened that we can’t continue”. At the same time, defer functions in the current goroutine run while the stack unwinds (that’s the important mechanism of panic and recover).
log.Fatal means “I’ll print a message and terminate the process via os.Exit(1)”. And os.Exit terminates immediately and does not run defers.
So Fatal is not “a stronger print”. It’s a different class of action: “print + kill switch”.
7. Common mistakes when working with log
Mistake #1: logging to stdout and breaking the output contract.
A beginner often does fmt.Println("DEBUG ...") next to useful output and doesn’t notice the problem until someone tries to pipe the result into another command. With the standard log logger, output goes to stderr by default, and that’s exactly what you want for CLI diagnostics.
Mistake #2: using log.Fatal “somewhere inside” (in the middle of the call stack).
It feels convenient, but the consequences are unpleasant: Fatal calls os.Exit(1). And os.Exit terminates the process instantly and does not run deferred functions. As a result, “guaranteed file closing via defer” turns into hoping you get lucky.
Mistake #3: using log.Fatal in library code.
A library should return an error, not decide the fate of the process. Otherwise you kill reuse, tests, and the ability to “degrade gracefully”. Technically, log.Fatal is a process exit, meaning the library suddenly gains power over the application.
Mistake #4: enabling a format that’s too “noisy”, then stopping reading the logs.
If you turn on Llongfile | Lmicroseconds just “because you can”, logs become long and unreadable. The outcome is paradoxical: logging exists, but nobody uses it. Keep the format informative enough, but don’t turn every line into a serialized novel.
Mistake #5: trying to “fix” errors by logging instead of handling them.
Sometimes a developer writes log.Printf("err=%v"), err and continues as if nothing happened, even though it’s an error after which you must exit or return it upward. A log is a record of a fact, not a fix. If the error prevents you from continuing, the correct action is — return error (or handle it), and the log is secondary.
GO TO FULL VERSION