JavaRush /Courses /Go SELF /The Essence of Error Handling

The Essence of Error Handling

Go SELF
Level 16, Lesson 2
Available

1. Why Go has so many if err != nil

When you first see Go code, it can feel like the language was designed specifically to train your patience: call → if err != nil → return → call → if err != nil → return… And now you are mentally ready to write a macro CHECK_ERR() and live in peace (but Go has no macros, because life should be fair).

In reality, this style is a deliberate choice: Go encourages explicit control of the execution flow when errors happen. That is, an error does not “jump” up the stack by itself; it becomes a value you work with like any other. In the official discussions about “errors as values”, this is stated directly: the goal is not to “get rid of checks”, but to make the program check errors correctly, without smearing control flow with magic.

That is where today’s idiom comes from:

if err != nil {
    return ...
}

Its meaning is as down-to-earth as it gets: “if the step failed, we cannot continue; exit now”.

2. Code rhythm: call → check → early return

Now we will make an important shift: we will stop treating if err != nil { return ... } as an “annoying obligation”, and start treating it as a rhythm in which Go code is written. It is like driving: at first you very consciously think “turn signal — mirror — maneuver”, and then it becomes automatic. Roughly the same thing happens here: step → check → either continue or exit.

To see the logic, it helps to imagine a function as a sequence of steps, where each step can “fail”. The early-return idiom makes it so that the “happy path” (happy path) reads top to bottom without extra nesting.

Here is a simple flowchart:

flowchart TD
    A[Step 1: call a function] --> B{err != nil?}
    B -- yes --> E[return ... err]
    B -- no --> C[Step 2: next call]
    C --> D{err != nil?}
    D -- yes --> E
    D -- no --> F[Continue the happy path]

That is exactly what if err != nil { return ... } does: it keeps control flow honest and readable.

3. Early return instead of an else staircase

Let’s talk about a typical alternative: you start writing error handling, but instead of early returns you build a “staircase”:

  • if err == nil { ... } else { ... }
  • inside the if there is another if err == nil { ... } else { ... }
  • and so on, until your code starts to look like a multi-story building with no elevator.

The problem with the “staircase” is not that it is “not idiomatic”. The problem is readability: you want to see the main scenario with your eyes, but you are forced to wade through levels of indentation. The Go approach is usually different: check the error, exit, and the main scenario continues at the same indentation level.

Let’s compare with a short example. Suppose we want to parse two numbers and print their sum.

Nested version

package main

import (
	"fmt"
	"strconv"
)

func printSum(a, b string) error {
	x, err := strconv.Atoi(a)
	if err == nil {
		y, err := strconv.Atoi(b)
		if err == nil {
			fmt.Println("result:", x+y) // result: ...
			return nil
		}
		return err
	}
	return err
}

It works, but it is noticeable that the “normal” logic is squeezed inside the if.

Go-style version: early returns

package main

import (
	"fmt"
	"strconv"
)

func printSum(a, b string) error {
	x, err := strconv.Atoi(a)
	if err != nil {
		return err
	}

	y, err := strconv.Atoi(b)
	if err != nil {
		return err
	}

	fmt.Println("result:", x+y) // result: ...
	return nil
}

This example is often used to illustrate what typical Go code with errors looks like. Yes, there are more lines, but the structure is clear: top to bottom you read “what we do”, and error handling is like “guards at the door” that do not let us go further if something went wrong.

4. What to write in return ... and why zero value matters

The idiom if err != nil { return ... } almost always lives inside functions that return an error to the outside. But beginners tend to run into two persistent questions here.

First: “Can I just return err?”
You can, if your function returns only error. But often the contract is (T, error), and then you must return two values.

Second: “What should I return instead of T if an error occurred?”
By basic Go practice, you return the zero value of type T. This is not magic and not a mandatory law of nature; it is a practical safeguard against accidentally using a result that is actually invalid.

Look at a small example: we parse a number from a string, but we want to forbid negative values.

package main

import (
	"fmt"
	"strconv"
)

