How I write Go Errors in 2025

Acknowledging the “I hate if err != nil” crowd

While I find it only mildly annoying to type the revered statement, I’ve researched ways to eliminate it while designing a new programming language. Once we get past this aspect, we can explore better ways to handle errors in Go. See my repo here for the code.

Things have gotten better over the years

Beyond the if err != nil situation, there have been discussions about improving error handling from the early days of Go. Dave Cheney gave at least two talks that led to the pkg/errors package, which eventually saw some of its ideas integrated into the Go standard library. In his “Don’t just check errors, handle them gracefully” talk, Cheney emphasized that errors should be handled only once, at the application’s edge, and that errors should be wrapped with context as they propagate up the call stack. In following the “only handle errors you can do something about” principles, the pkg/errors contained methods to todays errors standard library methods:

  • errors.Is
  • errors.As
  • errors.Wrap & %w

These functions allow me to safely check error types by traversing a tree of possible error types or by checking and converting error types. The errors.Wrap function enables the creation of errors that work with both errors.Is and errors.As methods, by building the trees they can navigate.

This progress in the standard library addresses most of Dave’s original requests, though it doesn’t tackle “stack traces” and “errors as constants.” In his “Constant errors” article, Cheney highlighted the problem that error variables in Go aren’t immutable and can be modified by any package that imports them, potentially leading to bugs that are difficult to diagnose. He also noted that error values should be opaque to all but their creator, and advocated for errors as constants to prevent modification. I personally don’t need stack traces since I typically log errors and handle them at the earliest point rather than passing them around extensively. So my current handling of errors, doesn’t address stack traces.

Introducing the ErrorStr[T any] string type

I create a standard struct that lives in an internal package in my libraries or binaries:

# <pkg>/internal/error.go
package e

type ErrorStr[T any] string       // errors are strings
func (e ErrorStr[T]) Error() string { ... }

type ErrorString = ErrorStr[any]  // errors are `any` string

First, I make the type alias: ErrorString = ErrorStr[any]. This allows me to define errors as constant strings. The ErrorStr type implements the Error() method, making it a valid error type. This enables an errors file containing all library errors:

# errors.go
const ErrWhoopsSomethingBad ErrorString = "whoops, you did a boo boo" 

This approach prevents users from modifying the error, which rarely happens but could be an issue as Dave mentioned in his “Constant errors” article. He warned that “any package that imports your package can change the value of the error,” creating bugs that are “difficult to diagnose and have the potential to cause security problems.” By using constants, I protect against this. But there’s more to consider.

What about variable data?

Go errors are famously flexible with error messaging, because it’s just a string under the hood. I often see errors created at the call site with errors.New with variable messaging:

# some.random.file.go
x := thing()
if whoops() {
	return errors.New("the woops in {x-thing} was not cool")
}

The {x-thing} is specific to this situation and can’t easily be added to a constant, since I may not know the x thing until runtime. So I’ve added formatting to ErrorStr[T any] with the method F(...v):

# errors.go
const ErrWhoopsSomethingBad ErrorString = "the woops in %s was not cool" 
# some.random.file.go
x := thing()
if whoops() {
	return ErrWhoopsSomethingBad.F(x)
}

What about nils?

I might want to wrap another error with context:

# errors.go
const ErrWhoops ErrorString = "whoops context for: %w" 

This allows me to pass along an error or let it be nil:

# some.random.file.go
func changeTheThing(incoming error) error {
	x, err := funcThatCanError()
	return errors.Join(incoming, ErrWhoops.F(err))
}

While this example is contrived, it shows how I’d wrap an error with context when available. To avoid another if err != nil check, I’ve built in a nilifier that can pass along a nil error if necessary:

# some.random.file.go
func changeTheThing(incoming error) error {
	x, err := funcThatCanError()
	return errors.Join(incoming, ErrWhoops.F(e.IsNilable(err)))
}

Where e. references the errors package containing my class. If err is nil, it passes nil rather than creating an error with "whoops context for: <nil>".

Making it even better: Structured data

I’ve added a KV(<key string>,<value>,...) method to the ErrorStr[T any] type. This lets me add context as structured data at the call site. The key must be a string, but the value can be any type:

return ErrWhoopsSomethingBad.KV("blog",true).F(x)

When viewing the error, a JSON string with the structured key/value pairs can be extracted:

the woops in that other place was not cool {"blog":true}

This allows me to see contextual data alongside the error message, enhancing what Dave originally discussed.

A potential footgun

I realize I’ve introduced a way to create panics by allowing naked strings and values for KV pairs. For type safety, I can use the e.Key() and e.Value() methods, which provide compile-time warnings for mismatches. I’ve kept the simpler approach for ease of use and speed, though it does create a footgun in my error handling.

Can I nest errors with structured data?

Yes. When I pass errors up the chain, any structured data already added will be logically incorporated into the error before it’s processed.

Why the [T any] generic part?

This is new - until recently, I didn’t use generics in my error handling code. Dave mentioned handling errors based on functionality in his “Don’t just check errors, handle them gracefully” talk, arguing that error handling should be based on the error’s behavior rather than its type or value. He gave the example of a Temporary() interface method to check on a custom error, suggesting we should “assert that an error implements a particular behavior, rather than asserting it is a specific type.”

This functionality was difficult for me to achieve because passing errors along meant changing the error type. Formatting the string naturally changes the underlying type, making it hard to add methods like Temporary() to an existing type without recreating the full type or embedding it in a struct (which would reduce string flexibility).

Using generics allows me to create purpose-built behavior types at the call site and check them with errors.Is, creating a behavior chain:

# errors.go
type temp struct{}
ErrorTemp = e.ErrorStr[temp]

const ErrWhoops ErrorTemp = "whoops context for: %w" 

Now I can check for the temp behavior:

# some.random.file.go
_, err := funcCanError()
if errors.Is(err, temp) {
	// continue after I correct the thing
} else if err != nil {
	return err
}

Tradeoffs compared to standard handling

Slight performance hit

There is a small performance hit because I’m using an internal ErrorFun function to relay the formatting data, adding an extra function call beyond the initial F() call. In my practice, this isn’t enough to significantly slow down an application, and it keeps my code clean and immutable, making error handling easier to reason about.

Conclusion

I handle errors by adding this class (available on GitHub). I define all library errors as constants, allowing those that need variable data to use formatting directives. I add KV contextual structured data when beneficial and can include behavioral types to guide error management.

I copy this package to different libraries rather than making it a separate shared library, as my error handling needs vary between projects. For example, sometimes I’ve added HTTP error codes to determine how to handle an error based on status codes, which isn’t necessary for every library, so I don’t make error handling generic, it keeps evolving. There may be changes for 2026 yet.