← Back to Go Guide

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.

FrameworkGitHub API (203 routes)Allocs
Gin~10,000 ns/op0
Echo~11,000 ns/op0
Chi~90,000 ns/opvaries
Gorilla Mux~1,000,000 ns/opmany

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() vs gin.New()

gin.Default() = gin.New() + Logger + Recovery middleware. Use gin.New() when you want full control over which middleware to attach.

Gin Modes
  • gin.SetMode(gin.DebugMode) — default, verbose logging
  • gin.SetMode(gin.ReleaseMode) — production, minimal logging
  • gin.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 vs Bind

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

TagWhat it does
requiredField must be present and non-zero
emailMust be valid email
urlMust be valid URL
min=5Minimum length (string) or value (number)
max=100Maximum length or value
len=8Exact length
gt=0Greater than
gte=0Greater than or equal
oneof=a b cMust be one of the listed values
alphanumAlphanumeric only
omitemptySkip validation if field is empty
eqfield=PasswordMust 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

gin-api/ ├── go.mod / go.sum ├── Makefile ├── Dockerfile ├── .env.example ├── cmd/ │ └── api/ │ └── main.go ← entry point, wires everything ├── internal/ │ ├── config/ │ │ └── config.go ← loads env vars / yaml │ ├── models/ │ │ └── user.go ← GORM models + request/response structs │ ├── repository/ │ │ └── user_repo.go ← DB queries (interface + impl) │ ├── service/ │ │ └── user_service.go ← business logic │ ├── handler/ │ │ └── user_handler.go ← Gin handlers (c *gin.Context) │ ├── middleware/ │ │ ├── auth.go │ │ ├── logger.go │ │ └── cors.go │ └── router/ │ └── router.go ← all route registration ├── pkg/ │ └── response/ │ └── response.go ← shared JSON response helpers └── migrations/
Dependency Flow

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)
Production Checklist
  • 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

Show All

Core Concepts

Ans

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.

Ans

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.

Ans

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.

Ans

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.

Ans

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

Ans

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
}()
Ans

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.

Ans

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.

Ans

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.


What's Next?
  1. Build a CRUD API with Gin + GORM
  2. Add JWT auth + middleware
  3. Write tests with httptest
  4. Add Swagger docs (swaggo/gin-swagger)
  5. Deploy with Docker + graceful shutdown
  6. Add WebSocket support (gorilla/websocket)
← Back to Go Fundamentals

You know Go. You know Gin. Now ship something.