func ParseNonNegativeInt(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, err
	}
	if n < 0 {
		return 0, fmt.Errorf("expected non-negative integer, got %d", n)
	}
	return n, nil
}

Here 0 is the zero value for int. We are effectively telling the calling code: “there is no result; do not touch it—look at err”.

5. A readability rule: error means exit

Now let’s lock in a simple rule that helps you write and read Go code.

If an error happened, usually there is nothing else to do in this function. That means the err != nil branch should either return control (return) or sharply change the flow (for example, break in a loop). In our topic we focus specifically on “returning outward”.

This habit does two things at once: the code becomes linear (less nesting), and it becomes harder to “accidentally continue” with bad data.

There is an important psychological effect: when you read a function, your brain quickly learns to recognize “guards”:

if err != nil {
    return ...
}

and almost automatically “skips past” them, understanding: this is protection, not the main logic. Notes about error handling in Go emphasize exactly this point: explicit error checks are part of the language model, and they shape a clear control flow.

6. return err and adding context without %w

A very common situation: an error came from inside (for example, from strconv.Atoi), and you want to return it outward. If you just do return 0, err, that is correct. But sometimes from the outside it will be unclear what exactly you were trying to do and on which data.

That is why we have a neat technique: add context as text via fmt.Errorf, without preserving the cause as a separate structure (we are not covering that today).

package main

import (
	"fmt"
	"strconv"
)

func ParseID(idStr string) (int, error) {
	id, err := strconv.Atoi(idStr)
	if err != nil {
		return 0, fmt.Errorf("parse id %q: %v", idStr, err)
	}
	return id, nil
}

This “operation: cause” style is regularly found in Go code. For example, in discussions of error handling (historically, before wrapping) they directly show adding context via fmt.Errorf("...: %v", err).

7. The run() error pattern and a single if err != nil in main()

Now let’s move to real practice: how to organize code so that error handling does not get smeared all over main.

A common and very convenient technique: make a function run() error, with all the logic inside it, while main() becomes a thin wrapper: call run(), check the error, print a message, and exit. This is especially good practice for today’s idiom, because inside run() you will write many early returns.

Let’s start with a skeleton:

package main

import "fmt"

func run() error {
	// the “real” logic will be here
	return nil
}

func main() {
	if err := run(); err != nil {
		fmt.Println("error:", err)
		return
	}
}

Pay attention to the form if err := run(); err != nil { ... }: we declared err right inside the if, and it exists only there. This reduces the chance of accidentally using an old err value somewhere later.

8. Steps inside run(): the TaskBox example

Let’s build a very simple learning toy: the program reads a command from stdin and executes one step. We are not building a full CLI with flags (that will come later); we will use what we already know: fmt.Scan.

We will support two commands:

  • add <title> — add a task (in memory),
  • get <id> — print a task by index.

To avoid getting into structs (they are still ahead), we will store tasks in []string. The index in the slice will be our id.

Here is a starter run() that reads a command and an argument:

package main

import "fmt"

func run() error {
	var cmd string
	var arg string

	if _, err := fmt.Scan(&cmd, &arg); err != nil {
		return err
	}

	fmt.Println("cmd:", cmd, "arg:", arg) // example: cmd: add arg: Buy_milk
	return nil
}

You can already see the rhythm here: call Scan → check → early return.

Now let’s add minimal logic with separate functions, so that it is convenient to return errors outward.

Add a task: error if the title is empty

package main

import (
	"errors"
)

func AddTask(tasks []string, title string) ([]string, error) {
	if len(title) == 0 {
		return nil, errors.New("task title is empty")
	}
	tasks = append(tasks, title)
	return tasks, nil
}

Notice: on error we return nil instead of a slice (this is the zero value for []string) and an error. This is honest: “there is no new state; the operation was not performed”.

Get a task: error if the index is out of range

package main

import "fmt"

func GetTask(tasks []string, id int) (string, error) {
	if id < 0 || id >= len(tasks) {
		return "", fmt.Errorf("task id out of range: %d", id)
	}
	return tasks[id], nil
}

