Skip to main content

Adding context to errors in Go

· 3 min read
Yaroslav Grebnov
Golang developer, SDET

This post compares how to enrich Go errors — both sentinel and typed — with additional context using github.com/ygrebnov/errorc.With versus the standard fmt.Errorf approach.

Sentinel error

Let's define a simple sentinel error:

import "errors"

var ErrInvalidInput = errors.New("invalid input")

Using github.com/ygrebnov/errorc.With function, the context can be added to the sentinel error in the following way:

import "github.com/ygrebnov/errorc"

err := errorc.With(
ErrInvalidInput,
errorc.Field("field1", "value1"),
errorc.Field("field2", "value2"),
)

This approach adds structured key-value context while preserving the original error, enabling accurate identification during error handling.

github.com/ygrebnov/errorc.With keeps track of the original error. This allows identifying it later during error handling using errors.Is function:

import (
"errors"
"fmt"
)

if errors.Is(err, ErrInvalidInput) {
fmt.Print("Handled invalid input error: ", err.Error())
}

// Output: Handled invalid input error: invalid input, field1: value1, field2: value2

While fmt.Errorf is slightly less verbose, it produces a less structured error format:

import "fmt"

err := fmt.Errorf("%w, field1: %s, field2: %s",
ErrInvalidInput,
"value1",
"value2",
)

The same as with github.com/ygrebnov/errorc.With, the original error is preserved, and it can be checked using errors.Is.

Typed error

The same as for a sentinel error, the context can be added to a typed error using github.com/ygrebnov/errorc.With function. The typed error can be later identified using errors.As function:

package main

import (
"errors"
"fmt"

"github.com/ygrebnov/errorc"
)

type ValidationError struct {
Message string
}

func (e *ValidationError) Error() string {
return e.Message
}

func main() {
// Create a new error of type ValidationError.
err := errorc.With(
&ValidationError{"invalid input"},
errorc.Field("field1", "value1"),
errorc.Field("field2", "value2"),
)

// Identify ValidationError using errors.As.
var ve *ValidationError
if errors.As(err, &ve) {
// Handle ValidationError.
fmt.Print("Handled ValidationError: ", err.Error())
}

// Output: Handled ValidationError: invalid input, field1: value1, field2: value2
}

The same behavior can be achieved using fmt.Errorf. The error wrapping code is slightly less verbose, but looks less structured:

err := fmt.Errorf("%w, field1: %s, field2: %s",
&ValidationError{"invalid input"},
"value1",
"value2",
)

Performance comparison

The benchmark tests below will compare performance of github.com/ygrebnov/errorc.With with that of fmt.Errorf:

import (
"errors"
"fmt"
"testing"

"github.com/ygrebnov/errorc"
)

func BenchmarkWith(b *testing.B) {
baseErr := errorc.New("benchmark error")
field1 := errorc.Field("key1", "value1")
field2 := errorc.Field("key2", "value2")

b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = errorc.With(baseErr, field1, field2)
}
}

func BenchmarkFmtErrorf(b *testing.B) {
baseErr := errors.New("benchmark error")
val1 := "value1"
val2 := "value2"

b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = fmt.Errorf("%w, key1: %s, key2: %s", baseErr, val1, val2)
}
}

These benchmarks measure the cost of constructing wrapped errors with two context fields in a tight loop.

The output of the benchmarks shows that github.com/ygrebnov/errorc.With is significantly faster than fmt.Errorf:

BenchmarkWith-8         53965288                21.81 ns/op
BenchmarkFmtErrorf-8 7401583 186.7 ns/op

Conclusion

In conclusion, github.com/ygrebnov/errorc.With provides a structured and efficient way to add context to errors in Go. Like fmt.Errorf, it allows for both sentinel and typed errors to be wrapped with additional information while maintaining the original error's identity. Benchmarks show that errorc.With is significantly faster than fmt.Errorf, making it a strong candidate for structured and performant error handling in Go applications.