Web APIs with the Gin Framework
Reviewed & published by Brayan K
Gin is a fast, popular HTTP web framework for Go built on top of net/http. You'll create a router with gin.Default(), define routes, read path and query parameters, return JSON, bind and validate request bodies into structs, and compose middleware and route groups — far less boilerplate than the standard library alone.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Create a router with gin.Default()
- Register GET and POST routes
- Read path params (c.Param) and query params (c.Query)
- Return JSON with c.JSON and gin.H
- Bind and validate JSON with c.ShouldBindJSON
- Add middleware and group routes; compare with net/http
1️⃣ Hello, Gin: gin.Default() and a route
gin.Default() gives you a router (an *gin.Engine) with logging and panic-recovery already attached. Register a route with the verb method (r.GET), and reply with c.JSON. gin.H is just a handy map[string]any for JSON.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
// gin.Default() returns an *gin.Engine that ALREADY has the Logger
// and Recovery middleware attached. Use gin.New() for a bare engine.
r := gin.Default()
// r.GET registers a handler for GET /ping. The handler takes a
// *gin.Context, which wraps the request and response.
r.GET("/ping", func(c *gin.Context) {
// c.JSON serializes the map to JSON and sets the status + header.
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
// Run starts an HTTP server (defaults to :8080).
r.Run(":8080")
}2️⃣ Reading input: path and query params
Two common input sources: a path parameter declared with :id and read with c.Param("id"), and a query parameter from the URL read with c.Query("q") (or c.DefaultQuery for a fallback).
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// :id is a PATH parameter — read it with c.Param.
r.GET("/users/:id", func(c *gin.Context) {
id := c.Param("id")
// ?format=... is a QUERY parameter — c.DefaultQuery supplies a
// fallback when it is absent.
format := c.DefaultQuery("format", "short")
c.JSON(http.StatusOK, gin.H{
"id": id,
"format": format,
})
})
r.Run(":8080")
}3️⃣ Binding & validating JSON
For POST/PUT bodies, define a struct with json and binding tags, then call c.ShouldBindJSON(&in). It unmarshals and validates, returning an error you handle (usually a 400).
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// The binding tags add validation: "required" means the field must be
// present, and "email" / "gte" enforce format and minimum value.
type CreateUser struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Age int `json:"age" binding:"gte=0"`
}
func main() {
r := gin.Default()
r.POST("/users", func(c *gin.Context) {
var in CreateUser
// ShouldBindJSON unmarshals the body AND runs the validators,
// returning an error you handle yourself (it does not abort for you).
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"created": in.Name})
})
r.Run(":8080")
}4️⃣ Middleware & route groups
Middleware wraps your handlers to run shared logic. Register it with r.Use(...); call c.Next() to continue the chain, and code after it runs on the way back out. A route group bundles routes under a shared prefix.
package main
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// Middleware is a handler that can run code before and after the others.
func timing() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next() // run the remaining handlers
// code here runs on the way BACK OUT
c.Header("X-Elapsed", time.Since(start).String())
}
}
func main() {
r := gin.Default()
r.Use(timing()) // apply to every route registered after this
// A route GROUP shares a path prefix (and can share middleware).
api := r.Group("/api/v1")
{
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
api.GET("/version", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"version": "1.0.0"})
})
}
r.Run(":8080")
}🎯 Your Turn
Build a greeting endpoint. Fill in the blanks marked ___ to read the path param and reply with JSON.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// 🎯 YOUR TURN — fill in the blanks marked ___
// 1) Register a GET route for /hello/:name
r.GET("/hello/:name", func(c *gin.Context) {
// 2) Read the :name path parameter
name := c.___("name") // 👉 Param
// 3) Respond with JSON: {"greeting": "Hi <name>"}
c.___(http.StatusOK, gin.H{ // 👉 JSON
"greeting": "Hi " + name,
})
})
r.Run(":8080")
}
// ✅ Expected:
// curl localhost:8080/hello/Ada -> {"greeting":"Hi Ada"}Common Errors (and the fix)
❌ Using c.Query for a path segment — returns empty for /users/:id.
✅ Read declared :id segments with c.Param("id").
❌ Not returning after a binding error — the handler keeps going and writes twice.
✅ Always return after sending the 400.
❌ Forgetting the binding:"required" tag — missing fields silently become zero values.
✅ Tag required fields so ShouldBindJSON rejects incomplete bodies.
❌ Omitting c.Next() in middleware — the chain stalls and the handler never runs.
✅ Call c.Next() to continue (or c.Abort() to stop deliberately).
🧠 Quick Recall
/users/:id -> read id with which method?
c.Param("id"). Query-string values like ?q=go use c.Query("q") instead.
bind body + validate, handle the error yourself?
c.ShouldBindJSON(&obj). It returns an error without auto-aborting, so you reply with your own 400.
🎯 Mini-Challenge: An Echo Endpoint
Write a POST /echo route: define a Message struct with a required text field, bind it, and echo it back — or return a 400 on bad input.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// 🎯 MINI-CHALLENGE: a tiny "echo" endpoint with validation.
// 1. Define a struct Message with a field Text string,
// tagged json:"text" and binding:"required".
// 2. POST /echo: bind the body with c.ShouldBindJSON.
// On error, respond 400 with {"error": ...}.
// 3. On success, respond 200 with {"echo": <the text>}.
//
// ✅ Expected:
// POST {"text":"hi"} -> {"echo":"hi"}
// POST {} -> 400 with an error message
func main() {
r := gin.Default()
// your route here
r.Run(":8080")
}🎉 Lesson Complete!
- ✅ gin.Default() builds a router with Logger and Recovery middleware
- ✅ Register routes per verb (r.GET, r.POST) and reply with c.JSON
- ✅ c.Param reads path segments; c.Query reads query strings
- ✅ c.ShouldBindJSON unmarshals and validates with binding tags
- ✅ r.Use adds middleware; r.Group bundles routes under a prefix
Practice quiz
What does gin.Default() return?
- A *gin.Engine with the Logger and Recovery middleware already attached
- A database handle
- A bare router with no middleware
- A plain http.Server
Answer: A *gin.Engine with the Logger and Recovery middleware already attached. gin.Default() builds an Engine that already includes the Logger and Recovery middleware; gin.New() gives you a bare engine.
How do you register a handler for GET /ping?
- r.Route("GET", "/ping")
- r.Add("/ping")
- r.GET("/ping", handler)
- r.Handle("/ping")
Answer: r.GET("/ping", handler). Gin has a method per HTTP verb; r.GET(path, handler) registers a GET route.
Inside a handler, how do you read a path parameter from /users/:id?
- c.Query("id")
- c.Param("id")
- c.Path("id")
- c.Get("id")
Answer: c.Param("id"). c.Param reads named segments declared with a colon in the route, like :id.
Which call reads a URL query string value like ?q=go?
- c.Header("q")
- c.Param("q")
- c.Form("q")
- c.Query("q")
Answer: c.Query("q"). c.Query returns the value of a URL query parameter; c.DefaultQuery lets you supply a fallback.
How do you send a JSON response with status 200?
- c.JSON(http.StatusOK, obj)
- c.Send(obj)
- c.Render(obj)
- c.Write(200, obj)
Answer: c.JSON(http.StatusOK, obj). c.JSON(statusCode, value) serializes the value to JSON and sets the Content-Type header.
Which method binds the request body into a struct AND returns an error you can handle?
- c.BindJSON only
- c.ShouldBindJSON(&obj)
- c.ParseJSON(&obj)
- c.ReadJSON(&obj)
Answer: c.ShouldBindJSON(&obj). c.ShouldBindJSON unmarshals and validates the body, returning an error without aborting the request automatically.
How do you require a JSON field during binding?
- A json:"!" tag
- It is required by default
- Call c.Require()
- A struct tag of binding:"required"
Answer: A struct tag of binding:"required". Gin reads validator tags like binding:"required" on struct fields to enforce presence and other rules.
What is the right way to add middleware to every route on an engine?
- r.Before(mw)
- r.Apply(mw)
- r.Use(mw)
- r.Middleware(mw)
Answer: r.Use(mw). r.Use(mw) registers middleware that runs for all routes registered afterward.
Inside middleware, what advances to the next handler in the chain?
- return
- c.Next()
- c.Skip()
- c.Continue()
Answer: c.Next(). c.Next() runs the remaining handlers; code after it runs on the way back out, useful for timing and logging.
Compared with the standard net/http, Gin mainly adds...
- routing with params, JSON helpers, binding/validation, and middleware groups
- a different network stack
- support for only GET requests
- a built-in database
Answer: routing with params, JSON helpers, binding/validation, and middleware groups. Gin builds on net/http but layers on a fast router, JSON helpers, request binding/validation, and a middleware/group system.
Continue this course
- Previous: Database Access with database/sql
- Next: Building CLIs with Cobra — Create commands, subcommands and flags for production-grade command-line tools
Frequently asked questions
What is the difference between gin.Default() and gin.New()?
gin.New() returns a bare *gin.Engine with no middleware attached. gin.Default() returns an engine that already has two middlewares wired in: Logger, which logs each request, and Recovery, which catches panics in handlers and returns a 500 instead of crashing the server. Use Default for normal apps; use New when you want full control over which middleware runs.
How do I read path parameters versus query parameters?
Path parameters are named segments in the route declared with a colon, like /users/:id; read them with c.Param("id"). Query parameters come from the URL query string, like /search?q=go; read them with c.Query("q") or c.DefaultQuery("q", "fallback") to supply a default when the parameter is missing. They are different sources, so they use different methods.
How does binding and validating JSON work?
Define a struct with json tags for the field names and binding tags for the rules, such as binding:"required,email". Call c.ShouldBindJSON(&obj): it unmarshals the request body into the struct and runs the validators. If anything is missing or invalid it returns an error, which you handle yourself (commonly by replying 400). ShouldBindJSON does not abort the request for you, unlike c.BindJSON which writes a 400 automatically.
What is middleware and how does c.Next() fit in?
Middleware is a handler in the chain that can run logic before and after your route handler — for logging, auth, timing, CORS, and so on. Register it with r.Use(...). Inside middleware, calling c.Next() runs the rest of the chain; any code after c.Next() executes on the way back out, which is how you measure elapsed time or set a response header once the handler is done. Calling c.Abort() stops the chain.
What are route groups for?
A route group bundles routes under a shared path prefix and, optionally, shared middleware. r.Group("/api/v1") lets you register /api/v1/health and /api/v1/users without repeating the prefix, and you can attach group-specific middleware (like an auth check on /admin) by passing it to Group or calling Use on the returned group. It keeps related routes organized.
How is Gin different from the standard net/http?
Gin is built on top of net/http but adds ergonomics and speed: a fast radix-tree router with path parameters, per-verb methods (GET, POST, ...), JSON helpers like c.JSON, request binding and validation via struct tags, a middleware chain with c.Next/c.Abort, and route groups. With net/http alone you would hand-roll routing, JSON encoding, and validation. For small services net/http is fine; Gin saves boilerplate as the API grows.