Go Tutorials – Go Error Management — Easy, Complete Tutorial

The most important rule to remember is:

In Go, errors are normal values. A function usually returns an error, and the caller decides what to do with it.

The basic flow is:

flowchart LR
    A[Call function] --> B{Problem?}
    B -- No --> C[Return result + nil]
    B -- Yes --> D[Return zero value + error]
    D --> E{Caller checks err}
    E -- err == nil --> F[Continue]
    E -- err != nil --> G[Handle error]

For example:

result, err := doSomething()

if err != nil {
    // handle the problem
}

fmt.Println(result)

1. Built-in / Standard Error Tools

First, one terminology correction that will make Go easier to understand.

Go has:

ThingTypePurpose
errorBuilt-in interfaceRepresents an error
errorsStandard library packageCreates/checks errors
fmtStandard library packageCreates formatted errors
panicBuilt-in functionStops normal execution
recoverBuilt-in functionCatches a panic

So:

error
panic()
recover()

are built into the language.

But:

import "errors"
import "fmt"

are packages from Go’s standard library.

What?

These are the main tools used for error management.

Why?

Because different situations need different tools:

Simple error                 → errors.New()
Error containing information → fmt.Errorf()
Check returned error         → if err != nil
Unexpected fatal condition   → panic()
Catch panic                  → recover()

When?

Most application code will mainly use:

if err != nil

along with:

errors.New(...)
fmt.Errorf(...)

How / Where?

Here is a complete example showing the main tools.

Complete example

package main

import (
	"errors"
	"fmt"
)

func divide(a, b int) (int, error) {

	// errors.New creates a simple error.
	if b == 0 {
		return 0, errors.New("cannot divide by zero")
	}

	return a / b, nil
}

func main() {

	result, err := divide(10, 0)

	// Always check the returned error.
	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	fmt.Println("Result:", result)
}

Run:

go run .

Output:

Error: cannot divide by zero

Remember

error       → type/interface
errors      → package
fmt         → package
panic       → built-in function
recover     → built-in function

2. The error Interface

This is the most important concept behind Go errors.

What?

error is a built-in interface.

Conceptually, Go defines it like this:

type error interface {
	Error() string
}

That means:

Anything having a method named Error() string can be an error.

Notice the capital E:

Error()

Why?

It allows us to create our own custom error types.

When?

Use custom errors when the error should contain additional information.

For example:

user ID
filename
HTTP status
database information
validation information

How?

Create a type and give it:

Error() string

Where?

Usually inside the package responsible for that particular operation.

Complete example

package main

import "fmt"

// Create our own error type.
type AgeError struct {
	Age int
}

// Because AgeError has:
//
// Error() string
//
// it satisfies Go's built-in error interface.
func (e AgeError) Error() string {
	return fmt.Sprintf("age %d is too young", e.Age)
}

func checkAge(age int) error {

	if age < 18 {
		return AgeError{Age: age}
	}

	return nil
}

func main() {

	err := checkAge(15)

	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	fmt.Println("Age accepted")
}

Output:

Error: age 15 is too young

The important part is:

func (e AgeError) Error() string

Because of this method:

AgeError
   │
   │ has Error() string
   ▼
satisfies
   │
   ▼
error interface
flowchart TD
    A[AgeError struct] --> B["Error() string method"]
    B --> C[implements error interface]
    C --> D[Can be returned as error]

You do not write:

implements error

Go automatically recognizes it.

Remember

type error interface {
    Error() string
}

is the heart of Go error management.


3. The errors Package

Now we know that error is an interface.

The next question is:

How do I easily create an error?

One common answer is the errors package.

import "errors"

What?

errors is a standard Go package for creating and working with errors.

The simplest function is:

errors.New()

Example:

errors.New("user not found")

Why?

Because creating a custom struct for every simple error would be unnecessary.

Instead of creating:

type MyError struct {
	...
}

you can simply write:

errors.New("something went wrong")

When?

Use errors.New() when the error message is fixed/simple.

Example:

invalid password
user not found
division by zero
file is empty

How?

err := errors.New("something went wrong")

Where?

Usually inside the function where the problem is detected.

Complete example

package main

