Error handling in Go: living with the error value
A practical approach to embracing error handling as a language idiom in Go, a language with no exception mechanism.
When I moved to Go from PHP, error handling was the thing that consumed most of my mental energy. Coming from an exception-based language, Go’s approach feels strange at first — even deliberately restrictive. Over time, understanding why this approach exists and how it becomes natural helped me grasp the rest of the language much more clearly.
How errors work in Go
In Go, error is an interface. It consists of a single method:
type error interface {
Error() string
}
Every function that can produce an error returns error as its last return value. The calling code is responsible for checking that value:
package main
import (
"errors"
"fmt"
)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("sıfıra bölme hatası")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Println("Hata:", err)
return
}
fmt.Println("Sonuç:", result)
}
There is no exception mechanism. An error is just an ordinary value — one part of what comes out of a function call.
Why if err != nil repeats so much
When reading Go code, you’ll notice if err != nil blocks recurring everywhere. This complaint comes up often. But it’s better read as a deliberate decision rather than a deficiency.
In PHP or Java, when an exception is thrown, it’s not always clear where the program stopped or which catch block kicked in. An exception climbs the call stack, jumping over frames. It’s easy to miss.
In Go, the error is right in my hands at the exact point it was produced. Ignoring it is an active choice — and while the compiler doesn’t enforce it, the linter will warn you. Handling the error isn’t optional; it’s part of the flow.
Let me contrast this with a concrete situation I experienced in PHP: an external API call silently failed, the exception was caught by an upper layer, but logging was missing. I only noticed the problem when a user complaint came in. In Go, that call would look like this, and the place where the error propagates is immediately visible:
resp, err := apiClient.Call(payload)
if err != nil {
return fmt.Errorf("dış API çağrısı başarısız: %w", err)
}
Wrapping errors
fmt.Errorf and the %w verb, introduced in Go 1.13, became the standard way to enrich error context:
func loadUserConfig(userID int) (Config, error) {
data, err := readFile(fmt.Sprintf("config/%d.json", userID))
if err != nil {
return Config{}, fmt.Errorf("loadUserConfig: kullanıcı %d için yapılandırma okunamadı: %w", userID, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("loadUserConfig: JSON ayrıştırma hatası: %w", err)
}
return cfg, nil
}
With %w the original error is wrapped but can still be inspected via errors.Is and errors.As:
cfg, err := loadUserConfig(42)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// Handle the file-not-found case specifically
}
log.Printf("Yapılandırma yüklenemedi: %v", err)
return
}
The choice between wrapping and returning directly matters. When you do return err, the original error information is preserved but context is lost. Wrapping with fmt.Errorf lets you add “who, what, where” to the error message. In a long call chain, having an error message that tells you what failed and where is far faster than hunting for a lone line in a log file.
Custom error types
When carrying just a message isn’t enough, you can define your own error type:
type NotFoundError struct {
Resource string
ID int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s bulunamadı (id: %d)", e.Resource, e.ID)
}
func findUser(id int) (*User, error) {
user := db.Find(id)
if user == nil {
return nil, &NotFoundError{Resource: "Kullanıcı", ID: id}
}
return user, nil
}
The caller can branch on this type using errors.As:
u, err := findUser(99)
if err != nil {
var notFound *NotFoundError
if errors.As(err, ¬Found) {
// Respond with 404
}
// Handle other errors
}
The adjustment period as a PHP developer
Coming from an exception-based language, the biggest habit shift is this: ignoring errors is no longer passive — it requires an active choice. Discarding an error return with _ is considered bad practice in the Go community, and rightly so.
The second major difference is that every error is handled individually. In PHP, a broad try-catch block lets you catch ten different errors in one place. In Go, there’s a check right next to every function call. It looks like a lot at first; over time you come to appreciate the value of seeing exactly where and when an error occurred.
There’s also an advantage that rarely gets mentioned: when reading code, the error path and the happy path separate cleanly. At any given line, the answer to “what happens if this call fails?” appears right on the next line. Instead of tracing a deep exception hierarchy, you follow a linear flow.
The price the language pays for this approach is syntactic repetition; the payoff is transparency in error flow. If that trade-off doesn’t suit you, Go may not suit you — but it’s an honest trade-off worth stepping into the project to discover.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.