Error handling is one of those things that looks very simple when you start learning Go.
You call a function.
You get a result and an error.
Then you write:
result, err := doSomething()
if err != nil {
return err
}
That is basically how Go handles errors.
There is no try-catch like Java or Python. There is no need to throw an exception and wait for something else to catch it.
Go treats errors as values. You receive the error from a function and decide what to do with it.
This looks simple but there is more to good error handling than just writing if err != nil.
In a real application you need to know when to create an error, when to return it, when to add context and when to actually handle it.
This article covers those parts in a practical way.
What is an error in Go?
In Go the error type is an interface.
It looks like this:
type error interface {
Error() string
}
Any type that implements the Error() method can be used as an error.
For example:
type MyError struct {
Message string
}
func (e MyError) Error() string {
return e.Message
}
Now MyError can be returned as an error.
But most of the time you do not need to create your own error type.
For simple errors you can use errors.New().
import "errors"
err := errors.New("something went wrong")
You can then return it from a function:
func divide(a int, b int) (int, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero")
}
return a / b, nil
}
When everything works we return nil as the error.
When something goes wrong we return an actual error.
That is the basic idea behind error handling in Go.
Why does Go use errors instead of exceptions?
If you come from languages like Java, Python or JavaScript you might find Go's approach a little strange at first.
In many languages you might write something like:
try:
do something
catch:
handle the error
Go does not work this way for normal errors.
Instead you write:
result, err := doSomething()
if err != nil {
return err
}
The error is part of the function's return value.
This makes the error path visible.
You can look at a function and immediately see that it can return an error:
func getUser(id int) (*User, error)
The second return value tells us that the function can fail.
This explicit style can sometimes make Go code look repetitive.
But there is also a benefit.
You are less likely to forget that an operation can fail because the error is directly in front of you.
The Go documentation also describes explicit error checking as an important part of the language's design.
The most common pattern
You will see this pattern everywhere in Go:
user, err := getUser(10)
if err != nil {
return err
}
fmt.Println(user)
The important thing is not the syntax.
The important thing is that we check the error immediately.
For example:
file, err := os.Open("config.json")
if err != nil {
return err
}
defer file.Close()
If opening the file fails we stop there.
We do not continue using file.
This makes the control flow easy to follow.
errors.New() for simple errors
When you need a simple error you can use errors.New().
var ErrInvalidAge = errors.New("invalid age")
Then:
func validateAge(age int) error {
if age < 18 {
return ErrInvalidAge
}
return nil
}
This works well when the error has a specific meaning and you want other parts of the application to recognize it.
For example:
if err := validateAge(15); err != nil {
return err
}
But there is another important tool when you need to add more information.
Adding context with fmt.Errorf
Imagine you are reading a user from a database.
You get an error:
user, err := repo.FindByID(id)
if err != nil {
return err
}
The problem is that the error might not tell you where the failure happened.
Was it the repository?
Was it the user query?
Was it the database connection?
We can add context:
user, err := repo.FindByID(id)
if err != nil {
return fmt.Errorf("failed to find user: %w", err)
}
Now the error contains useful information.
The %w is important here.
It does not just format the error as text. It wraps the original error so the original error can still be inspected later.
You can add more context as the error moves through different layers.
For example:
err := repo.FindUser(id)
if err != nil {
return fmt.Errorf("get user from repository: %w", err)
}
Then another layer:
err := service.GetUser(id)
if err != nil {
return fmt.Errorf("get user from service: %w", err)
}
The final error might look like:
get user from service: get user from repository: database connection failed
Now you know much more about what happened.
Why %w matters
There is an important difference between these two:
fmt.Errorf("failed to get user: %v", err)
and:
fmt.Errorf("failed to get user: %w", err)
%v formats the error as text.
%w wraps the error.
If you need to inspect the original error later you normally want %w.
For example:
if errors.Is(err, ErrUserNotFound) {
// handle user not found
}
This can still work even when the error has been wrapped.
That is one of the most useful parts of Go's modern error handling.
errors.Is
Sometimes you do not care about the exact error type.
You only care whether the error represents a specific condition.
For example:
var ErrUserNotFound = errors.New("user not found")
A repository might return:
return fmt.Errorf("find user %d: %w", id, ErrUserNotFound)
Later you can check it:
if errors.Is(err, ErrUserNotFound) {
return http.StatusNotFound
}
This is much better than checking the error message:
if err.Error() == "user not found" {
// ...
}
Do not build application logic around error strings.
Messages are for humans.
errors.Is gives you a proper way to check whether an error represents a known condition.
errors.As
Sometimes you need more than a specific error value.
You might need a specific error type that contains additional information.
For example:
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Reason
}
You can return it:
return &ValidationError{
Field: "email",
Reason: "invalid email address",
}
Then another layer can inspect the error:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println(validationErr.Field)
}
This is where custom error types become useful.
The error is not only a message anymore.
It carries structured information that another part of the application can use.
When should you create a custom error?
You do not need a custom error type for every error.
For something simple:
errors.New("invalid input")
is perfectly fine.
A custom error becomes useful when the caller needs additional information.
For example:
type APIError struct {
Code int
Message string
}
Or:
type ValidationError struct {
Field string
Reason string
}
The important question is not:
Can I create a custom error?
You obviously can.
The better question is:
Does the caller need structured information from this error?
If the answer is no then a simple error is usually enough.
Do not compare error strings
This is one of the common mistakes I see in Go code:
if err.Error() == "user not found" {
// ...
}
It works until someone changes the message.
Maybe tomorrow the message becomes:
user with the given ID was not found
Now the logic breaks.
Error messages should not normally be used as identifiers.
Instead define a known error:
var ErrUserNotFound = errors.New("user not found")
Then check it:
if errors.Is(err, ErrUserNotFound) {
// ...
}
This separates the error's meaning from its message.
Error handling across layers
This becomes especially important in backend applications.
Imagine a simple application with three layers:
HTTP Handler
↓
Service
↓
Repository
↓
Database
The repository talks to the database.
The service contains business logic.
The handler deals with HTTP.
Suppose the database returns an error.
The repository should usually add useful context and return it.
func (r *UserRepository) FindByID(id int) (*User, error) {
user, err := queryDatabase(id)
if err != nil {
return nil, fmt.Errorf("find user by id %d: %w", id, err)
}
return user, nil
}
The service can add its own context if needed:
func (s *UserService) GetUser(id int) (*User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return user, nil
}
The handler can then decide how that error should be presented to the client.
user, err := service.GetUser(id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
This separation is important.
The database should not decide what HTTP response the user receives.
The HTTP handler should not need to know how the database works.
Each layer should handle the part of the error that belongs to it.
Do not expose internal errors to users
Consider this error:
failed to create user: insert user: pq: duplicate key value violates unique constraint users_email_key
This might be useful for a developer.
It is not necessarily a good response for an API user.
A client might only need:
{
"error": "email already exists"
}
Internally you can keep the original error for logging.
Externally you return a safe message.
This gives us two different goals:
Developer information
create user: insert user: duplicate key value violates unique constraint
User information
email already exists
Good error handling should consider both.
The official Go blog also demonstrates this idea by separating the internal error from the message shown to the user.
Logging errors
Another common mistake is logging the same error at every layer.
Imagine this:
if err != nil {
log.Println(err)
return err
}
Then the service logs it again.
Then the handler logs it again.
Now one failure creates three log entries.
That makes production debugging harder.
A better approach is to decide where an error should be logged.
Often the boundary of the application is a good place.
For example:
user, err := service.GetUser(id)
if err != nil {
logger.Error("failed to get user", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
Lower layers can add context and return the error without necessarily logging it.
The exact approach depends on the application but the main idea is simple:
Do not log an error just because you received it.
Log it where you can actually do something useful with that information.
What about panic?
Go has panic but it is not a replacement for normal error handling.
This is usually not what you want:
func getUser(id int) *User {
user, err := findUser(id)
if err != nil {
panic(err)
}
return user
}
If a user does not exist then that is usually an expected application condition.
It should probably be an error.
func getUser(id int) (*User, error) {
user, err := findUser(id)
if err != nil {
return nil, err
}
return user, nil
}
panic is more appropriate for situations where the program cannot safely continue or where there is a programming error.
You can use recover to recover from a panic but it should generally be used at a suitable application boundary rather than as a replacement for normal error handling. The official Go material discusses using recovery as part of HTTP-level error handling.
One more important thing: not every error should be handled immediately
There is a difference between checking an error and handling an error.
You should check errors when they are returned.
But you do not always need to solve the problem at that exact line.
For example:
user, err := repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("find user: %w", err)
}
The repository does not know what the API should return.
It just adds context and passes the error upward.
The handler can then decide:
if errors.Is(err, ErrUserNotFound) {
// 404
}
Or:
// 500
This is an important distinction.
Check the error where it happens. Handle it where you have enough information to make the right decision.
A practical example
Here is a small example that brings the ideas together:
var ErrUserNotFound = errors.New("user not found")
type UserRepository struct{}
func (r *UserRepository) FindByID(id int) (*User, error) {
user, err := findFromDatabase(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("find user by id %d: %w", id, err)
}
return user, nil
}
The service can add context:
func (s *UserService) GetUser(id int) (*User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return user, nil
}
The HTTP handler can make the final decision:
user, err := service.GetUser(id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
logger.Error("failed to get user", "error", err)
http.Error(
w,
"internal server error",
http.StatusInternalServerError,
)
return
}
Notice what happened here.
The repository understood the database error.
The service added application context.
The handler understood HTTP.
The original error was preserved throughout the process.
That is what good error handling looks like in a layered Go application.
Common mistakes
Here are some mistakes worth avoiding.
Ignoring errors
result, _ := doSomething()
Sometimes ignoring an error is intentional.
But doing it without a good reason is risky.
If the error matters then handle it.
Returning vague errors
return errors.New("failed")
This does not tell us much.
Prefer:
return fmt.Errorf("failed to load user profile: %w", err)
Context matters.
Comparing error strings
Avoid:
if err.Error() == "user not found" {
}
Use errors.Is when you have a known error.
Wrapping without %w
If you want to preserve the original error use:
fmt.Errorf("operation failed: %w", err)
Not:
fmt.Errorf("operation failed: %v", err)
Using panic for normal failures
A missing user is not usually a reason to crash the program.
Return an error.
Logging everywhere
Do not automatically log an error at every layer.
Add context as the error moves up and log it where it is actually useful.
A simple mental model
When I think about Go error handling I keep it simple:
Something can fail
↓
Return an error
↓
Check the error
↓
Add useful context
↓
Preserve the original error
↓
Pass it upward if needed
↓
Handle it at the right layer
That is most of what you need.
You do not need a complicated error framework for every Go project.
Start with the standard library.
Use errors.New for simple errors.
Use fmt.Errorf with %w when you need context.
Use errors.Is when you need to identify a known error.
Use errors.As when you need a specific error type.
Create custom errors when you actually need structured information.
Use panic for situations that should not be handled as normal application errors.
Final thoughts
Go's error handling can look repetitive when you first see it.
You will write a lot of:
if err != nil {
return err
}
But that repetition is also part of what makes Go code predictable.
The error path is visible.
You know which operations can fail.
You know where the error is being passed.
You can add context without losing the original error.
You can decide where the error should finally be handled.
The important part is not just checking err != nil.
Good Go error handling is about giving errors enough context while keeping them useful to the next layer.
Keep errors explicit.
Keep their meaning clear.
Preserve the original error when wrapping.
Handle errors at the layer that has enough information to make the right decision.
That is usually enough to build error handling that is simple and reliable.