What Makes a Good Error?
Written by Dan Redington on August 25, 2025
curl -i https://api.example.com/v1/locations/123
HTTP/1.1 200 OK
Content-Type: application/json
{
"error": "Invalid user"
}
Have you ever had to handle an error like this? Not only is it contradictory between HTTP Status Code and content, but I'll bet your client doesn't automatically handle this as an error. What does a user have to do with the location I'm requesting? What is invalid about said user?
Errors like this are more common than you’d think, and that’s the problem. They’re often treated as an afterthought, but sloppy error payloads can:
- Confuse developers about how your API works
- Add friction for client implementations
- Mislead users with vague or contradictory messages
- Even trigger fatal crashes if handled incorrectly
Worse, once a bad error format is in the wild, fixing it later can be painful if clients already depend on the broken behavior. The good news: with just a bit of forethought, you can design error payloads that are consistent, informative, and resilient. By the end of this post, you’ll have a practical template for what makes a good error.
Error != Status Code
Let’s get the obvious one out of the way: an error is not the same thing as an HTTP status code.
In the earlier example, the API returned 200 OK while clearly delivering an error in the body. That’s a recipe for confusion. HTTP status codes should work with your error payload, not replace it.
Think of status codes as broad categories that describe the deliverability of a request, while your error payload explains the details. Used together, they give both humans and machines the right context.
Error Status Codes
When it comes to errors, the 4xx range is usually where the action is:
- 401 Unauthorized - “We don’t believe you are who you say you are.”
- 403 Forbidden - “We believe you, but you don’t have permission here.”
- 418 I’m a teapot - “When coffee just isn't doing it for you” (Yes, it’s real.)
Familiarize yourself with what already exists—you’ll be surprised how often an existing code fits your use case.
But what if the request is valid, the user is authenticated, authorized, happily sipping tea… and then you hit a business rule that fails? That’s where 422 Unprocessable Content shines. It’s a solid default for business logic errors.
Actionable
Even if you get structure and status codes right, most errors still fall short in one crucial way: they don’t mean anything.
Too often, errors are a developer’s shorthand for “I don’t have time for this edge case” or “I’m not even sure what this means.” But meaningless errors aren’t harmless; they actively hurt your product. They:
- Frustrate users with dead ends
- Increase customer support tickets
- Hide real, unexpected errors under vague noise
An error should always be actionable. That means it tells someone, whether a system or a user, what went wrong and what to do next.
Let's look at some examples:
# ❌ Bad - vague, no idea what to do about this. Trying later will never resolve
"Invalid request" or "Please try again later"
# 🟡 Better - User is aware of the target of the issue, but not what's wrong
"Email is invalid"
# ✅ Good - Expectations are clear and actionable
"Email must be a valid address in the format [email protected]"
# 🎉 Best - No guesswork, very clear what needs to happen
"Email is missing '@'. Please provide a valid address like [email protected]"
Of course, crafting errors at the “best” end of this spectrum takes effort. If you have the time and want to provide a great experience, it’s worth it. But here’s the thing: the difference between a bad error and a good one is often just a few extra words.
And sometimes, when I slow down to write a truly actionable error, I realize I don’t need the error at all, I can just handle or automate the edge case. The best error is the one the user never sees.
Interface Stability
One of the biggest frustrations with a poorly designed API is the lack of stability. As a client developer, you want to know that when you implement an error handler, it will behave the same way across the board. Without that confidence, every error feels like a gamble.
This goes beyond consistent payloads. It also comes down to the type of information you return. Consider this error:
HTTP/1.1 422 OK
Content-Type: application/json
{
"error": {
"message": "You must provide a picture of your receipt before you can submit your claim."
}
}
At first glance, this looks great. The status code makes sense, the payload is structured, and the message is actionable. But now imagine your product manager asks:
When a user sees this error, can we show a button that takes them to their receipts?
Seems straightforward, right? Maybe. You could switch on the status code and decide when to show the button. But what if this endpoint has multiple possible 422 errors? The typical fallback is to match against the error message itself. That’s brittle. Messages can change. They can be localized. Suddenly, your error handling is fragile and hard to maintain.
The real issue is that with the current payload, there’s no reliable way to distinguish this error from any other. The fix is simple: give every error a stable identifier.
HTTP/1.1 422 OK
Content-Type: application/json
{
"error": {
"message": "Debe proporcionar una foto de su recibo antes de poder enviar su reclamación.",
"code": "receipt_required"
}
}
Now the client can switch on error.code rather than parsing human text. The message can change, be localized, or even rewritten entirely, while the client’s behavior remains stable.
Use a Template
If you ever find yourself hand-crafting an error payload inside a controller or service, that’s a red flag. Your API is begging for a standardized error response.
The good news: it’s not hard to set up. A simple shared method can give you consistency and keep you from reinventing the wheel every time you need to return an error. Here’s a common pattern I use:
def render_error_message(message, code, status = :unprocessable_entity)
render json: { error: { message:, code: } }, status:
end
Oftentimes, I will add an additional wrapper method that simplifies the implementation even further with:
def render_error(code, status = :unprocessable_entity)
render_error_message(localized_error_message(code), code, status)
end
With this approach, your error responses are:
- Consistent - every error follows the same shape
- Flexible - you can localize or change messages without touching clients
- Lightweight - easy to call, no boilerplate
Conclusion
Errors are inevitable, but bad error design is not. A thoughtful error payload makes life easier for everyone: developers integrating with your API, support teams trying to debug issues, and ultimately the users who depend on your product.
By pairing clear status codes with actionable messages, giving errors stable identifiers, and returning them through a consistent template, you set a foundation that scales. Your clients can trust your API, your team can move faster without breaking integrations, and your users spend less time stuck and more time getting value.
The best part? None of this requires heroic effort. A little bit of discipline early on saves countless hours of confusion and rework down the road. Good errors don’t just prevent problems, they build confidence.
Your idea is ready. We're here to build it.
From prototype to production, we help you get there faster. We know how to balance speed, quality, and budget so your product starts delivering value right away.