import (
	"errors"
	"fmt"
)

func login(password string) error {

	if password != "secret123" {

		// Create and return a simple error.
		return errors.New("invalid password")
	}

	// nil means there was no error.
	return nil
}

func main() {

	err := login("wrong-password")

	if err != nil {
		fmt.Println("Login failed:", err)
		return
	}

	fmt.Println("Login successful")
}

Output:

Login failed: invalid password

The flow is:

login()
   │
   ├── correct password
   │       ↓
   │      nil
   │
   └── wrong password
           ↓
      errors.New(...)
           ↓
         error

Most important pattern

return errors.New("something went wrong")

4. errors.New() and fmt.Errorf()

You mentioned Error & Errorf function.

The common functions you will actually use are:

errors.New()
fmt.Errorf()

There isn’t a general built-in Error() function for creating errors.

Error() is normally the method required by the error interface.

errors.New()

Creates a simple fixed error.

errors.New("user not found")

fmt.Errorf()

Creates an error containing dynamic information.

fmt.Errorf("user %s not found", username)

Why Errorf?

Suppose username is:

rajesh

Instead of:

errors.New("user not found")

you might want:

user rajesh not found

That’s where:

fmt.Errorf()

is useful.

When?

Use:

errors.New() → simple/static error
fmt.Errorf() → dynamic/formatted error

Complete example

package main

import (
	"errors"
	"fmt"
)

func findUser(username string) error {

	if username == "" {

		// Fixed message:
		// errors.New is perfect here.
		return errors.New("username cannot be empty")
	}

	if username != "rajesh" {

		// Dynamic message:
		// username is inserted into the error.
		return fmt.Errorf("user %s not found", username)
	}

	return nil
}

func main() {

	err := findUser("amit")

	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	fmt.Println("User found")
}

Output:

Error: user amit not found

Easy rule

Fixed error:

errors.New("database unavailable")


Dynamic error:

fmt.Errorf("user %s not found", username)

Think:

New       = simple message
Errorf    = formatted message

5. Error Handling in Go

Now we reach the part you will use constantly.

What?

Error handling means:

  1. call a function
  2. receive an error
  3. check the error
  4. decide what to do

The famous Go pattern is:

if err != nil {
	// handle error
}

Why?

Go intentionally makes error handling explicit.

You can clearly see:

where an error happened
who handled it
what happened after it

When?

Whenever a function returns:

error

check it.

How?

Common pattern:

value, err := someFunction()

if err != nil {
	return
}

Where?

Immediately after calling the function.

Prefer:

result, err := divide(10, 2)

if err != nil {
	...
}

rather than ignoring err.

Complete example

package main

import (
	"errors"
	"fmt"
)

func divide(a, b float64) (float64, error) {

	if b == 0 {
		return 0, errors.New("cannot divide by zero")
	}

	// Success:
	// result + nil error
	return a / b, nil
}

func main() {

	result, err := divide(20, 4)

	// Step 1: check error.
	if err != nil {
		fmt.Println("Failed:", err)
		return
	}

	// Step 2: use result only after checking error.
	fmt.Println("Result:", result)
}

Output:

Result: 5

Now change:

divide(20, 4)

to:

divide(20, 0)

Output:

Failed: cannot divide by zero

Understand (result, error)

This is extremely common:

func divide(a, b float64) (float64, error)

Success:

return a / b, nil

means:

Result = valid
Error  = nothing

Failure:

return 0, errors.New("cannot divide by zero")

means:

Result = zero value
Error  = problem information

Mental model:

flowchart TD
    A["divide(20,4)"] --> B{Problem?}

    B -- No --> C["return 5, nil"]
    B -- Yes --> D["return 0, error"]

    C --> E["err == nil"]
    D --> F["err != nil"]

    E --> G[Use result]
    F --> H[Handle error]

The #1 Go error pattern

Memorize this:

result, err := someFunction()

if err != nil {
	fmt.Println(err)
	return
}

fmt.Println(result)

You will see this everywhere in Go.


6. Errors vs Panics

This distinction is extremely important.

Error

An error means:

Something went wrong, but the program can potentially deal with it.

Examples:

file doesn't exist
wrong password
database unavailable
invalid user input
network timeout

