Error Handling in Golang
What are errors?
Errors indicate an abnormal condition in the program. Let's say we are trying to open a file and the file does not exist in the file system. This is an abnormal condition and it's represented as an error.
Errors in Go are plain old values. Errors are represented using the built-in error
Just like any other built in type such as int, float64, ... error values can be stored in variables, returned from functions and so on.
Example
Let's start right away with an example program which tries to open a file which does not exist.
package main import ( "fmt" "os" ) func main() { f, err := os.Open("/test.txt") if err != nil { fmt.Println(err) return } fmt.Println(f.Name(), "opened successfully") }
In line no. 9 of the program above, we are trying to open the file at path /test.txt
(which will obviously not exist in the playground). The Open function of the os
package has the following signature,
func Open(name string) (file *File, err error)
If the file has been opened successfully, then the Open function will return the file handler and error will be nil. If there is an error while opening the file, a non nil error will be returned.
If a function or method returns an error, then by convention it has to be the last value returned from the function. Hence the Open
function returns err
as the last value.
The idiomatic way of handling error in Go is to compare the returned error to nil
. A nil value indicates that no error has occurred and a non nil value indicates the presence of an error. In our case we check whether the error is not nil in line no. 10. If it is not nil, we simply print the error and return from the main function.
Running this program will print
open /test.txt: No such file or directory
Perfect 😃. We get an error stating that the file does not exist.
Error type representation
Let's dig a little deeper and see how the built in error
type is defined. error is an interface type with the following definition,
type error interface {
Error() string
}
It contains a single method with signature Error() string
. Any type which implements this interface can be used as an error. This method provides the description of the error.
When printing the error, fmt.Println
function calls the Error() string
method internally to get the description of the error. This is how the error description was printed in line no. 11 of the above sample program.
Different ways to extract more information from the error
Now that we know error
is an interface type, lets see how we can extract more information about an error.
In the example we saw above, we have just printed the description of the error. What if we wanted the actual path of the file which caused the error. One possible way is parsing the error string. This was the output of our program,
open /test.txt: No such file or directory
We can parse this error message and get the file path "/test.txt" of the file which caused the error, but this is a shitty way. The error description can change at any time in the newer versions of the language and our code will break.
Is there a way to get the file name reliably 🤔? The answer is yes, it can be done and the standard Go library uses different ways to provide more information about errors. Lets look at them one by one.
1. Asserting the underlying struct type and getting more information from the struct fields
If you read the documentation of the Open function carefully, you can see that it returns an error of type *PathError.
PathError is a struct type and its implementation in the standard library is as follows,
type PathError struct {
Op string
Path string
Err error
}
func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
In case you are interested to know where the above source code exists, it can be found here https://golang.org/src/os/error.go?s=653:716#L11
From the above code, you can understand that *PathError
implements the error interface
by declaring the Error() string
method. This method concatenates the operation, path and actual error and returns it. Thus we got the error message,
open /test.txt: No such file or directory
The Path
field of PathError
struct contains the path of the file which caused the error. Lets modify the program we wrote above and print the path.
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Open("/test.txt")
if err, ok := err.(*os.PathError); ok {
fmt.Println("File at path", err.Path, "failed to open")
return
}
fmt.Println(f.Name(), "opened successfully")
}
In the above program, we use type assertion in line no. 10 to get the underlying value of the error interface. Then we print the path using err.Path
in line no. 11. This program outputs,
File at path /test.txt failed to open
Great 😃. We have successfully used type assertion to get the file path from the error.
2. Asserting the underlying struct type and getting more information using methods
The second way to get more information is to assert for the underlying type and get more information by calling methods on the struct type.
Let's understand this better by means of an example.
The struct type in the standard library is defined as follows,
type DNSError struct {
...
}
func (e *DNSError) Error() string {
...
}
func (e *DNSError) Timeout() bool {
...
}
func (e *DNSError) Temporary() bool {
...
}
As you can see from the above code, the DNSError
struct has two methods Timeout() bool
and Temporary() bool
which return a boolean value that indicates whether the error is because of a timeout or is it temporary.
Let's write a program which asserts the *DNSError
type and calls these methods to determine whether the error is temporary or due to timeout.
package main
import (
"fmt"
"net"
)
func main() {
addr, err := net.LookupHost("golangbot123.com")
if err, ok := err.(*net.DNSError); ok {
if err.Timeout() {
fmt.Println("operation timed out")
} else if err.Temporary() {
fmt.Println("temporary error")
} else {
fmt.Println("generic error: ", err)
}
return
}
fmt.Println(addr)
}
Note: DNS lookups do not work in the playground. Please run this program in your local machine.
In the program above, we are trying to get the ip address of an invalid domain name golangbot123.com
in line no. 9. In line no. 10 we get the underlying value of the error by asserting it to type *net.DNSError
. Then we check whether the error is due to timeout or temporary in line nos. 11 and 13 respectively.
In our case the error is neither temporary nor due to timeout and hence the program will print,
generic error: lookup golangbot123.com: no such host
If the error was temporary or due to timeout, then the corresponding if statement would have executed and we can handle it appropriately.
3. Direct comparison
The third way to get more details about an error is the direct comparison with a variable of type error
. Let's understand this by means of an example.
The Glob function of the filepath
package is used to return the names of all files that matches a pattern. This function returns an error ErrBadPattern
when the pattern is malformed.
ErrBadPattern is defined in the filepath
package as follows.
var ErrBadPattern = errors.New("syntax error in pattern")
errors.New() is used to create a new error. We will discuss about this in detail in the next tutorial.
ErrBadPattern is returned by the Glob function when the pattern is malformed.
Let's write a small program to check for this error.
package main
import (
"fmt"
"path/filepath"
)
func main() {
files, error := filepath.Glob("[")
if error != nil && error == filepath.ErrBadPattern {
fmt.Println(error)
return
}
fmt.Println("matched files", files)
}
In the program above we search for files of pattern [
which is a malformed pattern. We check whether the error is not nil. To get more information about the error, we directly comparing it to filepath.ErrBadPattern
in line. no 10. If the condition is satisfied, then the error is due to a malformed pattern. This program will print,
syntax error in pattern
The standard library uses any of the above mentioned ways to provide more information about an error. We will use these ways in the next tutorial to create our own custom errors.
Do not ignore errors
Never ever ignore an error. Ignoring errors is inviting for trouble. Let me rewrite the example which lists the name of all files that match a pattern ignoring the error handling code.
package main
import (
"fmt"
"path/filepath"
)
func main() {
files, _ := filepath.Glob("[")
fmt.Println("matched files", files)
}
We already know from the previous example that the pattern is invalid. I have ignored the error returned by the Glob
function by using the _
blank identifier in line no. 9. I simply print the matched files in line no. 10. This program will print,
matched files []
Since we ignored the error, the output seems as if no files have matched the pattern but actually the pattern itself is malformed. So never ignore errors.
This brings us to the end of this tutorial.
In this tutorial we discussed how to handle errors that occur in our program and also how to inspect the errors to get more information from them. A quick recap of what we discussed in this tutorial,
- What are errors?
- Error representation
- Various ways of extracting more information from errors
- Do not ignore errors
In the next tutorial, we will create our own custom errors and also add more context to standard errors.
Have a good day.