Table of contents
- Go Doesn’t Throw Exceptions
- The error Interface
- Adding Context with fmt.Errorf
- errors.Is and errors.As
- Custom Error Types
- defer
- panic and recover
Go Doesn’t Throw Exceptions
In Java or Python, when something goes wrong, you throw an exception. You either catch it with try-catch or you don’t. Go chose not to follow this approach. Instead, it returns errors as values.
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Open("nonexistent.txt")
if err != nil {
fmt.Println("failed to open file:", err)
return
}
defer f.Close()
fmt.Println("file opened successfully")
}
os.Open returns both a file and an error. If the error is not nil, something went wrong. This pattern might feel repetitive, and the Go community acknowledges this. But the reason for sticking with it is clear: it makes errors hard to ignore.
In exception-based languages, if you don’t write a catch block, errors either silently disappear or the program suddenly crashes. In Go, if you don’t handle a returned error, gofmt or linters will raise a warning.
The error Interface
Go’s error is a simple interface.
type error interface {
Error() string
}
Any type that implements just the Error() method can be an error. This simplicity is the heart of Go’s error system.
The simplest way to create an error is with errors.New.
package main
import (
"errors"
"fmt"
)
func validate(age int) error {
if age < 0 {
return errors.New("age cannot be negative")
}
if age > 150 {
return errors.New("please enter a realistic age")
}
return nil
}
func main() {
if err := validate(-5); err != nil {
fmt.Println("validation failed:", err)
}
}
When there’s no error, return nil. Checking with if err != nil on the caller side is the basic rhythm of Go code.
Adding Context with fmt.Errorf
When you want to add extra information to an error message, use fmt.Errorf.
package main
import (
"fmt"
"os"
)
func readConfig(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to read config file (%s): %w", path, err)
}
return nil
}
func main() {
err := readConfig("/etc/myapp/config.yaml")
if err != nil {
fmt.Println(err)
}
}
The %w verb is important here. Unlike %v, %w wraps the original error, preserving it. This allows you to later inspect the original error with errors.Is or errors.As.
The pattern of adding context to errors while maintaining the chain is essential in practice. The debugging difficulty is entirely different between seeing just “file open failed” in the logs versus “failed to read config file (/etc/myapp/config.yaml): open /etc/myapp/config.yaml: no such file or directory.”
Here’s a visualization of how wrapping with %w builds an error chain.
flowchart LR
OS["os.Open failed<br/>*PathError<br/>(no such file...)"] --> Wrap1["fmt.Errorf('readFile failed: %w', err)"]
Wrap1 --> Wrap2["fmt.Errorf('failed to read config file (%s): %w', path, err)"]
Wrap2 --> Log[Final error message]
Log -.->|"errors.Is"| OS
Log -.->|"errors.As"| OS
errors.Is and errors.As
When errors are wrapped, errors.Is is how you check for a specific error.
package main
import (
"errors"
"fmt"
"os"
)
func readFile(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("readFile failed: %w", err)
}
return nil
}
func main() {
err := readFile("nonexistent.txt")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Println("file does not exist")
} else {
fmt.Println("other error:", err)
}
}
}
errors.Is walks the error chain looking for a match. Since == comparison can’t find wrapped errors, you must develop the habit of using errors.Is.
When you need to extract an error of a specific type, use errors.As.
package main
import (
"errors"
"fmt"
"os"
)
func main() {
_, err := os.Open("nonexistent.txt")
if err != nil {
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("path:", pathErr.Path)
fmt.Println("operation:", pathErr.Op)
}
}
}
errors.As finds a specific type in the error chain and assigns it to a variable. It’s useful when you need to access additional information from the error.
Custom Error Types
By directly implementing the error interface, you can include any information you want in your errors.
package main
import "fmt"
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{
Field: "age",
Message: "must be between 0 and 150",
}
}
return nil
}
func main() {
err := validateAge(200)
if err != nil {
fmt.Println(err) // age: must be between 0 and 150
}
}
As long as there’s an Error() string method, the error interface is satisfied. You can freely include fields, codes, causes, and other information in the struct, which makes this pattern widely used for building API error responses or categorizing errors.
defer
defer schedules code to run when the function exits. It’s used for cleanup tasks like closing files, releasing locks, or closing connections.
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Create("test.txt")
if err != nil {
fmt.Println("failed to create file:", err)
return
}
defer f.Close() // Runs when main exits
f.WriteString("Hello, Go!")
fmt.Println("file written successfully")
}
The key point of defer is scheduling the close right when you open the resource. Since the open and close are near each other, you won’t forget. It serves a similar role to Java’s try-finally or Python’s with, but is much lighter.
When there are multiple defer statements, they execute in LIFO (last in, first out) order.
package main
import "fmt"
func main() {
fmt.Println("start")
defer fmt.Println("first defer")
defer fmt.Println("second defer")
defer fmt.Println("third defer")
fmt.Println("end")
}
// start
// end
// third defer
// second defer
// first defer
They stack up and execute in reverse order. This design naturally aligns with closing resources in the reverse order of opening them.
One thing to note: arguments passed to defer are evaluated at the point the defer is declared.
package main
import "fmt"
func main() {
x := 10
defer fmt.Println("deferred x:", x) // 10 is captured
x = 20
fmt.Println("x:", x) // x: 20
}
// x: 20
// deferred x: 10
panic and recover
panic immediately halts the program. recover recovers from a panic. These two should not be used for everyday error handling. They’re only for truly unrecoverable situations.
package main
import "fmt"
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
return a / b, nil // Panics if b is 0
}
func main() {
result, err := safeDiv(10, 0)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
}
recover only works inside a defer. Calling it in regular code always returns nil.
In practice, cases where panic is appropriate are extremely limited. It’s reserved for situations like missing required configuration at program startup, or detecting a programming error that should never occur.
func mustParseURL(rawURL string) *url.URL {
u, err := url.Parse(rawURL)
if err != nil {
panic(fmt.Sprintf("invalid URL: %s", rawURL))
}
return u
}
Functions in the Go standard library starting with Must (template.Must, regexp.MustCompile, etc.) follow this pattern. If initialization fails, there’s no reason to keep the program running, so they just trigger a panic.
Always handle normal errors with error return values, and use panic only as a last resort.
The next part covers Go’s collections. We’ll look at the difference between arrays and slices, the internal structure of slices, and how to work with maps.




Loading comments...