The Subtle Art of Error Creation: Understanding errors.New and fmt.Errorf in Go
Emily Parker
Product Engineer · Leapcell

Go's approach to error handling is idiomatic: errors are just values. This simplicity belies a powerful and flexible system, but like any powerful tool, it requires understanding to wield effectively. Two fundamental functions for creating errors are errors.New
and fmt.Errorf
. While both return an error
interface, their intended use cases and underlying capabilities differ significantly. Understanding these differences is crucial for writing robust, maintainable, and debuggable Go applications.
errors.New
: The Simple, Opaque Error
The errors.New
function is the simplest way to create a basic error. It takes a single string argument and returns an error
value that implements the Error()
method by returning that string.
package main import ( "errors" "fmt" ) func validateInput(input string) error { if input == "" { return errors.New("input cannot be empty") } if len(input) > 20 { return errors.New("input exceeds max length of 20 characters") } return nil } func main() { if err := validateInput(""); err != nil { fmt.Println("Error:", err) } if err := validateInput("This is a very long input string that will definitely exceed the limit."); err != nil { fmt.Println("Error:", err) } if err := validateInput("valid input"); err == nil { fmt.Println("Input is valid.") } }
When to use errors.New
:
-
Sentinel Errors: Its primary use case is for defining "sentinel errors" – specific, pre-defined error values that callers can check against using direct equality (
==
). These errors convey a very specific type of failure.package main import ( "errors" "fmt" ) var ErrNotFound = errors.New("item not found") var ErrPermissionDenied = errors.New("permission denied") func findItem(id int) (string, error) { if id == 1001 { return "", ErrNotFound } if id == 2002 { return "", ErrPermissionDenied } return fmt.Sprintf("Item-%d", id), nil } func main() { if _, err := findItem(1001); err != nil { if errors.Is(err, ErrNotFound) { fmt.Println("Client error: The requested item was not found.") } else if errors.Is(err, ErrPermissionDenied) { fmt.Println("Authentication error: You do not have permission.") } else { fmt.Println("Unexpected error:", err) } } }
In this example,
ErrNotFound
andErrPermissionDenied
are distinct sentinel errors allowing precise error handling. Theerrors.Is
function, introduced in Go 1.13, is the canonical way to check for sentinel errors, correctly handling wrapped errors (more on this withfmt.Errorf
). -
Simple, Static Errors: When the error message is fixed and doesn't require any dynamic information.
Limitations of errors.New
:
- Lack of Context: It provides only a static string. You cannot include dynamic information specific to the error's occurrence (e.g., a specific value that caused the error, a file path that couldn't be opened, or the underlying error that led to this one).
- No Error Wrapping: You cannot wrap an underlying error with
errors.New
, meaning you lose the call stack and context of the original failure. This makes debugging significantly harder.
fmt.Errorf
: Dynamic, Contextual, and Wrappable Errors
The fmt.Errorf
function is a more powerful and versatile way to create errors. It behaves similarly to fmt.Sprintf
, allowing you to format an error string using various verbs. Critically, it supports the %w
verb for error wrapping.
package main import ( "errors" "fmt" "os" ) func readFile(filepath string) ([]byte, error) { data, err := os.ReadFile(filepath) if err != nil { // Here, we wrap the underlying error returned by os.ReadFile. // The %w verb signals that 'err' is the wrapped error. return nil, fmt.Errorf("failed to read file '%s': %w", filepath, err) } return data, nil } func processFile(filename string) error { _, err := readFile(filename) if err != nil { // We can wrap again, adding more context at this layer. return fmt.Errorf("error processing data from %s: %w", filename, err) } return nil } func main() { // Simulate a file not found error if err := processFile("non_existent_file.txt"); err != nil { fmt.Println("Main handler received error:", err) // Prints the full error chain. // Check if the original error was a "not found" error from os package var pathErr *os.PathError if errors.As(err, &pathErr) { fmt.Printf("Specifically, it was a PathError for path: %s, operation: %s\n", pathErr.Path, pathErr.Op) } // Check if a specific sentinel error is in the chain (even if deeply nested) if errors.Is(err, os.ErrNotExist) { fmt.Println("The ultimate cause was that the file did not exist.") } } // Another example demonstrating dynamic error messages userID := 123 dbErr := errors.New("database connection failed") err := fmt.Errorf("failed to fetch user %d data: %w", userID, dbErr) fmt.Println(err) }
When to use fmt.Errorf
:
-
Dynamic Error Messages: When the error message needs to include specific values from the context of the error (e.g., invalid input values, failed resource IDs).
fmt.Errorf("invalid age %d; must be positive", age)
-
Error Wrapping (
%w
): This is the most crucial differentiator. When a function encounters an error from a dependency (another function call, a library, the OS), it should typically wrap that error to provide additional context. Wrapping allows you to preserve the original error Chain, which is invaluable for debugging.errors.Is(err, target)
: Checks iferr
or any error in its chain istarget
. Ideal for checking against sentinel errors.errors.As(err, &target)
: Unwrapserr
until it finds an error that can be assigned totarget
. Useful for checking for specific error types (e.g.,*os.PathError
,*net.OpError
) and extracting information from them.errors.Unwrap(err)
: Returns the underlying error wrapped byerr
, ornil
iferr
does not wrap an error. This is primarily used byerrors.Is
anderrors.As
internally.
Benefits of Error Wrapping:
- Preserves Root Cause: You can trace the entire sequence of errors that led to the final failure.
- Contextual Information: Each layer in the call stack can add its own contextual information, enriching the error message without losing the original details.
- Programmatic Inspection: Higher-level code can still check for specific low-level errors (using
errors.Is
orerrors.As
) without needing to parse error strings. This allows for more robust and less brittle error handling logic.
Choosing Between errors.New
and fmt.Errorf
Here's a simplified decision tree:
- Do you need to check for this exact error value using
errors.Is()
?- Yes: Define a
var
usingerrors.New
(e.g.,var ErrInvalidInput = errors.New("invalid input")
).
- Yes: Define a
- Does the error message need to include dynamic information (e.g., specific values, IDs)?
- Yes: Use
fmt.Errorf
.
- Yes: Use
- Are you propagating an error from a lower-level function or dependency?
- Yes: Use
fmt.Errorf
with%w
to wrap the underlying error.
- Yes: Use
- Are you creating a completely new error that doesn't wrap any existing one, and the message is static?
- Yes: You could use
errors.New
for simplicity, butfmt.Errorf("static error message")
is often used out of habit, and it's perfectly fine. If there's any chance you might want to add dynamic content later or wrap it,fmt.Errorf
is safer for future refactors.
- Yes: You could use
General Guideline:
Lean towards fmt.Errorf
in most cases, especially when errors propagate through multiple layers of your application. The ability to add context and wrap underlying errors is a powerful debugging aid that far outweighs the slight overhead of fmt.Errorf
over errors.New
. Reserve errors.New
primarily for defining var
sentinel errors that are checked explicitly within your application logic.
Best Practices for Error Messages
- Start with Lowercase: Error messages are typically not capitalized and do not end with punctuation, as
fmt.Println
will add a newline and potentially other formatting.- Good:
return fmt.Errorf("failed to open file '%s'", filename)
- Bad:
return errors.New("Failed to open file.")
- Good:
- Be Concise but Informative: Provide enough information to understand what went wrong without being overly verbose.
- Avoid Redundancy: If wrapping an error, the outer error message should add new context, not just repeat the inner error.
- Good:
fmt.Errorf("failed to process request for user %d: %w", userID, err)
- Bad:
fmt.Errorf("error: %s", err)
(this simply repeats the underlying error without adding context)
- Good:
- Errors are for Machines, not Humans (Primarily): While human-readable, the primary consumer of an error object is often code further up the stack that makes decisions based on the error. Detailed messages are for debugging. User-facing messages should be generated separately, often by mapping internal errors to friendlier messages.
Conclusion
Both errors.New
and fmt.Errorf
are indispensable tools in Go's error handling. While errors.New
excels at defining simple, static sentinel errors for direct comparison, fmt.Errorf
with its formatting capabilities and, more importantly, its error wrapping (%w
) support, is the workhorse for creating rich, contextual, and debuggable error chains. By leveraging these functions appropriately and adhering to best practices, Go developers can build applications that are not only robust in their error handling but also significantly easier to debug and maintain. The subtle art of creating meaningful errors lies in providing just enough information for both machines to react and humans to comprehend the root cause of an issue.