JavaRush /Courses /Go SELF /panic vs error — where panic is acceptable and where it i...

panic vs error — where panic is acceptable and where it is harmful

Go SELF
Level 18, Lesson 2
Available

1. Two kinds of problems: “expected” and “impossible”

If we simplify it a lot, Go gives you two different ways to say “something went wrong”, because not all “wrong” is the same. Sometimes the problem is expected: the user typed nonsense, there is no data, you cannot divide by zero, a file didn’t open. And sometimes the problem means your code broke its own promises and continuing is dangerous.

An expected problem is part of a program’s normal life. It’s not shameful, it’s not a “failure”, it’s just a branch of the scenario. In Go, that branch is almost always expressed as (T, error) or simply error. It didn’t work — you return an error, and the caller decides what to do.

An impossible problem is when the program ends up in a state that according to the author’s logic should not have happened at all. In Go, that usually leads to panic: the normal execution flow stops, defer starts running, the stack unwinds upward, and if nobody catches the panic at a boundary, the program crashes.

Here’s a useful analogy (just one, honestly): error is “controlled bad weather”, you brought an umbrella; panic is “the ceiling in your apartment suddenly decided to become the floor”. You can, of course, keep drinking tea, but usually you want to first understand why you are now sitting on the chandelier.

2. error as part of a function contract

In Go, errors are values. That means “an error” is not an exception from reality, but one of the expected outcomes of a function. A typical contract looks like this: “I will try to do X; if it works — I return the result; if it doesn’t — I return an error”.

That’s where the famous style if err != nil { return ... } comes from: it makes the error branch visible right in the code, without magic.

Let’s start building a small console application that we can make more complex in short pieces of code. Let it be a “mini command calculator”: read a command and two numbers, perform the operation. This is a great place to show that division by zero is an expected problem (the user could enter 0), so it’s an error, not a panic.

First, let’s introduce error constants (sentinel errors). We already know that errors can be compared using errors.Is when they are wrapped, and that makes for a convenient contract.

package main

import "errors"

var (
	ErrUnknownCommand = errors.New("unknown command")
	ErrDivByZero      = errors.New("division by zero")
)

Now let’s write the compute function. Notice: we are not “trying to guess” the input, we explicitly check conditions and return an error. It looks boring… but boring code is often the most reliable (and the cheapest to maintain — accounting applauds standing up).

package main

import "fmt"

func compute(cmd string, a, b int) (int, error) {
	switch cmd {
	case "add":
		return a + b, nil
	case "div":
		if b == 0 {
			return 0, ErrDivByZero
		}
		return a / b, nil
	default:
		return 0, fmt.Errorf("%w: %q", ErrUnknownCommand, cmd)
	}
}

Notice an important point: an unknown command is also an expected problem. The user can type "mul", "hello" or "pls-work". And that is not a “program accident”, it’s just invalid input. Therefore — error.

If deep down you want to “scold louder”, scold with the error message. But not with panic.

3. panic as a signal that “something is broken in the program”

The word panic sounds like a cat is about to crawl out of the monitor and knock over your build. In practice, panic is a built-in Go mechanism that stops the normal execution flow, starts stack unwinding, and guarantees that defer runs on the way up. This matters: even during a crash you still have a chance to release a resource, finish a log entry, or release a lock.

The key rule that saves nerves: panic is not a way to control business logic and not a replacement for error. If a situation is predictable and can be part of a function contract — return an error. If the situation indicates a programmer mistake, a broken invariant, or “we can’t live in this state” — then panic may be justified.

I’ll show a bad example that beginners sometimes do: “if division by zero — panic”. Why is it bad? Because division by zero can be normal user input.

package main

func badDivide(a, b int) int {
	if b == 0 {
		panic("division by zero") // bad: this is an expected scenario
	}
	return a / b
}

And now an example where panic is more appropriate: “we validated the input, but still got an impossible state”. For example, we have a function that accepts only two commands ("add", "div"), because a strict validation already happened earlier in the code. And if something third gets here — it means someone changed the code so the invariant is no longer true. That looks like a bug.

package main

