The Complete Gin Framework Guide
Everything you need to build production REST APIs with Gin. You know Go — now master the framework.
01 Why Gin?
Gin is the most popular Go web framework. ~81K GitHub stars, used by ~48% of Go developers. It's a thin, fast wrapper around net/http with routing, middleware, binding, and validation built in.
- Fast — zero-allocation router (httprouter), 40x faster than Martini
- Minimal — learn it in a day, not a week
- Middleware — plug-and-play: logging, recovery, CORS, auth
- Binding — auto-parse JSON/XML/Form into structs
- Validation — struct tag validation out of the box
- Battle-tested — Uber, Airbnb, Twitch, Dropbox, Cloudflare
| Framework | GitHub API (203 routes) | Allocs |
|---|---|---|
| Gin | ~10,000 ns/op | 0 |
| Echo | ~11,000 ns/op | 0 |
| Chi | ~90,000 ns/op | varies |
| Gorilla Mux | ~1,000,000 ns/op | many |
02 Setup & Hello World
# Create project
mkdir gin-api && cd gin-api # create project folder & enter it
go mod init github.com/yourname/gin-api # initialise Go module
# Install Gin
go get -u github.com/gin-gonic/gin # download & add Gin dependency
package main // every executable starts with package main
import "github.com/gin-gonic/gin" // import the Gin framework
func main() {
r := gin.Default() // includes Logger + Recovery middleware
r.GET("/ping", func(c *gin.Context) { // register GET /ping route
c.JSON(200, gin.H{"message": "pong"}) // respond with JSON 200
})
r.Run(":8080") // listen on 0.0.0.0:8080
}
go run main.go # compile & run the server
# curl http://localhost:8080/ping
# {"message":"pong"}
gin.Default() = gin.New() + Logger + Recovery middleware. Use gin.New() when you want full control over which middleware to attach.
gin.SetMode(gin.DebugMode)— default, verbose logginggin.SetMode(gin.ReleaseMode)— production, minimal logginggin.SetMode(gin.TestMode)— for tests- Or set env var:
GIN_MODE=release
03 Routing
All HTTP Methods
r.GET("/users", getUsers) // read list of users
r.POST("/users", createUser) // create a new user
r.PUT("/users/:id", updateUser) // replace full user record
r.PATCH("/users/:id", patchUser) // update partial user fields
r.DELETE("/users/:id", deleteUser) // remove a user
r.HEAD("/users", headUsers) // headers only, no body
r.OPTIONS("/users", optionsUsers) // preflight / CORS check
// Match ANY method
r.Any("/anything", handler) // handles GET/POST/PUT/… all at once
// Match specific methods
r.Match([]string{"GET", "POST"}, "/mixed", handler) // only listed methods
Route Groups
// Group routes by prefix
api := r.Group("/api") // all routes here start with /api
{
v1 := api.Group("/v1") // nested group: /api/v1
{
v1.GET("/users", getUsers) // GET /api/v1/users
v1.POST("/users", createUser) // POST /api/v1/users
}
v2 := api.Group("/v2") // second version group: /api/v2
{
v2.GET("/users", getUsersV2) // GET /api/v2/users
}
}
// Group with middleware
authorized := r.Group("/admin") // group all admin routes
authorized.Use(AuthRequired()) // every route here needs auth
{
authorized.GET("/dashboard", dashboard) // protected dashboard
authorized.POST("/settings", updateSettings) // protected settings
}
Static Files
// Serve a directory
r.Static("/assets", "./public") // maps URL prefix to disk folder
// Serve a single file
r.StaticFile("/favicon.ico", "./resources/favicon.ico") // one file at fixed path
// Embed filesystem (Go 1.16+)
r.StaticFS("/static", http.FS(embeddedFiles)) // serve embedded FS at /static
04 Params & Query Strings
// Path parameters :name
// GET /users/42
r.GET("/users/:id", func(c *gin.Context) { // :id is a named path param
id := c.Param("id") // "42"
c.JSON(200, gin.H{"id": id}) // send id back as JSON
})
// Wildcard *path (catches everything)
// GET /files/css/style.css → path = "/css/style.css"
r.GET("/files/*path", func(c *gin.Context) { // *path captures everything after /files
path := c.Param("path") // "/css/style.css"
c.String(200, "Path: %s", path) // respond with plain text
})
// Query strings
// GET /search?q=golang&page=2
r.GET("/search", func(c *gin.Context) { // reads ?q=&page= from URL
q := c.Query("q") // "golang"
page := c.DefaultQuery("page", "1") // "2" (or "1" if missing)
limit := c.Query("limit") // "" (empty if missing)
c.JSON(200, gin.H{"q": q, "page": page, "limit": limit}) // return all params
})
// Query array
// GET /filter?tags=go&tags=web&tags=api
r.GET("/filter", func(c *gin.Context) { // reads repeated ?tags= values
tags := c.QueryArray("tags") // ["go", "web", "api"]
c.JSON(200, gin.H{"tags": tags}) // return the slice
})
// Query map
// GET /info?user[name]=yatin&user[age]=25
r.GET("/info", func(c *gin.Context) { // reads ?user[key]=val pairs
user := c.QueryMap("user") // map["name":"yatin", "age":"25"]
c.JSON(200, user) // return the map as JSON
})
05 Request Binding
// Binding = auto-parse request body into a struct
type CreateUserReq struct { // struct Gin binds request JSON into
Name string `json:"name" binding:"required"` // name must be present
Email string `json:"email" binding:"required,email"` // must be valid email
Age int `json:"age" binding:"gte=0,lte=130"` // 0–130 inclusive
}
// ShouldBindJSON — returns error (you handle it)
r.POST("/users", func(c *gin.Context) { // POST /users accepts JSON body
var req CreateUserReq // declare empty struct to fill
if err := c.ShouldBindJSON(&req); err != nil { // parse JSON into req
c.JSON(400, gin.H{"error": err.Error()}) // bad request if invalid
return // stop — don't continue to success path
}
c.JSON(201, gin.H{"user": req}) // 201 Created with new resource
})
// Bind from different sources
c.ShouldBindJSON(&req) // JSON body
c.ShouldBindXML(&req) // XML body
c.ShouldBind(&req) // auto-detect (JSON/XML/Form)
c.ShouldBindQuery(&req) // query string only
c.ShouldBindUri(&req) // path params only
// Bind URI params
type UserUri struct { // maps :id in path to struct field
ID int `uri:"id" binding:"required"` // :id auto-converted to int
}
r.GET("/users/:id", func(c *gin.Context) { // GET /users/42
var uri UserUri // empty struct to receive path param
if err := c.ShouldBindUri(&uri); err != nil { // bind :id → uri.ID
c.JSON(400, gin.H{"error": err.Error()}) // invalid id format
return
}
c.JSON(200, gin.H{"id": uri.ID}) // respond with the parsed id
})
// Bind form data (POST form / multipart)
type LoginForm struct { // maps HTML form fields to struct
Username string `form:"username" binding:"required"` // must be present
Password string `form:"password" binding:"required"` // must be present
}
r.POST("/login", func(c *gin.Context) { // handle login form submission
var form LoginForm // empty struct to receive form data
if err := c.ShouldBind(&form); err != nil { // auto-detects form/JSON
c.JSON(400, gin.H{"error": err.Error()}) // return validation error
return
}
})
ShouldBind* returns the error — you decide what to do. Bind* (without Should) auto-responds with 400 and aborts. Always use ShouldBind* for control.
06 Validation
// Gin uses go-playground/validator under the hood
type Product struct { // validator tags on every field
Name string `json:"name" binding:"required,min=2,max=100"` // 2–100 chars
Price float64 `json:"price" binding:"required,gt=0"` // must be > 0
Category string `json:"category" binding:"required,oneof=electronics clothing food"` // allowlist
SKU string `json:"sku" binding:"required,alphanum,len=8"` // exactly 8 chars
Email string `json:"email" binding:"omitempty,email"` // optional email
URL string `json:"url" binding:"omitempty,url"` // optional URL
IP string `json:"ip" binding:"omitempty,ip"` // optional IP addr
}
Common Validation Tags
| Tag | What it does |
|---|---|
required | Field must be present and non-zero |
email | Must be valid email |
url | Must be valid URL |
min=5 | Minimum length (string) or value (number) |
max=100 | Maximum length or value |
len=8 | Exact length |
gt=0 | Greater than |
gte=0 | Greater than or equal |
oneof=a b c | Must be one of the listed values |
alphanum | Alphanumeric only |
omitempty | Skip validation if field is empty |
eqfield=Password | Must equal another field |
Custom Validator
import (
"github.com/gin-gonic/gin/binding" // to access Gin's validator
"github.com/go-playground/validator/v10" // underlying validator lib
)
// Custom validation function
var noSpaces validator.Func = func(fl validator.FieldLevel) bool { // returns true = valid
value := fl.Field().String() // get the field's string value
return !strings.Contains(value, " ") // fail if any space found
}
func main() {
r := gin.Default() // create router with default middleware
// Register custom validator
if v, ok := binding.Validator.Engine().(*validator.Validate); ok { // type-assert to concrete type
v.RegisterValidation("nospaces", noSpaces) // give it a tag name
}
}
// Use it
type User struct { // struct using the custom validator
Username string `json:"username" binding:"required,nospaces"` // "nospaces" runs our func
}
07 Response Types
// JSON (most common)
c.JSON(200, gin.H{"message": "ok"}) // send status + JSON body
c.JSON(200, user) // struct with json tags
// gin.H is just map[string]any — a shortcut
type H map[string]any // defined in gin; alias for convenience
// IndentedJSON (pretty, for debugging)
c.IndentedJSON(200, user) // human-readable JSON, slower
// String
c.String(200, "Hello %s", name) // printf-style plain text reply
// XML
c.XML(200, user) // serialise struct to XML
// YAML
c.YAML(200, user) // serialise struct to YAML
// Redirect
c.Redirect(301, "https://google.com") // 301 = permanent redirect
// File
c.File("./files/report.pdf") // serve file inline in browser
c.FileAttachment("./files/report.pdf", "download.pdf") // with custom filename
// Data (raw bytes)
c.Data(200, "application/octet-stream", rawBytes) // binary download
// Set headers
c.Header("X-Custom", "value") // add a custom response header
// Set status without body
c.Status(204) // 204 No Content — no body sent
// Set cookie
c.SetCookie("token", "abc", 3600, "/", "", false, true) // httpOnly=true
val, _ := c.Cookie("token") // read cookie value from request
08 Gin Context Deep Dive
*gin.Context is the most important type in Gin. It carries the request, response writer, middleware chain, and key-value store. One context per request.
func handler(c *gin.Context) { // c carries the whole request
// === REQUEST INFO ===
c.Request.Method // "GET"
c.Request.URL.Path // "/users/42"
c.Request.Header.Get("Auth") // header value
c.ClientIP() // client IP (respects proxies)
c.ContentType() // "application/json"
c.FullPath() // "/users/:id" (route pattern)
// === PARAMS ===
c.Param("id") // path param
c.Query("page") // query string
c.DefaultQuery("page", "1") // with default
c.PostForm("username") // form field
// === READ BODY ===
c.ShouldBindJSON(&req) // parse JSON body
c.GetRawData() // raw []byte body
// === WRITE RESPONSE ===
c.JSON(200, data) // JSON response
c.String(200, "hi") // plain text
c.Header("X-Key", "val") // set header
// === KEY-VALUE STORE (pass data between middleware) ===
c.Set("userID", 42) // store
id, exists := c.Get("userID") // retrieve
uid := c.MustGet("userID") // retrieve or PANIC
s := c.GetString("role") // typed getters
n := c.GetInt("count") // returns int, zero if missing
// === FLOW CONTROL ===
c.Next() // call next handler/middleware
c.Abort() // stop chain (no more handlers)
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"}) // abort + send JSON at once
c.IsAborted() // check if aborted
}
09 Middleware Basics
Middleware = a function that runs before/after your handler. They form a chain: Request → MW1 → MW2 → Handler → MW2 → MW1 → Response.
// Global middleware — applies to ALL routes
r.Use(gin.Logger()) // log every incoming request
r.Use(gin.Recovery()) // catch panics, return 500
// Group middleware — applies to group only
admin := r.Group("/admin") // create a /admin sub-router
admin.Use(AuthRequired()) // every /admin route needs auth
// Per-route middleware
r.GET("/secret", AuthRequired(), RateLimit(), secretHandler) // inline middleware chain
// Built-in middleware
gin.Logger() // logs every request
gin.Recovery() // recovers from panics, returns 500
10 Custom Middleware
// Logging middleware
func RequestLogger() gin.HandlerFunc { // returns a HandlerFunc
return func(c *gin.Context) { // this runs on every request
start := time.Now() // record when request arrived
path := c.Request.URL.Path // save path before handlers may change it
c.Next() // process request
// After handler runs:
latency := time.Since(start) // total time taken
status := c.Writer.Status() // HTTP status written by handler
log.Printf("[%d] %s %s %v", status, c.Request.Method, path, latency) // log it
}
}
// Auth middleware
func AuthRequired() gin.HandlerFunc { // gate: stops unauthenticated requests
return func(c *gin.Context) {
token := c.GetHeader("Authorization") // read Authorization header
if token == "" { // no token supplied at all
c.AbortWithStatusJSON(401, gin.H{"error": "missing token"})
return
}
userID, err := validateToken(token) // verify signature & expiry
if err != nil { // token was tampered or expired
c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
return
}
// Pass data to handlers
c.Set("userID", userID) // handlers can read this later
c.Next() // token OK — continue to actual handler
}
}
// Rate limiter middleware
func RateLimit(maxRequests int, window time.Duration) gin.HandlerFunc { // configurable limiter
limiter := rate.NewLimiter(rate.Every(window/time.Duration(maxRequests)), maxRequests) // token bucket
return func(c *gin.Context) {
if !limiter.Allow() { // bucket empty — reject request
c.AbortWithStatusJSON(429, gin.H{"error": "too many requests"}) // 429 Too Many Requests
return
}
c.Next() // within limit — continue
}
}
// Request ID middleware
func RequestID() gin.HandlerFunc { // unique ID per request for tracing
return func(c *gin.Context) {
id := uuid.New().String() // generate a fresh UUID
c.Set("requestID", id) // available to all handlers
c.Header("X-Request-ID", id) // echo ID back to client
c.Next() // continue chain
}
}
11 Authentication (JWT)
// go get github.com/golang-jwt/jwt/v5
import "github.com/golang-jwt/jwt/v5" // JWT parsing & signing lib
var jwtSecret = []byte(os.Getenv("JWT_SECRET")) // secret from env, not hardcoded
// Generate token
func GenerateToken(userID int) (string, error) { // creates a signed JWT
claims := jwt.MapClaims{ // payload embedded in the token
"user_id": userID, // our custom claim
"exp": time.Now().Add(24 * time.Hour).Unix(), // expire in 24h
"iat": time.Now().Unix(), // issued-at timestamp
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) // HMAC-SHA256
return token.SignedString(jwtSecret) // sign and return string
}
// JWT middleware
func JWTAuth() gin.HandlerFunc { // validates Bearer token on every call
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") // "Bearer <token>"
if !strings.HasPrefix(authHeader, "Bearer ") { // wrong format
c.AbortWithStatusJSON(401, gin.H{"error": "missing bearer token"})
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ") // strip "Bearer " prefix
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) { // parse & verify
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { // guard against alg:none
return nil, fmt.Errorf("unexpected signing method") // reject non-HMAC
}
return jwtSecret, nil // provide key for verification
})
if err != nil || !token.Valid { // expired, wrong sig, etc.
c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
return
}
claims := token.Claims.(jwt.MapClaims) // type-assert to access fields
c.Set("userID", int(claims["user_id"].(float64))) // JWT numbers are float64
c.Next() // token valid — proceed
}
}
// Login handler
r.POST("/login", func(c *gin.Context) { // POST /login with JSON body
var req struct { // anonymous inline struct for the request
Email string `json:"email" binding:"required"` // required email
Password string `json:"password" binding:"required"` // required password
}
if err := c.ShouldBindJSON(&req); err != nil { // validate input
c.JSON(400, gin.H{"error": err.Error()}); return
}
// verify password against DB...
token, _ := GenerateToken(user.ID) // create JWT for authenticated user
c.JSON(200, gin.H{"token": token}) // return token to client
})
// Protected routes
protected := r.Group("/api") // group that requires JWT
protected.Use(JWTAuth()) // every route below needs valid token
{
protected.GET("/me", func(c *gin.Context) { // GET /api/me — current user
userID := c.GetInt("userID") // read from context set by JWTAuth
c.JSON(200, gin.H{"user_id": userID}) // return it
})
}
12 CORS
// go get github.com/gin-contrib/cors
import "github.com/gin-contrib/cors" // official Gin CORS package
// Quick setup
r.Use(cors.Default()) // allows all origins
// Production config
r.Use(cors.New(cors.Config{ // fine-grained CORS rules
AllowOrigins: []string{"https://yoursite.com"}, // whitelist your domain
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, // permitted HTTP verbs
AllowHeaders: []string{"Authorization", "Content-Type"}, // permitted request headers
ExposeHeaders: []string{"Content-Length"}, // headers browser can read
AllowCredentials: true, // allow cookies cross-origin
MaxAge: 12 * time.Hour, // cache preflight for 12h
}))
13 Error Handling
// Standard JSON error response
type APIError struct { // consistent error shape across API
Code int `json:"code"` // HTTP status code
Message string `json:"message"` // human-readable reason
}
// Error middleware — collect errors, respond once
func ErrorHandler() gin.HandlerFunc { // centralised error response
return func(c *gin.Context) {
c.Next() // run all handlers first
// Check for errors after handlers run
if len(c.Errors) > 0 { // any errors recorded?
err := c.Errors.Last() // grab most recent error
switch e := err.Err.(type) { // type-switch on error kind
case *APIError: // our custom API error
c.JSON(e.Code, e) // use the code it carries
default: // unknown error — hide details
c.JSON(500, APIError{500, "internal error"})
}
}
}
}
// In handlers — use c.Error() to collect errors
func getUser(c *gin.Context) { // handler using ErrorHandler middleware
user, err := userService.FindByID(c.Param("id")) // fetch from service layer
if err != nil { // something went wrong
c.Error(&APIError{404, "user not found"}) // push error onto chain
return
}
c.JSON(200, user) // found — send it back
}
// Simpler pattern — just respond directly
func getUser2(c *gin.Context) { // inline error handling, no middleware
user, err := userService.FindByID(c.Param("id")) // call service
if errors.Is(err, ErrNotFound) { // sentinel error check
c.JSON(404, gin.H{"error": "not found"}); return
}
if err != nil { // any other unexpected error
c.JSON(500, gin.H{"error": "internal error"}); return
}
c.JSON(200, user) // success path
}
// Custom recovery (catch panics with context)
r.Use(gin.CustomRecovery(func(c *gin.Context, err any) { // called when handler panics
c.JSON(500, gin.H{"error": "server panic", "detail": fmt.Sprint(err)}) // safe 500 response
}))
14 Database (GORM)
// go get gorm.io/gorm
// go get gorm.io/driver/postgres
import (
"gorm.io/gorm" // ORM core
"gorm.io/driver/postgres" // Postgres dialect
)
// Model
type User struct { // GORM model mapped to "users" table
gorm.Model // ID, CreatedAt, UpdatedAt, DeletedAt
Name string `json:"name"` // plain text name
Email string `json:"email" gorm:"uniqueIndex"` // DB index ensures uniqueness
}
// Connect
func setupDB() *gorm.DB { // returns a ready-to-use DB handle
dsn := "host=localhost user=postgres password=pass dbname=myapp port=5432 sslmode=disable" // connection string
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) // open connection
if err != nil { log.Fatal(err) } // crash fast if DB unreachable
db.AutoMigrate(&User{}) // create/update tables automatically
return db // share this single handle across handlers
}
// Inject DB into handlers via middleware
func main() {
db := setupDB() // connect once at startup
r := gin.Default() // router with logger + recovery
r.GET("/users", func(c *gin.Context) { // list all users
var users []User // slice to hold results
db.Find(&users) // SELECT * FROM users
c.JSON(200, users) // return the slice
})
r.POST("/users", func(c *gin.Context) { // create a new user
var user User // empty struct to bind JSON into
if err := c.ShouldBindJSON(&user); err != nil { // validate request
c.JSON(400, gin.H{"error": err.Error()}); return
}
if result := db.Create(&user); result.Error != nil { // INSERT INTO users
c.JSON(500, gin.H{"error": "could not create"}); return
}
c.JSON(201, user) // return created user with its new ID
})
r.GET("/users/:id", func(c *gin.Context) { // get single user by ID
var user User // destination struct
if err := db.First(&user, c.Param("id")).Error; err != nil { // SELECT … LIMIT 1
c.JSON(404, gin.H{"error": "not found"}); return
}
c.JSON(200, user) // send the user record
})
r.Run(":8080") // start listening
}
15 File Upload/Download
// Single file upload
r.POST("/upload", func(c *gin.Context) { // accepts multipart form data
file, err := c.FormFile("file") // get the "file" form field
if err != nil { // no file uploaded or wrong field name
c.JSON(400, gin.H{"error": err.Error()}); return
}
dst := fmt.Sprintf("./uploads/%s", file.Filename) // build destination path
c.SaveUploadedFile(file, dst) // write to disk
c.JSON(200, gin.H{"filename": file.Filename, "size": file.Size}) // confirm upload
})
// Multiple files
r.POST("/uploads", func(c *gin.Context) { // batch upload endpoint
form, _ := c.MultipartForm() // parse the whole multipart form
files := form.File["files"] // get all files under key "files"
for _, file := range files { // iterate each uploaded file
c.SaveUploadedFile(file, "./uploads/"+file.Filename) // save each one
}
c.JSON(200, gin.H{"count": len(files)}) // tell client how many saved
})
// Set upload limit
r.MaxMultipartMemory = 8 << 20 // 8 MB
// Download
r.GET("/download/:name", func(c *gin.Context) { // serve file as attachment
name := c.Param("name") // filename from path param
c.FileAttachment("./uploads/"+name, name) // triggers browser download
})
16 HTML Templates
// Load templates
r.LoadHTMLGlob("templates/*") // load all files in templates/
// or specific files:
r.LoadHTMLFiles("templates/index.html", "templates/about.html") // explicit list
// Render
r.GET("/", func(c *gin.Context) { // serve the homepage
c.HTML(200, "index.html", gin.H{ // render template with data
"title": "Home", // accessible as {{ .title }}
"users": users, // accessible as {{ range .users }}
})
})
<!-- templates/index.html -->
<h1>{{ .title }}</h1>
{{ range .users }}
<p>{{ .Name }} - {{ .Email }}</p>
{{ end }}
17 Testing
import (
"net/http" // http.NewRequest, http.StatusOK…
"net/http/httptest" // in-memory response recorder
"strings" // strings.NewReader for body
"testing" // Go's standard test framework
"github.com/gin-gonic/gin" // the framework under test
)
// Setup test router
func setupRouter() *gin.Engine { // shared helper for all tests
gin.SetMode(gin.TestMode) // suppress debug output in tests
r := gin.New() // no default middleware in tests
r.GET("/ping", func(c *gin.Context) { // inline handler for test
c.JSON(200, gin.H{"message": "pong"})
})
r.POST("/users", createUser) // reuse real handler
return r // caller gets configured router
}
// Test GET
func TestPing(t *testing.T) { // test the /ping endpoint
r := setupRouter() // get a fresh router
req, _ := http.NewRequest("GET", "/ping", nil) // build a fake GET request
w := httptest.NewRecorder() // captures response in memory
r.ServeHTTP(w, req) // dispatch request through router
if w.Code != 200 { // check HTTP status
t.Errorf("expected 200, got %d", w.Code) // fail test with message
}
if !strings.Contains(w.Body.String(), "pong") { // check response body
t.Errorf("expected pong in body")
}
}
// Test POST with JSON body
func TestCreateUser(t *testing.T) { // test POST /users
r := setupRouter() // fresh router per test
body := strings.NewReader(`{"name":"Yatin","email":"y@test.com"}`) // fake JSON body
req, _ := http.NewRequest("POST", "/users", body) // create POST request
req.Header.Set("Content-Type", "application/json") // tell Gin it's JSON
w := httptest.NewRecorder() // in-memory response writer
r.ServeHTTP(w, req) // run through router
if w.Code != 201 { // expect 201 Created
t.Errorf("expected 201, got %d", w.Code)
}
}
// Table-driven tests
func TestRoutes(t *testing.T) { // run multiple cases in one func
r := setupRouter() // single router for all cases
tests := []struct { // slice of anonymous structs
method, path string // HTTP method and URL path
wantCode int // expected status code
}{
{"GET", "/ping", 200}, // should respond 200
{"GET", "/nonexist", 404}, // unregistered route → 404
}
for _, tt := range tests { // iterate every test case
req, _ := http.NewRequest(tt.method, tt.path, nil) // build request
w := httptest.NewRecorder() // capture response
r.ServeHTTP(w, req) // execute request
if w.Code != tt.wantCode { // compare status codes
t.Errorf("%s %s: got %d, want %d", tt.method, tt.path, w.Code, tt.wantCode)
}
}
}
18 Project Structure
main.go → router → handler → service → repository → DB
Handlers know about Gin. Services don't. Repositories only know about the DB. Clean separation.
19 Graceful Shutdown
package main // executable entry point
import (
"context" // timeout for shutdown
"log" // simple logging
"net/http" // http.Server for manual control
"os" // os.Signal type
"os/signal" // signal.Notify
"syscall" // SIGINT / SIGTERM constants
"time" // time.Second
"github.com/gin-gonic/gin" // web framework
)
func main() {
r := gin.Default() // create router
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "pong"})
})
srv := &http.Server{ // manual server for graceful shutdown
Addr: ":8080", // listen address
Handler: r, // gin router is the handler
}
// Start server in goroutine
go func() { // non-blocking so we can wait for signal below
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { // ErrServerClosed is normal
log.Fatalf("listen: %s\n", err) // real error — crash
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1) // buffered so signal isn't missed
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // Ctrl+C or kill
<-quit // block here until signal received
log.Println("Shutting down server...")
// Give 5 seconds to finish in-flight requests
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // 5s deadline
defer cancel() // release resources when done
if err := srv.Shutdown(ctx); err != nil { // wait for active conns
log.Fatal("Server forced to shutdown:", err) // timed out — force exit
}
log.Println("Server exited cleanly") // all requests finished in time
}
20 Docker & Deploy
# Dockerfile — multi-stage build
FROM golang:1.22-alpine AS builder # stage 1: compile with full Go toolchain
WORKDIR /app # set working dir inside container
COPY go.mod go.sum ./ # copy dependency manifests first
RUN go mod download # cache deps before copying source
COPY . . # copy all source code
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/api # static binary, stripped
FROM alpine:latest # stage 2: tiny final image
RUN apk --no-cache add ca-certificates # needed for HTTPS requests
WORKDIR /app # working dir in final image
COPY --from=builder /app/server . # copy only the compiled binary
EXPOSE 8080 # document the port (doesn't publish it)
ENV GIN_MODE=release # disable debug output in prod
CMD ["./server"] # start the binary
# Build and run
docker build -t gin-api . # build image tagged "gin-api"
docker run -p 8080:8080 --env-file .env gin-api # run, expose port, load env vars
# Final image size: ~15-20MB (vs ~1GB with full Go image)
- Set
GIN_MODE=release - Use graceful shutdown
- Add health check:
GET /health - Set request size limits:
r.MaxMultipartMemory - Use HTTPS (or terminate TLS at load balancer)
- Add rate limiting middleware
- Add request ID middleware for tracing
- Structured logging (zerolog / slog)
21 Interview Questions
Core Concepts
gin.H is simply map[string]any. It's a shortcut for building quick JSON responses without defining a struct. Use structs for complex/reusable responses, gin.H for simple one-offs.
ShouldBindJSON returns the error to you — you decide the response. BindJSON auto-responds with 400 and calls c.Abort(). Always prefer ShouldBind* for full control over error responses.
Gin stores handlers as a chain (slice). c.Next() advances to the next handler — code after Next() runs on the way BACK. c.Abort() stops the chain; remaining handlers won't execute. This is how logging middleware measures latency (before/after Next()) and auth middleware blocks unauthorized requests.
Use c.Set("key", value) in middleware and c.Get("key") or typed getters like c.GetInt("key") in the handler. The context's key-value store is request-scoped and lives for one request only.
Separate concerns: handler (HTTP layer, knows Gin), service (business logic, no HTTP), repository (data access, no business logic). Wire them with dependency injection in main.go. Use internal/ to enforce boundaries. Route groups map to resource types.
Advanced
No. *gin.Context is NOT goroutine-safe. If you need to use it in a goroutine, copy it first with c.Copy():
cp := c.Copy() // snapshot current request data safely
go func() { // run work in a background goroutine
// use cp, not c
log.Println(cp.ClientIP()) // safe: reading from copied context
}()
Gin uses a radix tree (compressed trie) for routing via httprouter. Route parameters are extracted in-place without allocating new strings for each request. The Context object is pooled with sync.Pool — reused across requests instead of being garbage collected each time.
Create an http.Server manually, start it in a goroutine, then listen for OS signals (SIGINT/SIGTERM). On signal, call srv.Shutdown(ctx) with a timeout context. This lets in-flight requests finish (up to the timeout) before the server stops. Critical for Kubernetes deployments where pods get SIGTERM before being killed.
Gin: largest ecosystem, most tutorials, best for teams. Slightly opinionated. Echo: similar perf, cleaner API for some, smaller community. Chi: lightweight, stdlib-compatible (net/http handlers), best for projects that want minimal framework lock-in. Choose Gin for most new projects due to ecosystem. Choose Chi if you want to stay close to stdlib.
- Build a CRUD API with Gin + GORM
- Add JWT auth + middleware
- Write tests with
httptest - Add Swagger docs (
swaggo/gin-swagger) - Deploy with Docker + graceful shutdown
- Add WebSocket support (
gorilla/websocket)
You know Go. You know Gin. Now ship something.