Adding context to errors in Go
This post compares two ways to enrich Go errors — both sentinel and typed — with additional context:
github.com/ygrebnov/errorc.With- the standard
fmt.Errorfapproach
The main difference is not only syntax. errorc gives you a more structured way to attach fields, and it works especially well together with github.com/ygrebnov/keys when you want stable hierarchical field names such as user.id or request.trace_id.
Sentinel error
Let's define a simple sentinel error:
import "errors"
var ErrInvalidInput = errors.New("invalid input")
Using github.com/ygrebnov/errorc.With, the context can be added to the sentinel error like this:
import "github.com/ygrebnov/errorc"
err := errorc.With(
ErrInvalidInput,
errorc.String("field1", "value1"),
errorc.String("field2", "value2"),
)
This approach adds structured key-value context while preserving the original error, which means the wrapped error can still be matched during handling.
github.com/ygrebnov/errorc.With keeps track of the original error. This allows identifying it later using errors.Is:
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
Using fmt.Errorf, you can achieve a similar result:
import "fmt"
err := fmt.Errorf("%w, field1: %s, field2: %s",
ErrInvalidInput,
"value1",
"value2",
)
Just like with errorc.With, the original error is preserved and can still be checked with errors.Is.
The difference is mostly in how the extra information is represented. fmt.Errorf formats everything into one string immediately, while errorc lets you think in terms of fields.
Typed error
The same approach works for a typed error. The wrapped typed error can still be identified using errors.As:
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.String("field1", "value1"),
errorc.String("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 also be achieved using fmt.Errorf:
err := fmt.Errorf("%w, field1: %s, field2: %s",
&ValidationError{"invalid input"},
"value1",
"value2",
)
Structured keys with keys
One practical advantage of errorc is that field helpers such as errorc.String accept any type whose underlying type is string. That makes it convenient to use typed keys from github.com/ygrebnov/keys.
package main
import (
"fmt"
"github.com/ygrebnov/errorc"
"github.com/ygrebnov/keys"
)
func main() {
userKey := keys.Factory(keys.WithSegments("user"))
requestKey := keys.Factory(keys.WithSegments("request"))
err := errorc.With(
errorc.New("invalid input"),
errorc.String(userKey("id"), "123"),
errorc.String(requestKey("trace_id"), "abc-xyz"),
)
fmt.Println(err)
// Output: invalid input, user.id: 123, request.trace_id: abc-xyz
}
This is where errorc becomes more compelling than a single formatted string: your field names can be centralized, reused, and kept consistent across logs, metrics, tracing attributes, and errors.
Beyond strings: Int, Bool, and Error
Context is not limited to string values. errorc also provides helpers for common cases:
cause := errors.New("disk full")
err := errorc.With(
errorc.New("operation failed"),
errorc.Int("retries", 3),
errorc.Bool("retryable", false),
errorc.Error("cause", cause),
)
fmt.Println(err)
// operation failed, retries: 3, retryable: false, cause: disk full
You can build the same final message with fmt.Errorf, but errorc keeps the code more uniform when you regularly attach multiple fields.
Performance comparison
The benchmark tests below compare github.com/ygrebnov/errorc.With with fmt.Errorf:
import (
"errors"
"fmt"
"testing"
"github.com/ygrebnov/errorc"
)
func BenchmarkWith(b *testing.B) {
baseErr := errorc.New("benchmark error")
field1 := errorc.String("key1", "value1")
field2 := errorc.String("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 benchmark output shows that github.com/ygrebnov/errorc.With is significantly faster than fmt.Errorf in this scenario:
BenchmarkWith-8 53965288 21.81 ns/op
BenchmarkFmtErrorf-8 7401583 186.7 ns/op
Conclusion
github.com/ygrebnov/errorc.With provides a structured and efficient way to add context to errors in Go.
Like fmt.Errorf, it works for both sentinel and typed errors while preserving the original error identity for errors.Is and errors.As. Where it stands out is the field-oriented API:
errorc.String,errorc.Int,errorc.Bool, anderrorc.Errormake intent explicit- typed keys from
github.com/ygrebnov/keyshelp keep field names consistent - the benchmark results show a substantial performance advantage over
fmt.Errorffor this use case
If all you need is a one-off formatted message, fmt.Errorf is still perfectly fine. If you want repeated, structured, and reusable error context, errorc is a strong fit.
