Back to blog
February 14, 2026

Go errors are not for validation

Stop stuffing expected problems into error returns. There's a better way.

BY MANYROWS TEAM ·
engineering go

A Go error means something unexpected happened. The database is down. The network timed out. A nil pointer slipped through. You log it, return 500, and investigate.

But “email is already taken” is not unexpected. Neither is “password too short” or “plan limit reached.” These are expected problems. Your code anticipated them. You wrote the if statement for them. They’re not errors, they’re outcomes.

So why do most Go codebases treat them the same way?

The usual pattern

You’ve seen this a hundred times:

func CreateAccount(email string) (*Account, error) {
    if email == "" {
        return nil, fmt.Errorf("email is required")
    }
    // ...
    if duplicate {
        return nil, fmt.Errorf("email already exists")
    }
    return acc, nil
}

The caller gets an error. Now what? String matching?

acc, err := CreateAccount(email)
if err != nil {
    if strings.Contains(err.Error(), "already exists") {
        // return 409
    } else if strings.Contains(err.Error(), "required") {
        // return 400
    } else {
        // return 500? maybe?
    }
}

This is fragile. You’re parsing English sentences to decide what HTTP status code to return. If someone changes the error message, your handler breaks silently.

Sentinel errors don’t fix it

The next attempt is usually sentinel errors:

var ErrEmailRequired = errors.New("email is required")
var ErrEmailDuplicate = errors.New("email already exists")
var ErrPasswordTooShort = errors.New("password too short")
var ErrPlanLimitReached = errors.New("plan limit reached")
// ... 40 more of these

Now you have a wall of var Err... declarations and the handler does errors.Is() chains. Better than string matching, but you still can’t carry metadata. Which field failed? What was the limit? What should the frontend display?

And you’ve lost the distinction between “email is taken” (expected, tell the user) and “database connection failed” (unexpected, page the on-call).

Separate the expected from the unexpected

Here’s what we do instead. Two return channels with different meanings:

func CreateAccount(ctx context.Context, email string) (*Account, *validation.Result, error) {
  • *validation.Result, expected problems. Validation failures, business rules, constraint violations. The user or developer can fix these.
  • error, unexpected problems. Bugs, infrastructure failures, things you didn’t anticipate. These get logged and investigated.

The handler never has to guess:

acc, vr, err := repo.CreateAccount(ctx, email)
if err != nil {
    // unexpected, log it, return 500
    log.Err(err).Msg("could not create account")
    writeError(w, "internal_error", 500)
    return
}
if !vr.Ok() {
    // expected, tell the user what's wrong
    writeValidationError(w, vr)
    return
}

No string matching. No sentinel errors. No guessing.

The Result type

A Result is just a list of issues. Zero issues means everything is fine.

type Issue struct {
    Field      string `json:"field,omitempty"`
    Code       string `json:"code"`
    Message    string `json:"message,omitempty"`
    DevMessage string `json:"devMessage,omitempty"`
    Extra      any    `json:"extra,omitempty"`
}

type Result struct {
    Issues []Issue `json:"issues,omitempty"`
    Status int     `json:"-"`
}

Each issue has:

  • Code: machine-readable, never changes: "required", "duplicate", "plan_limit_reached"
  • Field: optional, for input validation: "email", "password"
  • Message: user-facing, safe to display in UI
  • DevMessage: developer-facing, for debugging
  • Extra: arbitrary metadata: {"maxLength": 50}, {"currentCount": 10, "limit": 10}

Status carries a suggested HTTP status code but never gets serialized. The API layer uses it, but it doesn’t leak into the response.

Building results

For field-level validation:

validation.NewIssue("email", "required", "email is required")

For non-field problems, chain what you need:

validation.New("plan_limit").
    WithMessage("You've reached the app limit for your plan").
    WithExtra(map[string]int{"current": 10, "limit": 10}).
    WithStatus(http.StatusForbidden)

For duplicates in the repo layer:

if validation.LooksLikeUniqueViolation(err) {
    vr := validation.NewIssue("email", "duplicate", "email is already in use")
    vr.Status = http.StatusConflict
    return vr, nil  // not an error, an expected outcome
}
return nil, err  // this IS an error, something unexpected

The repo returns the constraint violation as a Result (expected) but the actual database failure as an error (unexpected). The distinction is clear at every layer.

What this fixes

The frontend gets structured data. Instead of parsing {"error": "email already exists"}, it gets:

{
    "error": "validation",
    "issues": [{
        "field": "email",
        "code": "duplicate",
        "message": "email is already in use"
    }]
}

The frontend can highlight the right field, show the right message, and the code never changes even if you reword the message.

The handler never guesses. err != nil is always “log this and return 500.” !vr.Ok() is always “tell the user.” No ambiguity.

Go’s compiler catches missing cases. When you change a function from returning error to returning (*Result, error), every caller that doesn’t handle the new return value fails to compile. Try getting that safety from sentinel errors.

It scales to business rules. This isn’t just for “email is required.” Plan limits, feature gates, rate limits, permission checks, anything where the outcome is expected and the user or developer can act on it.

The rule

If the caller can fix it, return a Result. If nobody expected it, return an error.

That’s it. Simple as that.


More from the blog