func computeValidated(cmd string, a, b int) int {
	switch cmd {
	case "add":
		return a + b
	case "div":
		return a / b // here we assume b != 0 (validated earlier)
	default:
		panic("unreachable: command was validated earlier")
	}
}

Notice: this is not “the user is at fault”, this is “our code is broken”. In such places, panic is sometimes more honest than silently returning a weird error that should never happen by design.

4. Typical runtime panics: slice, nil map, division by zero

When people first see panic, they often think panic only happens if you called panic(...) yourself. But Go can panic on its own too: so-called runtime panics — when you violate execution rules (for example, indexing beyond slice bounds).

Let’s look at three of the most common “rakes” at the current course level — you already know enough to hit them at full speed.

Indexing beyond slice bounds

It usually starts innocently: “there is definitely a third element there”. And then the input data turns out to be shorter.

package main

import "fmt"

func main() {
	s := []int{10, 20}
	fmt.Println(s[2]) // panic: index out of range
}

The right approach here is almost never recover and not “maybe we’ll get lucky”, but a normal length check and returning an error if the length does not match the contract.

package main

import (
	"errors"
	"fmt"
)

var ErrNeedAtLeast3 = errors.New("need at least 3 numbers")

func third(s []int) (int, error) {
	if len(s) < 3 {
		return 0, ErrNeedAtLeast3
	}
	return s[2], nil
}

func main() {
	_, err := third([]int{10, 20})
	fmt.Println(err) // need at least 3 numbers
}

The idea: if there isn’t enough data, that’s expected (input can vary), so it’s an error.

Writing to a nil map

A nil map is a tricky thing: you can read from it, but you cannot write to it. Beginners often memorize this as “magic”, but in practice it’s enough to remember a simple rule: if you are going to write — use make.

package main

func main() {
	var m map[string]int
	m["x"] = 1 // panic: assignment to entry in nil map
}

This is almost always a programmer error, because a nil map usually appears due to forgetting initialization. Inside your code, this is closer to a panic situation (the “map is initialized” invariant is broken). But if the map came from the outside as a parameter, it is better to make the API safer: either initialize inside, or return a clear error.

package main

import "errors"

var ErrNilMap = errors.New("map must be initialized")

func addCount(m map[string]int, key string) error {
	if m == nil {
		return ErrNilMap
	}
	m[key]++
	return nil
}

Notice the nuance: the same symptom (nil map) can be a “bug” or “expected input” — it depends on whose contract it is and who is responsible for initialization.

Division by zero with integers

With integers, a / 0 is a runtime panic. But we already decided that division by zero often comes from the user, so we do not reach a / b until we check b.

package main

import "fmt"

func main() {
	fmt.Println(10 / 0) // panic: integer divide by zero
}

In our compute we added the b == 0 check, and that’s the mature Go approach: don’t catch a panic — don’t create it.

Decision cheat sheet: return error or panic

When you are just starting out, your brain wants a simple rule like “panic is evil, error is good”. But reality is a bit more complex: panic is sometimes useful as “a signal that the code is broken”.

So you don’t have to guess every time, it’s handy to keep a small cheat-sheet table and check it until your intuition develops.

Situation Is it expected? What to do Why
User entered invalid data Yes
return ..., error
This is a normal branch of the scenario
Not found (missing key, no record) Yes
return ..., error
or
(T, bool)
Not a “crash”, but a search result
Division by zero coming from input Yes
return ..., error
It can be predicted and described
Indexing beyond slice bounds due to wrong logic No fix the code; as a last resort
panic
This is a bug, not “input”
Writing to a nil map because you forgot make No fix the code; sometimes
panic
is more appropriate
The initialization invariant is broken
“Impossible branch” after full validation No (by design)
panic("unreachable")
or reconsider the design
Better to crash than quietly live a lie

Notice a practical tradition: even if a package uses panic internally, the external API usually still returns errors explicitly. Users of your code shouldn’t have to “live in panics”.

5. Learning example: add/div command calculator

Time to assemble everything into a small example: read input, compute, print. Here we will reinforce the read → compute → print style and see that most robustness comes not from “magical techniques”, but from a couple of honest checks.

First, let’s write run() error so that main is minimal and doesn’t turn into a logic dump. This is a style Go developers like: main is the application boundary, and the “work” is in a separate function.