Here the zero value for string is the empty string "".

9. run() as an orchestrator: check the error after every step

Now the most important part: show the “orchestrator” Go style where run() calls steps one by one and after each step does exactly what this lecture is about.

package main

import (
	"fmt"
	"strconv"
)

func run() error {
	tasks := []string{"Learn Go"} // initial data so there is something to get

	var cmd, arg string
	if _, err := fmt.Scan(&cmd, &arg); err != nil {
		return err
	}

	if cmd == "add" {
		var err error
		tasks, err = AddTask(tasks, arg)
		if err != nil {
			return err
		}
		fmt.Println("ok") // ok
		return nil
	}

	if cmd == "get" {
		id, err := strconv.Atoi(arg)
		if err != nil {
			return fmt.Errorf("parse id %q: %v", arg, err)
		}
		task, err := GetTask(tasks, id)
		if err != nil {
			return err
		}
		fmt.Println(task) // for example: Learn Go
		return nil
	}

	return fmt.Errorf("unknown command %q", cmd)
}

There are many “small” if err != nil { return ... }, but that is exactly Go’s “proper boredom”: each step either succeeded, or we honestly exit.

10. Useful micro-patterns and anti-patterns

Micro-pattern: if _, err := ...; err != nil { return ... }

Sometimes a function returns a useful value and an error, but you do not need the value. For example, you print something or perform an action “for its side effects”. In such cases it is convenient to use an underscore _ and declare err right in the if.

package main

import "fmt"

func PrintHeader() error {
	if _, err := fmt.Println("=== TaskBox ==="); err != nil {
		return err
	}
	return nil
}

This pattern does two useful things: it does not pollute the function with variables and it immediately tells the reader: “only the error matters”.

Anti-pattern: “print err and keep going”

A very typical beginner mistake: they see err, get scared, print it… and then continue executing code as if everything is fine. This is especially dangerous in functions where on error the result becomes a zero value. For example, strconv.Atoi("x") returns 0 and an error. If you ignore the error, you suddenly have a “valid” number 0, and you will continue computing something completely different from what you intended.

The idiom if err != nil { return ... } is exactly what cures this: the “correct reaction” to an error is built into the code structure. You either handled the problem (and changed the flow), or you returned outward so that someone higher up can handle it.

11. Typical mistakes

Mistake #1: check err but do not change the control flow.
People often write fmt.Println(err) inside if err != nil, and then keep working with data that is actually invalid. If the error means the step was not completed, then after checking you usually need an early exit: return err (or another explicit exit from the current scenario).

Mistake #2: mix up the condition and handle the error in the err == nil branch.
It looks funny until it happens at night on Friday right before a deadline. Keep a simple “mantra” in mind: err != nil is the problem branch, err == nil is the success branch. If you are writing an early return, the condition is almost always err != nil.

Mistake #3: use the result before checking the error.
In contracts (T, error), the value T on error is often the zero value. It looks “normal” and is therefore especially dangerous. The correct order in Go is almost ritual: call → check err → only then use T.

Mistake #4: return a “partial result” together with an error and hope the caller “figures it out somehow”.
Sometimes you want to return “what you managed to compute” along with an error. At the beginner level this usually leads to the result being used while err is forgotten. In basic Go style it is safer to return the zero value on error, so that incorrect usage is less likely.

Mistake #5: create nesting instead of early returns.
When you wrap every step in if err == nil { ... }, you get an indentation staircase and the “normal scenario” gets hidden inside. Early return is exactly what keeps the happy path “straight”, without tunnels and underground levels.

1
Task
Go SELF, level 16, lesson 2
Locked
Token Doubler
Token Doubler
1
Task
Go SELF, level 16, lesson 2
Locked
Shielded Division
Shielded Division
1
Task
Go SELF, level 16, lesson 2
Locked
Non-negative input
Non-negative input
1
Task
Go SELF, level 16, lesson 2
Locked
Maximum of the list
Maximum of the list
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION