JSON in Gin: c.JSON(), ShouldBindJSON, gin.H & Request Validation

Last updated:

Gin returns JSON from any handler with c.JSON(http.StatusOK, data) — passing a struct, gin.H map, or slice produces a Content-Type: application/json response using Go's encoding/json marshaler. gin.H is a type alias for map[string]any, making inline JSON responses concise without defining a struct. c.ShouldBindJSON(&target) reads and JSON-decodes the request body into a Go struct, returning an error if the body is malformed or missing required fields; c.BindJSON does the same but automatically writes a 400 response on error. Struct fields use binding:"required" and binding:"email" validation tags (powered by go-playground/validator) to enforce field presence and format rules. This guide covers c.JSON(), gin.H, c.ShouldBindJSON vs c.BindJSON, struct validation tags, JSON error responses, middleware for centralized error handling, and versioned API routing with RouterGroup.

c.JSON() and gin.H: Returning JSON Responses

Every Gin handler receives a *gin.Context — the c.JSON(statusCode, data) method marshals the data value to JSON, sets Content-Type: application/json, and writes the HTTP status code in a single call. The data argument accepts any JSON-serializable Go value: a named struct, a gin.H map, a slice, or even a primitive. gin.H is defined as type H map[string]any in the Gin package — it exists purely to shorten inline JSON construction and carries no additional behavior.

package main

import (
	"net/http"
	"github.com/gin-gonic/gin"
)

// ── Named struct response ──────────────────────────────────────
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email,omitempty"` // omitted when empty
}

// ── gin.H inline map response ──────────────────────────────────
func pingHandler(c *gin.Context) {
	// gin.H is map[string]any — concise for ad-hoc JSON shapes
	c.JSON(http.StatusOK, gin.H{
		"message": "pong",
		"version": "1.0",
	})
}

// ── Struct response ────────────────────────────────────────────
func getUserHandler(c *gin.Context) {
	user := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
	c.JSON(http.StatusOK, user)
	// Output: {"id":1,"name":"Alice","email":"alice@example.com"}
}

// ── Slice response (JSON array) ────────────────────────────────
func listUsersHandler(c *gin.Context) {
	// make([]User, 0) marshals to [] instead of null for nil slices
	users := make([]User, 0)
	users = append(users,
		User{ID: 1, Name: "Alice"},
		User{ID: 2, Name: "Bob"},
	)
	c.JSON(http.StatusOK, users)
	// Output: [{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]
}

// ── Paginated envelope ─────────────────────────────────────────
func listUsersPaginatedHandler(c *gin.Context) {
	users := []User{{ID: 1, Name: "Alice"}, {ID: 2, Name: "Bob"}}
	c.JSON(http.StatusOK, gin.H{
		"data":     users,
		"total":    150,
		"page":     1,
		"per_page": 20,
	})
}

// ── 201 Created with Location header ──────────────────────────
func createUserHandler(c *gin.Context) {
	user := User{ID: 42, Name: "Charlie", Email: "charlie@example.com"}
	c.Header("Location", "/api/v1/users/42")
	c.JSON(http.StatusCreated, user)
}

func main() {
	r := gin.Default()
	r.GET("/ping",       pingHandler)
	r.GET("/users",      listUsersHandler)
	r.GET("/users/page", listUsersPaginatedHandler)
	r.GET("/user",       getUserHandler)
	r.POST("/users",     createUserHandler)
	r.Run(":8080")
}

Use json:"field_name,omitempty" struct tags to control the JSON output shape. omitempty skips a field when it holds the zero value for its type — empty string, 0, false, nil pointer, or empty slice. Use json:"-" to unconditionally exclude a field (for example, password hashes). Gin respects all standard encoding/json struct tag conventions since it uses that package internally. After calling c.JSON(), do not write to the response again — Gin does not prevent double writes but the HTTP response will be malformed.

ShouldBindJSON vs BindJSON: Reading Request Bodies

Gin provides two methods to decode a JSON request body into a Go struct. c.ShouldBindJSON(&target) returns an error without touching the response — the handler decides how to respond on failure, making it the correct choice for any production handler. c.BindJSON(&target) calls c.AbortWithStatus(400) automatically on failure — convenient for prototypes, but it prevents custom error bodies. The request body is a stream and can only be read once; after either method consumes it, subsequent reads return EOF.

