1. Introduction
When a program behaves strangely, your hand reaches for the good old “let’s print the variables.” And that’s normal: debugging is basically a conversation with the program, except it answers with numbers, strings, and sometimes a panic. But there’s a nuance: not all prints are the same. Sometimes you need a one-off “probe” on a specific line, and sometimes you need a neat “trace” of events that lets you reconstruct the execution flow.
In this lecture, we’ll look at two tools that look similar on the surface (both output text) but solve different problems: printf debugging via fmt.Printf and logging via the log package. Most importantly, we’ll learn how to choose what’s appropriate at a given moment so you don’t drown in your own messages.
2. Printf debugging: a quick “flashlight” in the code
Printf debugging is when you temporarily add fmt.Printf/fmt.Println to test a hypothesis: “what is the variable’s value right here?”, “which branch did we enter?”, “what is the slice length before indexing?”. It’s the fastest way to get an answer, especially if you’ve already found the problematic line from a stack trace and now want to understand the lead-up.
It’s important to treat this like a medical tool: a flashlight in the throat is useful, but living with it all the time is inconvenient. In other words, print debugging is good specifically as temporary diagnostics—you add it, check, draw a conclusion, and usually remove it or replace it with a cleaner solution.
The “label + value + context” pattern
For print debugging to actually help, the message should be clear not only to “you right now,” but also to “you in 10 minutes when you’ve already forgotten what you wanted to check.” That’s why a good diagnostic Printf almost always has three parts: a label, a value, and a context. The label is what you’re printing; the context is where and in what scenario it’s happening.
Here’s a minimal example (note %T—it often saves you when “it seemed like it was an int, but it’s actually a string from input”):
package main
import "fmt"
func main() {
x := "42"
fmt.Printf("debug: x=%q type=%T\n", x, x) // debug: x="42" type=string
}
Here %q prints a string in quotes—this is handy when the value contains spaces or “invisible” characters like \n.
Where printf debugging is especially useful
Printf debugging is great at points where the program can fail “subtly”: before dereferencing a pointer, before indexing a slice, before an important if condition, before returning an error, at a function’s entry (parameters), and at its exit (result). The idea is to print not everything, but exactly what helps confirm or refute a single hypothesis.
For example, you suspect the function received unexpected input. Then you should print the input itself:
package todo
import "fmt"
func NormalizeTitle(title string) string {
fmt.Printf("debug: NormalizeTitle input=%q\n", title)
return title // no logic yet, the only important thing is that we entered
}
Yes, it’s primitive—and that’s the strength. At the “catch the weirdness” stage, you often don’t need perfect design; you need the answer to “what’s actually happening?”
3. Logging: when you need a “black box”
Logging is when you output messages not as a one-off check, but as a meaningful record of events: “started an operation,” “read data,” “created a task,” “got an error.” The key difference from printf debugging is that a log, in the good sense, is meant to be read later: by you an hour later, a teammate a week later, or by you again, but already in a test.
In Go, the basic tool for this is the log package. It’s simple but useful: by default it adds date and time, writes to stderr (this matters), and provides a consistent message style. Logging also helps when a bug is “flaky”: it shows up in one run but not in another. Logs let you compare two runs and see where the paths diverged.
Minimal log.Printf
Below is a basic example. It looks like fmt.Printf, but behaves like a log: the default format is more “service-like.”
package main
import "log"
func main() {
log.Printf("op=%s status=%s", "start", "ok") // 2026/01/16 12:34:56 op=start status=ok
}
A timestamp (date/time) is not decoration. When you have several messages in a row, time helps you understand ordering and “pauses.”
Why log.Fatal is not “just a convenient print”
It’s important not to confuse these: log.Printf prints and continues, while log.Fatal prints and terminates the program (via os.Exit(1)).
In learning examples, you’ll sometimes see log.Fatal(err) as a simple way to “crash if there’s an error.” For example, in classic Go error-handling walkthroughs you’ll encounter a construct like: if os.Open returns an error, call log.Fatal and stop.
For debugging this can sometimes be convenient, but making it a habit to “stuff log.Fatal everywhere” is dangerous: in library code it breaks error propagation, and in an application it can cut off normal handling (cleanup, closing files, returning an exit code). Keep a simple rule in mind: log.Fatal is a boundary-of-the-app tool, not a “universal if.”
4. fmt.Printf and log.Printf in practice
When beginners hear “logging,” they think: “well, it’s still just printing text.” Formally yes, but in meaning, these are two different modes of talking to a program.
Comparison by criteria
| Criterion | (printf debugging) |
(logging) |
|---|---|---|
| Typical goal | Quickly test a hypothesis | Leave an event trail |
| Lifetime | Usually temporary, while hunting a bug | Often stays in the code for a long time |
| Default output | |
|
| Out-of-the-box format | Only what you wrote | Date/time + your message |
| “User-facing output” | Often yes (in CLI/tasks) | Almost never (it’s diagnostics) |
| Risk of “breaking task/test output” | High (stdout gets polluted) | Lower (stderr is separate) |
The practical rule here is simple: if your code must print strictly defined output to the user (especially in tasks/tests), diagnostic fmt.Printf can easily ruin everything. Logs are less risky because they live in stderr.
stdout and stderr: why separate them
Many people think separating stdout/stderr is some kind of “Unix philosophy.” In practice it’s just a way not to mix up what is the program’s result and what are its internal comments.
If you print the user-visible result to stdout, any extra fmt.Printf("debug: ...") ruins the result. But if diagnostics go to stderr, the result stays clean. That’s exactly why the log package writes to stderr by default: it’s basically telling you “this is internal.”
Imagine (purely conceptually) that someone wants to process your program’s output as data. If you mixed debug into it, they’ll get a mess. If the data is in stdout and diagnostics are in stderr, everything works predictably.
5. Example: a mini task tracker and “weird input”
Let’s build a small story, very similar to real life: the program doesn’t always crash, but sometimes behaves oddly. We won’t complicate the architecture—we care about the diagnostic technique. Suppose we have a mini app that accepts a command line like title: buy milk and extracts the task title.
Buggy version and a typical panic
We write a naive parser: split by : and take the second part. If there’s no colon, we get index out of range.
package todo
import (
"strings"
)
func ParseTitle(line string) string {
parts := strings.Split(line, ":")
return strings.TrimSpace(parts[1]) // panic if ":" is missing
}
Now imagine the user entered title=buy milk (confused : and =). From the stack trace you’ll find this line. But then the question arises: what exact string got here, and how was it split? This is where the choice between fmt.Printf and log.Printf begins.
A quick check via fmt.Printf
If you’re reproducing the problem locally and need exactly one observation, print debugging is the fastest move:
package todo
import (
"fmt"
"strings"
)
func ParseTitle(line string) string {
parts := strings.Split(line, ":")
fmt.Printf("debug: line=%q parts=%v len=%d\n", line, parts, len(parts))
return strings.TrimSpace(parts[1])
}
The upside of this approach is that you literally “shine through” the spot where you think the bug is. The downside is that if this function is used in places where stdout must be clean, you’ll suddenly get debug: in the user’s response or in a test’s expected output.
More systematic: log.Printf as an event trail
If you want diagnostics not to mix with the program’s results, and you want a timestamp in the message, use log. In practice this is often more convenient by the second minute of debugging, because messages don’t “get lost” in normal output.
package todo
import (
"log"
"strings"
)
func ParseTitle(line string) string {
parts := strings.Split(line, ":")
log.Printf("parse_title: line=%q parts=%v len=%d", line, parts, len(parts))
return strings.TrimSpace(parts[1])
}
Notice the format: you don’t need to write “debug debug debug.” It’s better to write “what I’m doing” (parse_title) and the key fields. This already looks like a mini log.
Fix the bug properly
Debugging is not the goal; it’s a path to normal behavior. The proper fix here is to stop panicking on bad input and return an error. And yes, fmt.Errorf is appropriate here: it creates an error that can be printed and logged, and fmt prints an error using its Error() string.
package todo
import (
"fmt"
"strings"
)
func ParseTitle(line string) (string, error) {
parts := strings.SplitN(line, ":", 2)
if len(parts) < 2 {
return "", fmt.Errorf("invalid title format: %q", line)
}
return strings.TrimSpace(parts[1]), nil
}
Notice how much this changes the “debugging reality”: now instead of a panic we have a regular error branch that can be handled neatly.
6. What to choose and when: practical rules
The main beginner mistake is trying to pick the “one correct” tool forever. In reality, the choice depends on the stage of bug hunting and where exactly you’re working: in a library function, in main, in a test, in a quick prototype.
Printf debugging is reasonable when you’ve almost found the problem and you need to quickly understand one specific value. It’s like asking the program one question: “what’s in variable parts right now?” Convenient and fast, but you usually don’t want to leave it forever, because those prints start living their own life and pollute output.
Logging makes more sense when you want to see the execution flow: which steps were taken, with which parameters, and at which step things went wrong. It’s especially helpful when the error happens “sometimes”: without logs you’re looking at the program like a black box, and with logs it’s like a device with a flight recorder.
Another selection criterion is “who is the reader.” If the reader of the message is the user, it’s almost always fmt (and you carefully design the text). If the reader is the developer (i.e., you), it’s either a temporary fmt.Printf or log.Printf as a diagnostic trail.
7. How to write diagnostic messages
The problem with most diagnostic messages is not that they exist, but that they’re useless: x=42 without context, here without meaning, start without specifying what exactly. A good message almost always answers “what happened?” and “with what data?”
Often it’s enough to agree with yourself on a small style. For example: op=<operation> key=value key=value. Even without “structured logging,” this dramatically improves readability.
package main
import (
"log"
)
func main() {
userID := 7
taskTitle := "buy milk"
log.Printf("op=create_task user_id=%d title=%q", userID, taskTitle)
}
Such messages are easier to scan and simpler to compare across runs.
And one more self-ironic truth: the scariest log is the log you left “for five minutes,” and a month later it’s still printing in a loop of 10,000 iterations. That’s why diagnostics should be either pinpointed, or controlled (for example, via a debug variable), or explicitly temporary.
Diagnostics in tests: t.Logf instead of fmt.Println
In tests, you often want to print “what happened,” but fmt.Println can get mixed with the test runner’s output and make it harder to read. In tests, it’s more appropriate to use t.Logf, because it’s tied to a specific test and shown in a controlled way (for example, with -v).
The key point: if you added fmt.Printf in code called by a test, the test may start “making noise” and look unstable—especially if it asserts on stdout. Logging to stderr usually interferes less, but you still shouldn’t turn tests into a TV series.
8. Common mistakes
Mistake #1: mixing user output and diagnostics in stdout.
This is a classic trap: you added fmt.Printf("debug: ..."), found everything, and then forgot to remove it. As a result, the program “works,” but the output becomes unreadable, and an automated checker (or other code that reads stdout) suddenly breaks. Diagnostics are better either removed or moved to log (stderr) and made meaningful.
Mistake #2: writing messages without labels and context.
A message like “42” or “here” is almost always useless, because a minute later you won’t remember what exactly it was and why it mattered. Even a short label like parse_title: or len(parts)=... sharply increases the value of diagnostics, especially once you have more than three messages.
Mistake #3: trying to “log everything,” especially inside loops.
When you print on every loop iteration, you get not diagnostics but a noise attack on your own eyes. It’s far more effective to print either the first few iterations, or only anomalies, or aggregates (for example, final counters). Otherwise you drown in text and stop noticing the real problem.
Mistake #4: using log.Fatal as a universal error handler.
log.Fatal really does print a message and terminate the program, and in examples it can look convenient. But if you start doing this “everywhere,” you’ll lose proper error handling, and instead of cleanly returning an error you’ll get an unexpected program exit. At minimum, keep log.Fatal at the application boundary and use it deliberately.
Mistake #5: leaving diagnostics that expose unnecessary data.
Even in learning examples, it’s useful to build the habit of not printing everything (especially if it may include tokens, passwords, private data). Today we’re not talking about security, but “don’t log secrets” is a toothbrush-level habit: you need to do it every day, otherwise later it gets expensive.
GO TO FULL VERSION