Go Web Frameworks: net/http, Chi, Gin, Echo and Fiber · The Standard Library and the Big Four

Gin: The Most Popular Go Web Framework

Gin gives you a fast router, a Context with helpers for JSON, binding and validation of request bodies with struct tags, route groups and middleware. Build the books API the Gin way.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

The books API with Gin: a validated POST /books that rejects bad input with a helpful message, a versioned route group, and a middleware that adds a request ID.

The idea

Gin (github.com/gin-gonic/gin) is the most widely used Go web framework. Handlers receive a *gin.Context that wraps the request and response and adds helpers:

  • c.Param("id"), c.Query("page"), c.GetHeader(...),
  • c.JSON(status, value), c.AbortWithStatusJSON(...),
  • binding: c.ShouldBindJSON(&input) decodes the body and validates it using binding:"required,min=1" struct tags (powered by go-playground/validator),
  • c.Set / c.Get to pass values from middleware to handlers, c.Next() / c.Abort() to control the chain.

gin.Default() includes a logger and panic recovery; gin.New() starts empty so you choose middleware. In production set release mode (GIN_MODE=release or gin.SetMode(gin.ReleaseMode)).

A *gin.Engine is an http.Handler, so it runs with http.Server and tests with httptest.

The code

Binding, validation, groups and middleware in Gin
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"

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

type CreateBook struct {
	Title  string `json:"title" binding:"required,min=2"`
	Author string `json:"author" binding:"required"`
	Year   int    `json:"year" binding:"omitempty,gte=1450,lte=2100"`
}

func requestID() gin.HandlerFunc {
	n := 0
	return func(c *gin.Context) {
		n++
		c.Set("requestID", fmt.Sprintf("req-%d", n)) // available to every later handler
		c.Header("X-Request-ID", fmt.Sprintf("req-%d", n))
		c.Next()
	}
}

func main() {
	gin.SetMode(gin.ReleaseMode)
	r := gin.New()
	r.Use(gin.Recovery(), requestID())

	books := []CreateBook{{Title: "Learning Go", Author: "Jon Bodner", Year: 2024}}
	v1 := r.Group("/api/v1")
	{
		v1.GET("/books/:id", func(c *gin.Context) {
			if c.Param("id") != "1" {
				c.JSON(http.StatusNotFound, gin.H{"error": "book not found"})
				return
			}
			c.JSON(http.StatusOK, books[0])
		})
		v1.POST("/books", func(c *gin.Context) {
			var in CreateBook
			if err := c.ShouldBindJSON(&in); err != nil {
				c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
				return
			}
			books = append(books, in)
			c.JSON(http.StatusCreated, gin.H{"id": len(books), "request": c.GetString("requestID")})
		})
	}

	send := func(method, path, body string) {
		req := httptest.NewRequest(method, path, strings.NewReader(body))
		req.Header.Set("Content-Type", "application/json")
		rec := httptest.NewRecorder()
		r.ServeHTTP(rec, req)
		out, _ := io.ReadAll(rec.Body)
		fmt.Printf("%s %s -> %d %s %s\n", method, path, rec.Code, rec.Header().Get("X-Request-ID"), out)
	}

	send("GET", "/api/v1/books/1", "")
	send("POST", "/api/v1/books", `{"title":"Concurrency in Go","author":"Katherine Cox-Buday","year":2017}`)
	send("POST", "/api/v1/books", `{"title":"X"}`)
	send("POST", "/api/v1/books", `{"title":"Old Book","author":"Anon","year":1200}`)
}

Output

GET /api/v1/books/1 -> 200 req-1 {"title":"Learning Go","author":"Jon Bodner","year":2024}
POST /api/v1/books -> 201 req-2 {"id":2,"request":"req-2"}
POST /api/v1/books -> 422 req-3 {"error":"Key: 'CreateBook.Title' Error:Field validation for 'Title' failed on the 'min' tag\nKey: 'CreateBook.Author' Error:Field validation for 'Author' failed on the 'required' tag"}
POST /api/v1/books -> 422 req-4 {"error":"Key: 'CreateBook.Year' Error:Field validation for 'Year' failed on the 'gte' tag"}

Run this on your computer — see “Get Started” for setup.

Validation errors come from the validator in a developer-oriented format (Key: 'CreateBook.Author' Error:Field validation for 'Author' failed on the 'required' tag). Real APIs translate them into friendly per-field messages; the error-handling lesson shows how.

Common mistakes

  • c.Bind… vs c.ShouldBind…. BindJSON writes a 400 response itself on failure; ShouldBindJSON returns the error so you decide the response. Prefer ShouldBind….
  • Forgetting return after c.JSON for an error. Gin doesn't stop your function; Abort only stops later middleware.
  • Using gin.Default() blindly in production. Its logger writes plain text to stdout; many teams swap it for structured logging.
  • Leaking the raw validator message to users. Map it to clear field errors.
  • Copying *gin.Context into goroutines. Use c.Copy() if work continues after the handler returns.

Key points

  • Gin: fast router + *gin.Context helpers + binding/validation via struct tags.
  • ShouldBindJSON + binding:"required,…" validates input in one call.
  • Groups (r.Group("/api/v1")) and middleware (c.Set, c.Next, c.Abort).
  • The engine is an http.Handler: run it with http.Server, test it with httptest.

Further reading: Gin documentation, gin on pkg.go.dev.

Practice questions

What's the difference between c.BindJSON and c.ShouldBindJSON in Gin?

Answer

Both decode and validate the body. BindJSON automatically aborts with a 400 response on failure; ShouldBindJSON only returns the error, letting you choose the status code and error format. ShouldBind is usually preferred.

How do Gin middleware pass data to handlers?

Answer

With c.Set(key, value) in the middleware and c.Get / c.GetString(key) in later handlers. c.Next() runs the rest of the chain; c.Abort() stops later handlers from running.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?