package main

import (
	"net/http"
	"github.com/gin-gonic/gin"
)

type CreateUserRequest struct {
	Name  string `json:"name"  binding:"required,min=1,max=100"`
	Email string `json:"email" binding:"required,email"`
	Age   int    `json:"age"   binding:"gte=0,lte=150"`
}

// ── c.ShouldBindJSON — preferred for production ────────────────
func createUserShouldBind(c *gin.Context) {
	var req CreateUserRequest

	// Decodes JSON body AND runs binding tag validation
	if err := c.ShouldBindJSON(&req); err != nil {
		// Full control over the error response
		c.JSON(http.StatusUnprocessableEntity, gin.H{
			"error": err.Error(),
		})
		return // stop handler — response is already written
	}

	// req is fully validated here
	c.JSON(http.StatusCreated, gin.H{
		"id":    99,
		"name":  req.Name,
		"email": req.Email,
	})
}

// ── c.BindJSON — auto-400 on failure (prototype use only) ─────
func createUserBindJSON(c *gin.Context) {
	var req CreateUserRequest

	// Calls c.AbortWithStatus(400) on error — no custom error body
	if err := c.BindJSON(&req); err != nil {
		return // BindJSON already wrote 400 and aborted
	}

	c.JSON(http.StatusCreated, req)
}

// ── Reading the body manually before ShouldBindJSON ───────────
// Use this if you need to inspect the raw bytes (e.g., for logging)
import (
	"bytes"
	"io"
)

func createUserWithLogging(c *gin.Context) {
	body, _ := io.ReadAll(c.Request.Body)
	// Restore the body so ShouldBindJSON can read it
	c.Request.Body = io.NopCloser(bytes.NewBuffer(body))

	var req CreateUserRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
		return
	}

	// body bytes available for logging
	_ = body
	c.JSON(http.StatusCreated, req)
}

func main() {
	r := gin.Default()
	r.POST("/users",     createUserShouldBind)
	r.POST("/users-v2",  createUserBindJSON)
	r.Run(":8080")
}

ShouldBindJSON returns two categories of errors: a *json.SyntaxError or *json.UnmarshalTypeError when the JSON body is malformed or the types do not match, and a validator.ValidationErrors when the JSON is valid but binding tags are violated. Check the error type with errors.As to give different HTTP status codes: 400 for syntax errors (the client sent unparseable JSON) and 422 for validation errors (the client sent valid JSON that failed business rules).

Struct Binding Tags for JSON Validation

Gin uses the go-playground/validator v10 library for field-level validation. Add a binding struct tag alongside the json tag — both are read when ShouldBindJSON processes the struct. Validation runs after JSON decoding succeeds, so type mismatches are caught first and binding violations are caught second.

package main

import (
	"errors"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/gin-gonic/gin/binding"
	"github.com/go-playground/validator/v10"
)

// ── Comprehensive binding tag reference ───────────────────────
type ProductRequest struct {
	Name        string   `json:"name"        binding:"required,min=1,max=200"`
	SKU         string   `json:"sku"         binding:"required,alphanum,len=8"`
	Email       string   `json:"email"       binding:"required,email"`
	Price       float64  `json:"price"       binding:"required,gt=0"`
	Quantity    int      `json:"quantity"    binding:"gte=0"`
	Status      string   `json:"status"      binding:"required,oneof=active inactive draft"`
	Tags        []string `json:"tags"        binding:"max=10,dive,min=1,max=50"`
	Website     string   `json:"website"     binding:"omitempty,url"` // optional, validated if present
	ReferenceID string   `json:"reference_id" binding:"omitempty,uuid4"`
}

// ── Handler with structured validation error response ─────────
type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

