1. Why return errors instead of printing messages
When you’re just starting out, it’s very tempting to act “like a human”: if something went wrong — print a message and move on. The problem is that a computer is not a human: it doesn’t understand hints. If a function couldn’t do its job, it needs to explicitly report that to the calling code, so the caller can stop, ask the user to enter the data again, or choose a different path.
Errors in Go are good because they don’t break the control flow with “exception magic”; instead, they’re passed around as ordinary values. This makes the code predictable: you read top to bottom and see where failure is possible. And if a function returns an error, that’s a signal: “that’s it — you can’t use this result any further until you figure it out.”
Tools: errors.New and fmt.Errorf
Now we’ll introduce the two main tools of this lesson.
- errors.New("text") creates a simple error with a fixed message.
- fmt.Errorf("format %d", x) creates an error, but lets you substitute values into the text, making the message more specific and more useful for diagnostics.
Important: in this lecture we use fmt.Errorf without %w. That is, we don’t build “cause chains” and we don’t make a nested error part of the contract (API). We’re solving a simpler task: “create an error” or “create an error with context in the message text”.
2. errors.New: a fixed message
errors.New is the most straightforward way to say: “something bad happened”. It takes a string and returns a value of type error. It’s suitable for cases where the cause is clear and doesn’t require parameters: "division by zero", "empty input", "invalid operation".
A mini example: a division function that refuses to divide by zero.
package main
import (
"errors"
"fmt"
)
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
q, err := Divide(10, 0)
fmt.Println(q, err) // 0 division by zero
}
Two things are important to see here. First, on error we returned 0 — that’s the zero value for int. Second, we returned a non-nil error so the calling code can make a decision.
3. fmt.Errorf: an error with context
As soon as you want to see a specific value in an error, errors.New starts to lose. The message "invalid input" is like “something broke” without details: yes, thanks, I already noticed. That’s when fmt.Errorf enters the stage: it formats a string according to fmt.Printf rules and returns it as an error.
For example, we compute a percent and explicitly report that total turned out to be zero:
package main
import "fmt"
func Percent(part, total int) (int, error) {
if total == 0 {
return 0, fmt.Errorf("percent: total is 0 (part=%d)", part)
}
return part * 100 / total, nil
}
func main() {
p, err := Percent(5, 0)
fmt.Println(p, err) // 0 percent: total is 0 (part=5)
}
The message became specific: you can see the operation ("percent"), the cause ("total is 0"), and a useful detail (part=5). That’s exactly the information you’ll be missing when the program “crashes” in an unexpected place and you’re trying to understand where exactly the user typed some nonsense.
Cheat sheet: errors.New vs fmt.Errorf
So you don’t have to guess each time “which button to press”, it’s convenient to keep a simple rule: if the message is fixed — errors.New, if you need substitution in the text — fmt.Errorf.
| Situation | What to choose | Why |
|---|---|---|
| The error is always the same (“empty input”) | |
shorter, clearer, less noise |
| Need to insert a value (number/string) | |
the error message becomes specific |
| Need to insert a string and see spaces/emptiness | |
%q shows the string “as is” in quotes |
| There’s someone else’s error err, and we want to add context as text | |
added context, but without “cause chains” |
5. What to return in (T, error) on error
This part may seem boring, but it will save you hours of your life. When a function returns (T, error), we have an agreement: if err != nil, then the T value must not be considered correct. So the calling code doesn’t have to guess, it’s customary to return the zero value for T.
That’s exactly why you so often see constructs like return 0, err or return "", err or return nil, err. It’s not because Go programmers are fans of zeros. It’s because the zero value reduces the chance of accidentally continuing computations “as if everything is fine”.
A small table to remember:
| Result type T | Zero value (what we return on error) |
|---|---|
|
|
|
|
|
|
|
|
|
|
A mini example returning a string:
package main
import (
"errors"
"fmt"
)
func NonEmpty(s string) (string, error) {
if len(s) == 0 {
return "", errors.New("empty string")
}
return s, nil
}
func main() {
v, err := NonEmpty("")
fmt.Printf("v=%q err=%v\n", v, err) // v="" err=empty string
}
Here "" is not a “correct value”, but a marker: “it didn’t work, there’s no result”.
6. How to add context to someone else’s error (without %w)
A very common scenario: you call strconv.Atoi, it returns an error, but you want it to be clear at the top what exactly you were parsing. In this lecture we do it the simplest way: create a new error via fmt.Errorf and insert the original one as %v.
Why is that OK? Because fmt.Errorf without %w just builds a new text, and you get a clear message for a human. It adds context, but does not turn the internal error into part of your contract.
An example with parsing:
package main
import (
"fmt"
"strconv"
)
func ParseLimit(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse limit %q: %v", s, err)
}
if n < 1 {
return 0, fmt.Errorf("limit must be >= 1, got %d", n)
}
return n, nil
}
func main() {}
Notice %q: it will show the string in quotes. If the user enters " " (two spaces), you’ll see it, instead of staring at the screen for ten minutes thinking: “why is it empty if it’s not empty?”.
7. Example: a calculator with validation
Now we’ll put everything together in a small app that continues our course: read input, parse, compute, print. In previous topics you might have built a “calculator” directly in main. Now we’ll start writing more neatly: we’ll introduce functions that return (int, error) and create errors via errors.New/fmt.Errorf.
And yes: this is the moment when your code becomes a bit more grown-up. It’s still small, but it’s no longer a “200-line sheet in main”.
Function parseInt: convert a string to a number with a clear error
Let’s start with parsing a number. We don’t want main to know the details of strconv.Atoi. Let main just say: “give me a number, and if it didn’t work — return an error”.
package main
import (
"fmt"
"strconv"
)
func parseInt(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse int %q: %v", s, err)
}
return n, nil
}
Here we use fmt.Errorf, because it’s important for us to show the problematic input with %q and add the original error with %v.
Function compute: compute and validate the operation
Now we need a function that takes two numbers and an operation sign. If the operation is unknown — that’s an error. If it’s division by zero — that’s also an error.
package main
import (
"errors"
"fmt"
)
func compute(a, b int, op string) (int, error) {
switch op {
case "+":
return a + b, nil
case "/":
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
default:
return 0, fmt.Errorf("unknown operation %q", op)
}
}
Notice how the tools are distributed here. “Division by zero” is a fixed reason, so errors.New. “Unknown operation” depends on op, so fmt.Errorf.
Assembling main: read 3 tokens and handle errors
Let’s build a minimal program that expects input like: 10 / 2 or 7 + 5.
package main
import "fmt"
func main() {
var aStr, op, bStr string
fmt.Scan(&aStr, &op, &bStr)
a, err := parseInt(aStr)
if err != nil {
fmt.Println("error:", err)
return
}
b, err := parseInt(bStr)
if err != nil {
fmt.Println("error:", err)
return
}
res, err := compute(a, b, op)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", res)
}
If you run it with input 10 / 0, you’ll get a clear refusal. If you run it with input 10 ^ 3, you’ll get unknown operation "^". And most importantly: the program doesn’t pretend everything is fine when everything is not.
Error-flow diagram
When you write code with errors, it’s sometimes useful to imagine it as a simple flowchart:
flowchart TD
A[Read input] --> B["parseInt(a)"]
B -->|err!=nil| E[print error; return]
B --> C["parseInt(b)"]
C -->|err!=nil| E
C --> D["compute(a,b,op)"]
D -->|err!=nil| E
D --> F[print result]
This is exactly the mindset that makes Go code calm: each step either succeeds or ends the function with an error.
8. Common mistakes
Mistake #1: returning an “almost normal value” together with an error and hoping nobody uses it.
For example, returning -1 or 999 “as a marker” plus an error. In reality, someone will definitely forget to check err and will continue computing. It’s better to return the zero value (0, "", nil) — it reduces the chance that the result will look “plausible”.
Mistake #2: making errors too generic.
A message like errors.New("invalid input") without context quickly turns into “sure, the input is invalid — but which one exactly?”. If the error is tied to a specific value — use fmt.Errorf and include the problematic input via %q or %d. This is especially important in parsing.
Mistake #3: confusing when to use errors.New vs fmt.Errorf and writing fmt.Errorf everywhere “on autopilot”.
fmt.Errorf("division by zero") technically works, but creates the feeling that there should be a %d somewhere and it was simply forgotten. If formatting isn’t needed — errors.New is more readable and more honest.
Mistake #4: concatenating strings by hand instead of using fmt.Errorf.
Sometimes beginners write "bad input: " + s, and then wonder why it’s hard to add a number, quotes, and different formats. fmt.Errorf does it more neatly, and %q also helps you see “invisible” spaces.
Mistake #5: printing an error inside a function instead of returning it outward.
If parseInt starts printing fmt.Println("error") by itself, you lose control: who is responsible for output, who for formatting, what to do next. Much simpler: the function returns an error, and the decision “print / don’t print” is made by the calling code (for example, main).
GO TO FULL VERSION