Go / Gin

Gin Custom Validation Error Messages Returning Raw Internal Errors

When Gin's binding validation errors are returned to clients as raw, unfriendly text straight from the underlying validator library, it's because the default error is being passed through directly rather than being caught and translated into your own custom, user-facing messages.

The Problem

A validation failure on a bound struct returns something technically accurate but not fit for an API response:

{
  "error": "Key: 'CreateUserInput.Email' Error:Field validation for 'Email' failed on the 'required' tag"
}

This exposes internal struct and field names directly, and isn't the kind of message you'd want a frontend to display to an end user, or even necessarily want exposed about your internal code structure at all.

Why It Happens

Gin's ShouldBindJSON and similar binding functions return the error produced directly by the underlying go-playground/validator library when validation fails. This error's default string format is designed for developer debugging, not end-user or API-client consumption, and using it directly in an API response happens when:

  • The binding error is passed straight into the JSON response without any transformation, as a quick way to surface validation feedback during initial development that never gets replaced with proper handling
  • There's no centralized error-handling logic distinguishing validation errors from other error types, so every error type ends up serialized the same generic way
  • The specific validation failure (which field failed, which rule) isn't being extracted from the error in a structured way, making it harder to build a clean, field-by-field error response even when the intent is there

The Fix

Type-assert the binding error to validator.ValidationErrors, which gives you structured access to each individual field failure rather than just a single opaque error string:

import "github.com/go-playground/validator/v10"

func bindAndValidate(c *gin.Context, input interface{}) bool {
    if err := c.ShouldBindJSON(input); err != nil {
        var validationErrors validator.ValidationErrors
        if errors.As(err, &validationErrors) {
            errorMap := make(map[string]string)
            for _, fieldErr := range validationErrors {
                errorMap[fieldErr.Field()] = formatFieldError(fieldErr)
            }
            c.JSON(http.StatusBadRequest, gin.H{"errors": errorMap})
            return false
        }
        // not a validation error - likely malformed JSON entirely
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
        return false
    }
    return true
}

Write a helper that translates each validation tag into a clean, human-readable message, rather than exposing the raw tag name:

func formatFieldError(fe validator.FieldError) string {
    switch fe.Tag() {
    case "required":
        return fmt.Sprintf("%s is required", fe.Field())
    case "email":
        return fmt.Sprintf("%s must be a valid email address", fe.Field())
    case "min":
        return fmt.Sprintf("%s must be at least %s characters", fe.Field(), fe.Param())
    default:
        return fmt.Sprintf("%s is invalid", fe.Field())
    }
}

This produces a clean, structured response like:

{
  "errors": {
    "Email": "Email is required",
    "Password": "Password must be at least 8 characters"
  }
}

For a more polished API, translate the Go struct field name into whatever naming convention your API generally uses (like snake_case for JSON), using the struct's own JSON tags rather than the raw Go field name, since API clients shouldn't need to know your internal Go naming conventions:

type CreateUserInput struct {
    Email string `json:"email" binding:"required,email"`
}

The validator library's fe.Field() returns the Go struct field name by default, not the JSON tag — mapping between the two (or using a library extension that supports this natively) closes this last gap between internal naming and what's actually exposed to API consumers.

Still Not Working?

If some validation errors still show raw internal text despite this handling, confirm you're checking for validator.ValidationErrors specifically, not a broader error type check that might not correctly identify every validation failure — for example, binding errors caused by malformed JSON syntax (not a validation rule failure at all, but a parsing failure before validation even runs) return a different underlying error type entirely, and need their own separate, equally clean handling rather than falling through to a raw, unhandled error message.