func createProductHandler(c *gin.Context) {
	var req ProductRequest

	if err := c.ShouldBindJSON(&req); err != nil {
		// Distinguish JSON syntax errors from validation errors
		var syntaxErr *json.SyntaxError
		if errors.As(err, &syntaxErr) {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON syntax"})
			return
		}

		// validator.ValidationErrors: one entry per failing field
		var validationErrs validator.ValidationErrors
		if errors.As(err, &validationErrs) {
			fieldErrors := make([]FieldError, 0, len(validationErrs))
			for _, ve := range validationErrs {
				fieldErrors = append(fieldErrors, FieldError{
					Field:   ve.Field(),  // struct field name
					Message: ve.Tag(),    // e.g. "required", "email", "min"
				})
			}
			c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": fieldErrors})
			return
		}

		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusCreated, gin.H{"sku": req.SKU, "name": req.Name})
}

// ── Register a custom validator ───────────────────────────────
// Run once at startup, before routes are registered
func registerCustomValidators() {
	if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
		// Custom tag "notblank" — rejects strings that are only whitespace
		v.RegisterValidation("notblank", func(fl validator.FieldLevel) bool {
			return strings.TrimSpace(fl.Field().String()) != ""
		})
	}
}

// Usage: binding:"required,notblank"

The dive tag instructs the validator to descend into slice or map elements and apply subsequent tags to each element. In the example above, binding:"max=10,dive,min=1,max=50" enforces that the Tags slice has at most 10 elements (max=10) and that each element is between 1 and 50 characters (dive,min=1,max=50). Access the underlying *validator.Validate instance via binding.Validator.Engine() to register custom tags, custom type functions, and struct-level validation with RegisterStructValidation.

JSON Error Responses and Centralized Error Handling

Returning consistent JSON error bodies across all handlers is a cross-cutting concern best handled by a dedicated error middleware. Gin provides c.Error(err) to attach errors to the context without writing a response; a middleware registered with r.Use() inspects these errors after c.Next() and writes the final JSON error response.

package main

import (
	"errors"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
)

// ── Custom error types ─────────────────────────────────────────
type NotFoundError struct{ Resource string }
func (e *NotFoundError) Error() string { return e.Resource + " not found" }

type ValidationError struct{ Fields []FieldError }
func (e *ValidationError) Error() string { return "validation failed" }

// ── Centralized error middleware ───────────────────────────────
func ErrorMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Next() // run the handler

		// No errors — nothing to do
		if len(c.Errors) == 0 {
			return
		}

		// Use the last error (handlers may attach multiple)
		err := c.Errors.Last().Err

		var notFound *NotFoundError
		var validErr *ValidationError

		switch {
		case errors.As(err, &notFound):
			c.JSON(http.StatusNotFound, gin.H{"error": notFound.Error()})
		case errors.As(err, &validErr):
			c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": validErr.Fields})
		default:
			c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
		}
	}
}

// ── Handler using c.Error() instead of c.JSON() for errors ────
func getUserHandler(c *gin.Context) {
	id := c.Param("id")
	user, err := db.GetUser(id)
	if err != nil {
		// Attach the error — middleware writes the response
		_ = c.Error(&NotFoundError{Resource: "user"})
		return
	}
	c.JSON(http.StatusOK, user)
}

// ── Direct JSON error pattern (without middleware) ─────────────
func deleteUserHandler(c *gin.Context) {
	id := c.Param("id")
	if err := db.DeleteUser(id); err != nil {
		// Return immediately after writing — prevents double writes
		c.JSON(http.StatusNotFound, gin.H{
			"error": "user not found",
			"id":    id,
		})
		return
	}
	c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}

func main() {
	r := gin.New()
	r.Use(gin.Recovery()) // recover from panics
	r.Use(ErrorMiddleware())

	r.GET("/users/:id", getUserHandler)
	r.DELETE("/users/:id", deleteUserHandler)
	r.Run(":8080")
}

For RFC 7807 Problem Details responses, define a ProblemDetails struct with fields Type, Title, Status, Detail, and Instance, then return it with the appropriate HTTP status code. This produces interoperable error JSON that clients can parse uniformly regardless of which endpoint generated the error.

Versioned JSON APIs with RouterGroup

Gin's RouterGroup groups routes under a shared URL prefix and allows scoped middleware. Call r.Group(prefix) to create a group — all routes registered on the group inherit the prefix and any middleware applied to the group. Nesting groups produces compound prefixes with no runtime cost: route resolution uses a radix tree built at startup.

package main

import (
	"net/http"
	"github.com/gin-gonic/gin"
)

// ── v1 handlers ────────────────────────────────────────────────
func listUsersV1(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"version": "v1",
		"users":   []gin.H{{"id": 1, "name": "Alice"}},
	})
}

func createUserV1(c *gin.Context) {
	type Req struct {
		Name string `json:"name" binding:"required"`
	}
	var req Req
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusCreated, gin.H{"id": 42, "name": req.Name})
}

// ── v2 handlers (new field in response) ───────────────────────
func listUsersV2(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"version": "v2",
		"data":    []gin.H{{"id": 1, "name": "Alice", "avatar_url": "https://..."}},
		"total":   1,
	})
}

// ── Auth middleware — applied per group ────────────────────────
func AuthRequired() gin.HandlerFunc {
	return func(c *gin.Context) {
		token := c.GetHeader("Authorization")
		if token == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"error": "authorization required",
			})
			return
		}
		c.Next()
	}
}

func main() {
	r := gin.Default()

	// ── Public routes (no auth) ────────────────────────────────
	public := r.Group("/api")
	{
		public.GET("/health", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"status": "ok"})
		})
	}

	// ── v1 API group with auth middleware ──────────────────────
	v1 := r.Group("/api/v1")
	v1.Use(AuthRequired())
	{
		v1.GET("/users",    listUsersV1)
		v1.POST("/users",   createUserV1)
		v1.GET("/users/:id", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"id": c.Param("id")})
		})
	}

	// ── v2 API group — same auth, new response shapes ──────────
	v2 := r.Group("/api/v2")
	v2.Use(AuthRequired())
	{
		v2.GET("/users", listUsersV2)
		// Additional nested group for admin-only endpoints
		admin := v2.Group("/admin")
		admin.Use(func(c *gin.Context) {
			// additional admin check
			c.Next()
		})
		admin.DELETE("/users/:id", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"deleted": c.Param("id")})
		})
	}

	r.Run(":8080")
}
// Routes produced:
// GET  /api/health
// GET  /api/v1/users        (AuthRequired)
// POST /api/v1/users        (AuthRequired)
// GET  /api/v1/users/:id    (AuthRequired)
// GET  /api/v2/users        (AuthRequired)
// DEL  /api/v2/admin/users/:id (AuthRequired + admin check)

Groups are defined entirely at startup — Gin compiles all routes into a radix tree before any request arrives. Adding or removing routes at runtime is not supported and would cause a data race. For feature-flag-driven routing, check the flag inside a single handler rather than registering conditional routes.

JSON Middleware: Logging, CORS, and Rate Limiting

Gin middleware functions have the signature func(c *gin.Context) and call c.Next() to pass control to the next middleware or handler. Middleware registered with r.Use() applies globally; middleware registered on a RouterGroup applies only to that group. Common JSON API middleware includes request logging, CORS headers, and rate limiting.

package main

import (
	"net/http"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
)

// ── JSON request/response logger ───────────────────────────────
func JSONLogger() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next()
		// Log after handler completes
		log.Printf(`{"method":"%s","path":"%s","status":%d,"latency_ms":%d}`,
			c.Request.Method,
			c.Request.URL.Path,
			c.Writer.Status(),
			time.Since(start).Milliseconds(),
		)
	}
}

// ── CORS middleware for JSON APIs ──────────────────────────────
func CORSMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
		c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")

		// Respond to preflight requests immediately
		if c.Request.Method == http.MethodOptions {
			c.AbortWithStatus(http.StatusNoContent)
			return
		}
		c.Next()
	}
}

// ── Simple in-memory rate limiter ─────────────────────────────
type RateLimiter struct {
	mu       sync.Mutex
	requests map[string][]time.Time
	limit    int           // max requests
	window   time.Duration // per window
}

func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
	return &RateLimiter{
		requests: make(map[string][]time.Time),
		limit:    limit,
		window:   window,
	}
}