Return:

error

Panic

A panic means:

Normal program execution cannot safely continue.

Example:

panic("something seriously wrong")

A panic starts unwinding the current goroutine’s stack.

Unless recovered, the program terminates and prints a stack trace.

When to use which?

Normal expected problem:

return errors.New("user not found")

Exceptional/programmer/invariant problem:

panic("configuration must never be empty")

Complete example

package main

import (
	"errors"
	"fmt"
)

// Normal problem:
// return an error.
func checkUsername(username string) error {

	if username == "" {
		return errors.New("username cannot be empty")
	}

	return nil
}

// Serious invariant problem:
// panic.
func startApplication(configLoaded bool) {

	if !configLoaded {
		panic("application configuration was not loaded")
	}

	fmt.Println("Application started")
}

func main() {

	// NORMAL ERROR
	err := checkUsername("")

	if err != nil {
		fmt.Println("Handled error:", err)
	}

	fmt.Println("Program is still running")

	// PANIC
	startApplication(false)

	// This will never execute.
	fmt.Println("Finished")
}

Typical output:

Handled error: username cannot be empty
Program is still running
panic: application configuration was not loaded

Notice the difference.

With error:

if err != nil {
	fmt.Println(err)
}

you control what happens.

With:

panic(...)

normal execution stops.

Easy comparison

ErrorPanic
Normal problemExceptional situation
ReturnedThrown by panic()
Caller handles itStops normal execution
Very commonMuch less common
return errpanic(...)
Expected failuresBroken assumptions/invariants

Golden rule

Don’t use panic just because something returned an error.

Usually:

if err != nil {
	return err
}

not:

if err != nil {
	panic(err)
}

7. Converting Panic to Error

Sometimes you are calling code that may panic, but you don’t want that panic to crash your program.

Go provides:

recover()

But recover() works only from a deferred function during panic unwinding.

The pattern is:

defer func() {
	if r := recover(); r != nil {
		// panic was caught
	}
}()

What?

recover() catches a panic.

Why?

It lets you turn:

panic

into:

ordinary error

When?

Use it mainly at controlled boundaries.

For example:

server request handler
library wrapper
plugin execution
worker/job boundary
code you don't control

Do not use recover() as normal error handling.

How?

This looks strange initially:

defer func() {
	if r := recover(); r != nil {
		err = fmt.Errorf("panic occurred: %v", r)
	}
}()

The defer waits.

If a panic occurs, the deferred function runs and recover() catches the panic.

Complete example

package main

import "fmt"

// This function converts a panic into a normal error.
func safeDivide(a, b int) (result int, err error) {

	// This deferred function runs when safeDivide finishes,
	// including when a panic happens.
	defer func() {

		// recover() catches the panic.
		if r := recover(); r != nil {

			// Convert panic value into a normal error.
			err = fmt.Errorf("operation failed: %v", r)
		}
	}()

	if b == 0 {
		panic("cannot divide by zero")
	}

	result = a / b
	return result, nil
}

func main() {

	result, err := safeDivide(10, 0)

	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	fmt.Println("Result:", result)
}

Output:

Error: operation failed: cannot divide by zero

Without recover():

panic
   ↓
program crashes

With recover():

panic
   ↓
defer runs
   ↓
recover()
   ↓
fmt.Errorf(...)
   ↓
normal error
   ↓
caller handles it
flowchart TD
    A[Function starts] --> B[defer recovery function]
    B --> C[Code runs]
    C --> D{Panic?}

    D -- No --> E[Normal return]

    D -- Yes --> F[Deferred function runs]
    F --> G["recover()"]
    G --> H["Create error using fmt.Errorf"]
    H --> I[Return error to caller]

Important

This:

recover()

does not mean:

catch every error.

It only catches:

panic()

Errors and panics are two different mechanisms.


The Complete Mental Model

You can understand nearly all basic Go error handling with this picture:

flowchart TD
    A[Function executes] --> B{Problem occurs?}

    B -- No --> C["return result, nil"]

    B -- Yes --> D{Normal / expected problem?}

    D -- Yes --> E["return result, error"]
    E --> F["Caller checks err != nil"]
    F --> G[Handle / return / log error]

    D -- No --> H["panic(...)"]
    H --> I{recover exists?}

    I -- No --> J[Program / goroutine terminates]
    I -- Yes --> K["recover()"]
    K --> L[Possibly convert panic to error]

