Custom Errors in Go: A Practical Guide
Grace Collins
Solutions Engineer · Leapcell

Key Takeaways
- Custom errors in Go provide better context and type safety compared to plain errors.
- Struct-based custom errors allow for richer error messages and structured data like error codes.
- Use
errors.Is
anderrors.As
to handle and identify custom errors safely, especially when wrapping errors.
Go (Golang) provides a simple yet powerful way to handle errors, emphasizing explicit error checking. While the built-in error
type suffices for many scenarios, complex applications often require custom errors for better clarity, categorization, and debugging. In this guide, we'll explore how to define, create, and use custom errors effectively in Go.
Why Use Custom Errors?
Using custom errors gives you:
- Context: Explain what went wrong, where, and why.
- Type Safety: Let consumers of your code detect specific error types.
- Better Debugging: Carry structured information (e.g., codes, metadata).
Basic Custom Error with errors.New
The simplest way to define a custom error is:
import "errors" var ErrUnauthorized = errors.New("unauthorized access")
This creates a sentinel error value that can be checked using errors.Is
.
if err == ErrUnauthorized { // Handle unauthorized case }
But this approach is limited—you can't attach dynamic context.
Struct-Based Custom Errors
To include more context, define your own error type by implementing the error
interface:
type ValidationError struct { Field string Message string } func (e *ValidationError) Error() string { return "validation failed on '" + e.Field + "': " + e.Message }
Usage:
err := &ValidationError{ Field: "email", Message: "invalid format", } fmt.Println(err) // validation failed on 'email': invalid format
You can then use type assertions:
if vErr, ok := err.(*ValidationError); ok { fmt.Println("Field:", vErr.Field) }
Using errors.As
and errors.Is
When wrapping errors (e.g., with fmt.Errorf
or errors.Join
), you can still check for specific error types using errors.As
or errors.Is
.
return fmt.Errorf("processing failed: %w", err)
Later:
if errors.As(err, &ValidationError{}) { // Handle validation error }
Or:
if errors.Is(err, ErrUnauthorized) { // Handle known sentinel error }
Custom Errors with Error Codes
You can design custom error types that carry error codes or categories:
type AppError struct { Code int Message string } func (e *AppError) Error() string { return fmt.Sprintf("code %d: %s", e.Code, e.Message) } const ( ErrCodeInvalidInput = 1001 ErrCodeNotFound = 1002 )
Then:
return &AppError{ Code: ErrCodeInvalidInput, Message: "missing required field", }
And later:
if appErr, ok := err.(*AppError); ok && appErr.Code == ErrCodeNotFound { // Handle not found }
Wrapping with Stack Traces (Optional)
For advanced use cases, libraries like pkg/errors or Go 1.20’s errors.Join
allow you to retain full error stack traces and compose multiple errors.
Best Practices
- Use sentinel errors (
var ErrSomething = errors.New(...)
) when the error meaning is static and widely reused. - Define struct-based custom error types for contextual or typed error handling.
- Always wrap and propagate errors using
%w
to allow further introspection. - Avoid string comparison on errors; use
errors.Is
orerrors.As
.
Conclusion
Go’s philosophy encourages treating errors as first-class values. By creating custom errors, you can write more maintainable, debuggable, and robust code. Whether you're building APIs, middleware, or large applications, custom errors help communicate intent and context clearly across the stack.
FAQs
Custom error types allow you to carry structured data and offer better control over error handling.
Use errors.As
to safely extract the underlying error type.
errors.Is
checks against a specific error value; errors.As
extracts an error by type.
We are Leapcell, your top choice for hosting Go projects.
Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:
Multi-Language Support
- Develop with Node.js, Python, Go, or Rust.
Deploy unlimited projects for free
- pay only for usage — no requests, no charges.
Unbeatable Cost Efficiency
- Pay-as-you-go with no idle charges.
- Example: $25 supports 6.94M requests at a 60ms average response time.
Streamlined Developer Experience
- Intuitive UI for effortless setup.
- Fully automated CI/CD pipelines and GitOps integration.
- Real-time metrics and logging for actionable insights.
Effortless Scalability and High Performance
- Auto-scaling to handle high concurrency with ease.
- Zero operational overhead — just focus on building.
Explore more in the Documentation!
Follow us on X: @LeapcellHQ