func (rl *RateLimiter) Middleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		ip := c.ClientIP()
		rl.mu.Lock()
		now := time.Now()
		cutoff := now.Add(-rl.window)
		// Prune old entries
		recent := rl.requests[ip][:0]
		for _, t := range rl.requests[ip] {
			if t.After(cutoff) {
				recent = append(recent, t)
			}
		}
		rl.requests[ip] = recent
		if len(recent) >= rl.limit {
			rl.mu.Unlock()
			c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
				"error":       "rate limit exceeded",
				"retry_after": rl.window.Seconds(),
			})
			return
		}
		rl.requests[ip] = append(rl.requests[ip], now)
		rl.mu.Unlock()
		c.Next()
	}
}

func main() {
	r := gin.New()
	limiter := NewRateLimiter(100, time.Minute) // 100 req/min per IP

	r.Use(gin.Recovery())
	r.Use(JSONLogger())
	r.Use(CORSMiddleware())

	api := r.Group("/api/v1")
	api.Use(limiter.Middleware())
	{
		api.GET("/users", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"users": []string{"Alice", "Bob"}})
		})
	}

	r.Run(":8080")
}

For production CORS handling, use the community package github.com/gin-contrib/cors which handles all preflight and credential scenarios correctly. For rate limiting at scale, use a Redis-backed sliding window with github.com/go-redis/redis_rate rather than an in-memory map, which does not survive restarts and does not work across multiple server instances.

Performance: Swapping encoding/json for go-json or Sonic

Gin's c.JSON() uses Go's standard encoding/json package. Replacing it with a faster library requires a single import change — no API changes, no handler rewrites. Sonic (from ByteDance) uses SIMD instructions on amd64 and arm64 to achieve roughly 2× faster serialization than encoding/json. go-json is a pure-Go alternative with similar gains that works on all architectures.

// ── Install Sonic ──────────────────────────────────────────────
// go get github.com/bytedance/sonic

// ── main.go — add blank import at the top ─────────────────────
package main

import (
	// Blank import registers Sonic as Gin's JSON codec automatically
	// on supported architectures (amd64 / arm64 with Go 1.17+)
	_ "github.com/bytedance/sonic/encoder"

	"github.com/gin-gonic/gin"
)

// All c.JSON() calls now use Sonic internally — no other changes needed

// ── Alternative: go-json (pure Go, all architectures) ─────────
// go get github.com/goccy/go-json
// import _ "github.com/goccy/go-json"

// ── Manual Sonic usage for fine-grained control ───────────────
import (
	"net/http"
	sonicApi "github.com/bytedance/sonic"
	"github.com/gin-gonic/gin"
)

func fastResponseHandler(c *gin.Context) {
	type Response struct {
		ID   int    `json:"id"`
		Name string `json:"name"`
	}

	resp := Response{ID: 1, Name: "Alice"}

	// Marshal with Sonic directly
	data, err := sonicApi.Marshal(resp)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "marshal error"})
		return
	}

	c.Data(http.StatusOK, "application/json", data)
}

// ── Streaming large JSON arrays without buffering ──────────────
import (
	"encoding/json"
	"net/http"
	"github.com/gin-gonic/gin"
)

func streamUsersHandler(c *gin.Context) {
	users := fetchManyUsers() // []User with thousands of records

	c.Writer.Header().Set("Content-Type", "application/json")
	c.Writer.WriteHeader(http.StatusOK)

	// Write directly to the response writer — no intermediate []byte
	enc := json.NewEncoder(c.Writer)
	if err := enc.Encode(users); err != nil {
		// Cannot change status code once writing started
		_ = err
	}
}

// ── Release mode removes debug overhead ───────────────────────
func init() {
	// Call before gin.New() or gin.Default()
	gin.SetMode(gin.ReleaseMode)
}

func main() {
	r := gin.Default()
	r.GET("/users", streamUsersHandler)
	r.GET("/fast",  fastResponseHandler)
	r.Run(":8080")
}

Benchmarks on a typical 20-field struct show encoding/json at ~500 ns/op, go-json at ~280 ns/op, and Sonic at ~220 ns/op on amd64. The gain matters most at high throughput — at 10,000 requests per second, switching from encoding/json to Sonic saves roughly 2.8 seconds of JSON serialization CPU time per second. For endpoints that return small JSON payloads (under 1 KB), the difference is negligible compared to network I/O latency. Use gin.SetMode(gin.ReleaseMode) in production — debug mode logs every request to stdout, adding measurable overhead at high concurrency.