package main

import (
	"fmt"
)

func run() error {
	var cmd string
	var a, b int

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

	res, err := compute(cmd, a, b)
	if err != nil {
		return err
	}

	fmt.Println(res)
	return nil
}

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

There is one issue so far: when input ends, fmt.Scan will return an error, and we will print "error: EOF". For a learning example this is fine, but in a real CLI you would distinguish “end of input” from a real error.

Right now what matters more is this: we never use panic as a reaction to “bad input”.

Now recall that compute is already written and returns an error. Division by zero doesn’t cause a panic, because we check beforehand. And that is the key style: if you can prevent a runtime panic with a simple check — prevent it. Not for beauty, but for predictability.

If you run it and enter:

div 10 0

you will get a clear error rather than a process crash.

Must style: when you can use it, and when you can’t

Sometimes in Go you see functions like MustXxx: template.Must, regexp.MustCompile, and so on. The idea is simple: “if there is an error here, there is no point in continuing”.

This is not a “cool style for the brave”, but a very specific tool: you lock in the assumption that an error is impossible at this point, and if it still happens — the program is assembled incorrectly.

Let’s make a sample mustCompute, but agree right away: this is not for user input, but for cases where you yourself set the command and numbers inside the code and consider them correct.

package main

import "fmt"

func mustCompute(cmd string, a, b int) int {
	res, err := compute(cmd, a, b)
	if err != nil {
		panic(fmt.Errorf("mustCompute failed: %w", err))
	}
	return res
}

Why can this be okay? Because if cmd, a, b are not from the user, but from your own code (for example, predefined parameters), then an error means a bug in program logic or its configuration. In that case, crashing is sometimes more honest than trying to “somehow continue”.

But if you start applying mustCompute to user input, you will turn any invalid input into a hard crash. The user types "div 10 0", and instead of “you can’t divide by zero” they get a crash. That’s not strictness, that’s UX sadism.

One more nuance: what should you put into panic(...)? Technically, anything, but commonly people put a string or an error. A string quickly turns into “oops”, while an error can be wrapped to add context.

6. Typical mistakes when choosing between panic and error

Mistake #1: panicking on any bad input.
This is the most common confusion: someone sees that panic is “louder” and starts using it as an “enhanced error”. The result is the program crashing where it should have simply said “I don’t understand the command” or “you can’t divide by zero”. If the problem can be expected and explained — it’s an error, not a crash.

Mistake #2: returning error from a place that actually indicates a bug.
The opposite extreme is “panic is evil, so I always return error”. Then you catch an error like errors.New("impossible happened"), print it, continue working… and 30 seconds later your data is partially corrupted because you continued execution after breaking an invariant. If the state is truly “impossible”, it is better to stop than to quietly pretend everything is under control.

Mistake #3: relying on recover as a universal “try/catch” and therefore not checking conditions.
Even if you know that recover exists in Go, the temptation of “fine, if something happens — I’ll catch the panic” leads to lazy functions that don’t check slice length, divisor, nil map. That breaks predictability. It’s much cheaper and clearer to prevent a panic with simple input checks than to later figure out why it happened.

Mistake #4: mixing contracts without an explicit agreement.
When some functions return (T, error), others sometimes panic “depending on mood”, and the calling code doesn’t know what to expect, the program becomes like a detective story: you are not solving the task, you are looking for where it will blow up next. It’s better to keep a single external contract (usually via error), and if panic is used somewhere internally as an “emergency exit” mechanism, it should be deliberate and very localized.

Mistake #5: overly abstract messages in panic and in error.
Both panic and error are communication with your future self. A message like panic("error") or return errors.New("failed") carries almost no information. A good message should answer: “what were we trying to do, and why is this bad”. Even if you crash, at least you crash with a hint rather than a riddle.

1
Task
Go SELF, level 18, lesson 2
Locked
Division without drama
Division without drama
1
Task
Go SELF, level 18, lesson 2
Locked
Command-line calculator
Command-line calculator
1
Task
Go SELF, level 18, lesson 2
Locked
Third Attempt
Third Attempt
1
Task
Go SELF, level 18, lesson 2
Locked
Validated branch
Validated branch
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION