Do you know what's scarier than a panic in a Golang application? 🤔
An error that doesn't panic, doesn't log, doesn't show up - but silently fails in production.
These are like landmines scattered across your codebase, patiently waiting to blow up at the worst possible time - like when you're sipping on a cold beer 🍺, celebrating a successful release.
And then…
📞 "Hey, the feature isn't working. Logs look clean. What's going on?"
Oh no. That's your PM. On a weekend. With no error trace.
I've been there. And yes, I left my beer mid-sip. 😞
When I first started with Go, I didn't fully grasp that errors in Go are first-class citizens - passed around just like any other value. If you don't handle them, they don't scream. They whisper. And that whisper can turn into a production outage.
So this article is not just a rant. It's a survival guide - to help you prevent these ghost errors from ruining your weekend or your system's reputation. 🧯
Let's walk through:
- How to structure your error objects for better debugging
- How to distinguish between expected and unexpected errors
- And how to turn unhandled errors into actionable alerts for your team
Why Error Handling in Go Needs Your Full Attention
Not all errors are created equal. Some are edge cases you anticipate. Some are wild cards. But all of them need structure and context if you want to trace them later.
✅ Expected (or edge-case) errors:
- User requests a resource that doesn't exist
- Invalid query parameter
- Authorization failure
- Timeout or network delay
These are normal. They happen. You handle them, maybe return a 400
or 403
, and move on.
❌ Unexpected errors (the real troublemakers):
- DB connection fails
- Memory exhaustion
- Filesystem full
- Internal services misbehaving
- Panic due to nil pointer dereference
These should never happen - but if they do, you want to know immediately.
What Should Your Error Object Include?
Think of your error like a black box recorder. When things go wrong, it should tell you exactly what happened and how to reproduce it. Here's what it must include:
🧩 What happened?
A clear description: e.g. "invalid DB credentials", "failed to marshal JSON", or "timeout while calling payment gateway".
📍 Where did it happen?
Add stack traces or function names - Go makes this easy with tools like runtime.Caller() or libraries like pkg/errorsor xerrors.
⏱️ When did it happen?
Include request context: user ID, request ID, trace ID, timestamp - all of these help recreate the scenario and debug faster.
💬 What should the user see?
Don't leak internals! Instead, return friendly, actionable messages:
"Something went wrong. Please get in touch with support with Reference ID: xyz123."
🔍 How can the devs debug it further?
Include traceable metadata in logs or bug tracking systems so your team has breadcrumbs to follow. Reference IDS go a long way.
In the next section, we'll build a real REST API example and demonstrate how to put this into practice, gracefully handling, categorising, and propagating errors up to monitoring and alerting systems.
But before that, just remember this:
Handling errors isn't just about avoiding panics - it's about staying in control of your system even when it misbehaves.
Building a REST API in Go (Without Losing Your Mind Over Errors)
So, imagine we're building a REST API server - nothing fancy, just your good old layered Go backend:
- Router Layer: Parses the HTTP request and routes it appropriately.
- Service Layer: Decides whether to hit the DB, cache, or file system.
- Persistence Layer: Deals directly with the database.
Let's zoom into the persistence layer, where two common issues can ruin your day:
- The database isn't reachable (classic).
- The data just… doesn't exist (also classic).
For the second case, we create a custom error structure to wrap all the "not found" drama:
type ObjectNotFoundError struct {
ObjectType string `json:"objectType"`
ObjectIdentifiers map[string]any `json:"objectIdentifier"`
StackTrace string `json:"stackTrace"`
}
func (e *ObjectNotFoundError) Error() string {
return fmt.Sprintf("%s not found, filter properties: [%v]", e.ObjectType, e.ObjectIdentifiers)
}
func (e *ObjectNotFoundError) LogMessage() string {
b, _ := json.Marshal(e)
return string(b)
}
🧠 What's Cool About This?
- Error() gives a user-friendly summary.
- LogMessage() gives devs all the gritty details.
- ObjectType tells us what was missing.
- ObjectIdentifiers explains how we looked for it.
- StackTrace points us right to where the problem happened.
Now, let's see this in action in the persistence layer:
func (usrPer *UserPersistence) Get(ctx context.Context, id int) (*entities.User, error) {
sqlQuery := fmt.Sprintf("select * from users where id = %d", id)
data, err := usrPer.baseDb.Query(ctx, sqlQuery)
if err != nil {
return nil, err // --- 1️⃣
}
if id <= 0 {
return nil, &ObjectNotFoundError{. // --- 2️⃣
ObjectType: "User",
ObjectIdentifiers: map[string]any{"id": id},
StackTrace: string(debug.Stack()),
}
}
fmt.Println(data)
// else we will extract data from the data, for this we will hard code this return data
return &entities.User{
Id: id,
Name: fmt.Sprintf("user-%d", id),
CreatedDate: time.Now().Add(-10 * time.Hour),
}, nil
}
Breakdown
- 1️⃣ We forward the DB error if it happens.
- 2️⃣ We simulate an invalid user ID scenario (I know validation doesn't belong here, but bear with me - this is just for the demo).
A small disclaimer: I know this is not the right place for this validation, but I have only done this to explain how to gracefully propagate the error from the innermost layer.
Now Let's Head Over to the Service Layer 🚪
Here's where we catch and convert those deep, scary errors into something the client can actually digest.
type ServiceError struct {
InnerError error
HttpStatusCode int
HttpResponse *dto.ErrorResponse
}
func (e *ServiceError) Error() string {
return e.InnerError.Error()
}
func (e *ServiceError) GetHttpStatusCode() int {
return e.HttpStatusCode
}
func (e *ServiceError) GetErrorResponse() *dto.ErrorResponse {
return e.HttpResponse
}
We also define a handy interface:
type ILogMessageError interface {
error
LogMessage() string
}
Now, here's the service logic in action:
func (svc UserService) Get(ctx context.Context, id int) (*dto.User, error) {
fmt.Println("get in the svc")
userInfo, err := svc.userPersistenceObj.Get(ctx, id)
if err != nil {
if e, ok := err.(ILogMessageError); ok {
return nil, svc.WrapError(e)
}
logBug(err)
return nil, fmt.Errorf("can not retrive user: %d", id)
}
return (&dto.User{}).Init(userInfo), nil
}
🧪 What's Happening Here?
-
Interface Check: We see if the error implements
ILogMessageError
- if yes, it's a known error. - WrapError: Transforms the error into a format with HTTP status and message.
- logBug(): If it's an unexpected error, alert the engineering team. PagerDuty bells ring. People panic. You know the drill.
// handle all types of errors that we expect the persistence to return
// or we can have a builder to build the ServiceError, and also follow
// The open and closed principle, and single responsibility principal
func (svc UserService) WrapError(err ILogMessageError) *ServiceError {
var objNotFound *persistence.ObjectNotFoundError
var result *ServiceError
if errors.As(err, &objNotFound) {
result = &ServiceError{
InnerError: err,
HttpStatusCode: http.StatusNotFound,
HttpResponse: &dto.ErrorResponse{
ErrorCode: "usr-404",
ErrorDescription: err.Error(),
},
}
}
return result
}
func logBug(err error) {
// log a bug and page enginnering team about the error
log.Println("logged a bug for error", err, string(debug.Stack()))
}
Finally, The Router - The Gatekeeper to the Client 🌐
Here, we check whether the service-level error can be converted to an HTTP response.
type IHttpResponseError interface {
GetHttpStatusCode() int
GetErrorResponse() *dto.ErrorResponse
}
func (r UserRouter) Get(c *gin.Context) {
var userReq dto.User
if err := c.ShouldBindUri(&userReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userInfo, err := r.userServiceObj.Get(c.Request.Context(), userReq.Id)
if err != nil {
if e, ok := err.(IHttpResponseError); ok {
c.JSON(e.GetHttpStatusCode(), e.GetErrorResponse())
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, userInfo)
}
🧩 Why This Works
- Handled errors bubble up in a predictable format with HTTP codes.
- Unhandled errors raise the flag to devs and get returned as generic 500s.
- Clients stay informed. Devs stay sane. Production stays alive. 😅
🎯 Final Thoughts
And there you have it - an end-to-end error handling strategy that:
- Keeps your layers clean and decoupled
- Makes debugging a breeze with structured error logs
- Ensures your users never see a dreaded "500 Internal Server Error" without context
🔗 Full source code available on GitHub:
👉 architagr/The-Weekly-Golang-Journal - error-propagation
Stay Connected!
💡 Follow me on LinkedIn: Archit Agarwal
🎥 Subscribe to my YouTube: The Exception Handler
📬 Sign up for my newsletter: The Weekly Golang Journal
✍️ Follow me on Medium: @architagr
👨💻 Join my subreddit: r/GolangJournal
Top comments (0)