Key Terms

c.JSON()
The primary Gin method for writing a JSON response. It takes an HTTP status code and any JSON-serializable Go value, sets the Content-Type: application/json header, and writes the marshaled JSON body. Internally it calls encoding/json.Marshal (or the registered JSON codec) and writes the bytes to c.Writer. After c.JSON() is called, the response header is committed — calling it again logs a warning and overwrites the body. For streaming responses, use c.Stream() or json.NewEncoder(c.Writer) instead.
gin.H
A type alias defined as type H map[string]any in the Gin package. It exists to reduce boilerplate when constructing inline JSON responses — gin.H{"key": "value"} is equivalent to map[string]any{"key": "value"} but is shorter to type. gin.H carries no additional behavior and is serialized by the same encoding/json path as any other map. Use it for ad-hoc error bodies and simple acknowledgement responses; use named structs for persistent response shapes that appear in multiple handlers.
ShouldBindJSON
A *gin.Context method that reads the HTTP request body, JSON-decodes it into the provided pointer, and then runs go-playground/validator binding tag checks on all struct fields. It returns a non-nil error on either step failing, leaving the caller in full control of the error response. This is the preferred method for production handlers. The error type is either a json.SyntaxError (malformed JSON), json.UnmarshalTypeError (type mismatch), or validator.ValidationErrors (binding tag violation). Each case typically warrants a different HTTP status code.
RouterGroup
A Gin construct that groups routes under a common URL prefix with optional scoped middleware. Created by calling r.Group(prefix)on the engine or on an existing group. Routes registered on a group inherit the prefix and all middleware attached to that group. Groups can be nested — a group created from another group inherits the parent's prefix. The grouping is purely a registration-time concept; at runtime Gin resolves all routes through a single radix tree with no per-group overhead. Middleware applied to a group runs only for requests that match that group's routes.
binding tags
Struct field tags with the key binding that specify validation rules applied by go-playground/validator v10 after JSON decoding. Common values include required (non-zero value required), email (valid email format), min=N/max=N (length or numeric range), oneof=a b c (enumerated set), uuid4 (UUID v4 format), and url (valid URL). The omitempty modifier skips validation when the field is zero-valued. Tags are validated only when one of the binding methods (ShouldBindJSON, BindJSON, ShouldBind) is called — not on struct initialization.

FAQ

How do I return a JSON response in Gin?

Call c.JSON(statusCode, data) from any handler function. The first argument is an HTTP status code — use net/http constants like http.StatusOK (200) or http.StatusCreated (201). The second argument is any Go value: a struct, a gin.H map, a slice, or a primitive. Gin serializes it using encoding/json and writes a Content-Type: application/json header automatically. For example: c.JSON(http.StatusOK, gin.H{"message": "ok", "id": 42}). For struct responses, define the struct with json tags to control field names: json:"user_id" maps the field to "user_id" in the output. Use json:"-" to exclude a field entirely and json:",omitempty" to skip zero-value fields. Gin does not require you to call c.Abort() after c.JSON() — it sets the response and returns normally.

What is the difference between ShouldBindJSON and BindJSON in Gin?

Both methods decode the JSON request body into a Go struct, but they differ in error handling. c.ShouldBindJSON(&target) returns an error when decoding fails — the caller decides how to respond, making it the preferred choice for custom error handling. c.BindJSON(&target) calls c.AbortWithStatus(400) automatically when decoding fails, writing a 400 Bad Request and halting the handler chain. In practice, prefer c.ShouldBindJSON in every handler and return a 422 Unprocessable Entity with a JSON error body for validation failures, since 422 is more semantically correct than 400 for well-formed JSON that fails business validation. Use c.BindJSON only in quick prototypes where the automatic 400 behavior is acceptable. Both methods read the request body exactly once; reading it again requires capturing the bytes with io.ReadAll first and then restoring c.Request.Body.

How do I validate JSON request fields in Gin?

