go
69 lines · 9 steps
Turning Gin validation errors into JSON
A Gin handler binds and validates a request, then maps validator failures into structured, per-field error messages.
Explained by
highlit
1package api
2
3import (
4 "errors"
5 "net/http"
6
7 "github.com/gin-gonic/gin"
8 "github.com/go-playground/validator/v10"
9)
10
11type CreateUserRequest struct {
12 Email string `json:"email" binding:"required,email"`
13 Username string `json:"username" binding:"required,min=3,max=32,alphanum"`
14 Password string `json:"password" binding:"required,min=8"`
15 Age int `json:"age" binding:"required,gte=13,lte=120"`
16}
17
18type FieldError struct {
19 Field string `json:"field"`
20 Message string `json:"message"`
21}
22
23func (h *Handler) CreateUser(c *gin.Context) {
24 var req CreateUserRequest
25 if err := c.ShouldBindJSON(&req); err != nil {
26 var verrs validator.ValidationErrors
27 if errors.As(err, &verrs) {
28 fields := make([]FieldError, 0, len(verrs))
29 for _, fe := range verrs {
30 fields = append(fields, FieldError{
31 Field: fe.Field(),
32 Message: messageFor(fe),
33 })
34 }
35 c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": fields})
36 return
37 }
38 c.JSON(http.StatusBadRequest, gin.H{"error": "malformed JSON body"})
39 return
40 }
41
42 user, err := h.users.Create(c.Request.Context(), req.Email, req.Username, req.Password, req.Age)
43 if err != nil {
44 c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create user"})
45 return
46 }
47 c.JSON(http.StatusCreated, user)
48}
49
50func messageFor(fe validator.FieldError) string {
51 switch fe.Tag() {
52 case "required":
53 return "this field is required"
54 case "email":
55 return "must be a valid email address"
56 case "min":
57 return "must be at least " + fe.Param() + " characters"
58 case "max":
59 return "must be at most " + fe.Param() + " characters"
60 case "alphanum":
61 return "must contain only letters and numbers"
62 case "gte":
63 return "must be " + fe.Param() + " or greater"
64 case "lte":
65 return "must be " + fe.Param() + " or less"
66 default:
67 return "is invalid"
68 }
69}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Struct binding tags let you declare validation rules right next to the fields they govern.
- 2Distinguishing malformed JSON from validation failures lets you return the right HTTP status for each.
- 3Translating raw validator tags into human messages keeps your API responses clean and client-friendly.
Related explainers
javascript
const express = require('express'); const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware'); const router = express.Router();
Building an API gateway proxy in Express
reverse-proxy
middleware
api-gateway
Intermediate
6 steps
rust
use axum::{ extract::{FromRequestParts, Query}, http::{request::Parts, StatusCode}, };
Building a custom Axum extractor for query filters
extractors
query-parsing
enums
Intermediate
8 steps
go
package middleware import ( "net/http"
Per-IP write rate limiting in Gin
rate-limiting
middleware
concurrency
Intermediate
8 steps
python
import asyncio from dataclasses import dataclass import aiohttp
Bounded-concurrency HTTP fetching with asyncio
async
concurrency
semaphore
Intermediate
8 steps
javascript
import { useCallback, useRef, useState } from 'react'; export function ColorPicker({ initialColor = '#3b82f6', onCommit }) { const [committed, setCommitted] = useState(initialColor);
A validated color picker in React
uncontrolled-inputs
refs
validation
Intermediate
7 steps
go
package middleware import ( "net/http"
A content-type guard middleware in Gin
middleware
closures
http
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/turning-gin-validation-errors-into-json-explained-go-6264/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.