Final Comparison — Everything Side by Side

ConceptWhat is it?Main purposeTypical syntaxWhen to useNormal?
errorBuilt-in interfaceRepresent a problemvar err errorNearly all recoverable failuresYes
Error() stringInterface methodMakes a type satisfy errorfunc (e MyError) Error() stringCustom errorsYes
errorsStandard packageError utilitiesimport "errors"Creating/checking errorsYes
errors.New()FunctionCreate simple errorerrors.New("invalid input")Static error messageYes
fmt.Errorf()FunctionCreate formatted errorfmt.Errorf("user %s not found", name)Dynamic error messageYes
if err != nilError-handling patternDetect returned errorsif err != nil {...}After error-returning function callsExtremely common
nilNo errorIndicates successreturn value, nilSuccessful operationExtremely common
panic()Built-in functionStop normal executionpanic("bad state")Exceptional/unrecoverable situationRare
recover()Built-in functionCatch panicrecover()Controlled panic boundaryRare
defer + recoverRecovery patternConvert/catch panicdefer func(){...}()Server/library boundariesRare

errors.New vs fmt.Errorf vs panic

This is probably the most useful decision table to remember:

SituationUse
Simple fixed errorerrors.New()
Error needs variables/datafmt.Errorf()
Function succeedsnil
Function returns errorif err != nil
Normal expected failureerror
Truly exceptional impossible statepanic()
Need to catch a panicrecover()

For example:

// Simple
return errors.New("invalid password")
// Dynamic
return fmt.Errorf("user %s not found", username)
// Success
return result, nil
// Handle
if err != nil {
	return
}
// Exceptional
panic("database configuration missing")
// Catch panic
if r := recover(); r != nil {
	fmt.Println(r)
}

One Very Important Go Pattern to Memorize

If you remember only one piece of code from this tutorial, remember this:

package main

import (
	"errors"
	"fmt"
)

func divide(a, b int) (int, error) {

	if b == 0 {
		return 0, errors.New("cannot divide by zero")
	}

	return a / b, nil
}

func main() {

	result, err := divide(10, 2)

	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	fmt.Println("Result:", result)
}

The entire philosophy is essentially:

Function
   │
   ├── Success
   │      ↓
   │  result, nil
   │
   └── Failure
          ↓
      zero, error
          │
          ▼
    Caller checks
          │
     err != nil

Final Memory Trick

error           = WHAT represents a problem

errors.New()    = CREATE a simple problem

fmt.Errorf()    = CREATE a problem with values/details

err != nil      = CHECK whether a problem happened

panic()         = STOP because something exceptional happened

recover()       = CATCH a panic

defer           = WHERE recover is normally placed

And the single most important distinction:

Errors are normal in Go. Panics are exceptional.

For most Go programs, you will spend far more time writing if err != nil than writing panic() or recover().

Related Posts

Go Tutorials: Go Methods

Go methods become much easier once you understand one idea: A method is simply a function attached to a type. And the important rule is: A receiver…

Read More

Go Tutorials: Modules, Packages, Subpackages, Submodules, and Workspaces

This tutorial gives you one complete mental model for: The most important idea is: And sometimes: These two designs are very different. 1. What is a Go…

Read More

Go Tutorials: Numeric Types Beginner Tutorial

Go has four main families of numeric types: The simplest way to remember them is: Type family Stores Example int Whole numbers, positive or negative -10, 0,…

Read More

Go Tutorials: Go Testing, Benchmarking, and Profiling

These three topics become much easier once you separate the questions they answer: Topic Main question Main Go tool Result Testing Does my code work correctly? go…

Read More

Go Tutorials: Generics

1. What are Generics? Generics let you write one piece of code that works with multiple types while keeping Go’s compile-time type safety. Suppose you want a…

Read More

Go Concurrency Management Made Simple

This tutorial covers: The goal is: One concept → one purpose → one complete runnable example. Every example is independent. Save any example as: and run: 0….

Read More