Add binding struct tags to your request struct fields. Gin uses the go-playground/validator library (v10) to enforce these rules after JSON decoding. binding:"required" ensures the field is present and non-zero. binding:"email" validates email format. binding:"min=3,max=100" enforces string length. binding:"gte=0,lte=150" enforces numeric range. binding:"oneof=admin user guest" limits to an enumerated set. After calling c.ShouldBindJSON(&req), check if the returned error is non-nil — if it is, the error is a validator.ValidationErrors value listing every field that failed. Return c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) to send a 422 response. For custom error messages per field, cast the error to validator.ValidationErrors and iterate over the slice — each entry has a Field() name and a Tag() identifying which rule failed.

How do I return a JSON error response in Gin?

The idiomatic pattern is c.JSON(statusCode, gin.H{"error": message}) for simple errors, or a structured error struct for machine-readable responses. For validation errors from ShouldBindJSON, use HTTP 422: c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}). For not-found cases, use 404: c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}). For authentication failures, use 401. For centralized error handling across all routes, register a global error middleware that calls c.Next(), then inspects c.Errors and writes the JSON error response. Handlers attach errors with c.Error(err) instead of writing the response directly. Always call return immediately after c.JSON() in error paths to prevent writing the success response as well.

What is gin.H and when should I use it?

gin.H is a type alias defined as type H map[string]any in the Gin source code. It is shorthand for map[string]interface{} (or map[string]any in Go 1.18+), allowing inline JSON construction without defining a dedicated struct. Use gin.H for ad-hoc response shapes — error messages, simple acknowledgements, and paginated envelopes: c.JSON(http.StatusOK, gin.H{"users": users, "total": 150}). For persistent response shapes used across multiple handlers or endpoints, define a named struct instead — structs produce stable JSON field names, support json tags for renaming and omitempty, and are easier to document and test. gin.H values are not type-checked at compile time, so a typo in a key name will not be caught until runtime.

How do I build a versioned JSON API with Gin?

Use Gin's RouterGroup to group routes under a common URL prefix. Call r.Group("/api/v1") to create a v1 group, then register routes on that group — all inherit the /api/v1 prefix. For example: v1 := r.Group("/api/v1"); v1.GET("/users", listUsersV1); v1.POST("/users", createUserV1). For a v2 group with different response shapes: v2 := r.Group("/api/v2"); v2.GET("/users", listUsersV2). Middleware can be scoped per group — v1.Use(AuthMiddleware()) applies the middleware only to /api/v1/* routes. A common pattern is to define handler functions in separate packages (handlers/v1, handlers/v2) and register them in groups, keeping version-specific logic isolated. Groups add zero runtime overhead — they are resolved at startup into a radix tree.

How do I make Gin JSON responses faster?

Gin uses Go's standard encoding/json by default. Swapping the JSON library to a faster alternative takes one import line. Import github.com/bytedance/sonic with a blank identifier: import _ "github.com/bytedance/sonic/encoder". Sonic is a high-performance JSON library from ByteDance that uses SIMD instructions on x86-64 and arm64, delivering approximately 2 times faster serialization than encoding/json on typical API payloads. For read-heavy paths, go-json (github.com/goccy/go-json) offers similar gains with a pure-Go implementation that works on all architectures. Use json.NewEncoder(c.Writer) to stream large JSON arrays directly to the response writer without buffering. Set gin.SetMode(gin.ReleaseMode) in production to skip debug logging, which reduces per-request overhead by roughly 5 to 10 percent.

How do I return a JSON list/array from a Gin handler?

Pass a Go slice as the second argument to c.JSON(). Gin serializes any slice to a JSON array: c.JSON(http.StatusOK, users) where users is []User returns [{...}, {...}]. To return an empty array instead of null when the slice is nil, initialize with make([]User, 0) rather than var users []User — a nil slice marshals to null in Go's encoding/json, while an empty slice marshals to []. For paginated responses, wrap the array in a gin.H envelope: c.JSON(http.StatusOK, gin.H{"data": users, "total": 150, "page": 1}). For streaming large arrays without buffering the entire slice in memory, use json.NewEncoder(c.Writer) — write directly to the HTTP response writer without allocating an intermediate []byte buffer, which matters when returning thousands of records.

Validate and format your Gin JSON responses

Paste any JSON response from your Gin API into Jsonic's validator to catch schema issues instantly.

Open JSON Validator

Further reading and primary sources