Skip to main content

Gin Framework: Routing, Middleware, and Handlers

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

Gin is the most widely used web framework for Go. It wraps Go’s net/http with a router, middleware chain, and request binding — enough to build production REST APIs without reaching for anything else. The design is intentionally thin: Gin handles routing and middleware; everything else (database, authentication logic, business rules) lives in your code.

This guide covers the practical patterns: route organization, middleware composition, request binding with validation, and file handling. For the underlying HTTP fundamentals see Go HTTP client and server.

Installation and Basic Setup

go get github.com/gin-gonic/gin

The gin.Default() router comes with two middleware pre-installed: a logger and a panic recovery handler. For production, you often want to build the router from scratch with gin.New() and add only the middleware you control:

package main

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

func main() {
    r := gin.New()
    r.Use(gin.Recovery())  // recover from panics, return 500
    r.Use(requestLogger()) // your own structured logger

    r.GET("/health", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"status": "ok"})
    })

    r.Run(":8080")  // starts the server; blocks until killed
}

In production, set gin.SetMode(gin.ReleaseMode) before creating the router — it disables debug output and slightly improves performance.

Routing

Gin’s router supports path parameters (:name), wildcard paths (*path), and any HTTP method. Path parameters are accessible via c.Param:

// Static path
r.GET("/users", listUsers)

// Path parameter
r.GET("/users/:id", getUser)      // matches /users/42
r.PUT("/users/:id", updateUser)
r.DELETE("/users/:id", deleteUser)

// Wildcard — matches /files/images/photo.jpg, etc.
r.GET("/files/*path", serveFile)

func getUser(c *gin.Context) {
    id := c.Param("id")  // "42"
    // ...
}

Route Groups: Organizing by Concern

Route groups apply a common prefix and share middleware. This is how you structure versioned APIs and access-controlled sections without repeating yourself:

func setupRoutes(r *gin.Engine) {
    // Public endpoints — no auth required
    public := r.Group("/api/v1")
    {
        public.POST("/login", handleLogin)
        public.POST("/register", handleRegister)
        public.GET("/health", handleHealth)
    }

    // Authenticated endpoints
    auth := r.Group("/api/v1")
    auth.Use(authMiddleware())
    {
        auth.GET("/profile", handleProfile)
        auth.GET("/users", handleListUsers)
        auth.GET("/users/:id", handleGetUser)
        auth.POST("/users", handleCreateUser)
    }

    // Admin-only endpoints — additional middleware layer
    admin := r.Group("/api/v1/admin")
    admin.Use(authMiddleware(), requireRole("admin"))
    {
        admin.GET("/stats", handleStats)
        admin.DELETE("/users/:id", handleDeleteUser)
    }
}

The {} block after r.Group(...) is purely stylistic — Go doesn’t require it. It improves readability by visually grouping the routes that belong to each group.

Middleware

A Gin middleware is a function that returns gin.HandlerFunc. It calls c.Next() to pass control to the next handler in the chain, and can do work both before and after:

func requestLogger() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        path := c.Request.URL.Path

        c.Next()  // process the request

        // After the handler runs
        slog.Info("request",
            slog.String("method", c.Request.Method),
            slog.String("path", path),
            slog.Int("status", c.Writer.Status()),
            slog.Duration("duration", time.Since(start)),
            slog.String("ip", c.ClientIP()),
        )
    }
}

func authMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        token := c.GetHeader("Authorization")
        if token == "" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
            return  // c.Abort() stops the chain; no need to call c.Next()
        }

        claims, err := validateToken(token)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
            return
        }

        // Store data for downstream handlers
        c.Set("user_id", claims.UserID)
        c.Set("role", claims.Role)

        c.Next()
    }
}

c.Abort() stops the middleware chain — none of the subsequent handlers or middleware run. c.Next() advances to the next handler. If you return without calling either, behavior is the same as c.Next() — so always be explicit.

Request Binding and Validation

Gin’s binding system decodes JSON, form data, or query parameters into a struct and runs validation in one call. Use ShouldBindJSON (returns error) rather than BindJSON (writes 400 and returns error) so you control the response format:

type CreateUserRequest struct {
    Name  string `json:"name"  binding:"required,min=2,max=100"`
    Email string `json:"email" binding:"required,email"`
    Age   int    `json:"age"   binding:"min=0,max=150"`
    Role  string `json:"role"  binding:"omitempty,oneof=admin user viewer"`
}

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

    user, err := userService.Create(c.Request.Context(), req.Name, req.Email)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
        return
    }

    c.JSON(http.StatusCreated, user)
}

The binding tags use github.com/go-playground/validator/v10 under the hood. Common rules: required, min, max, email, url, oneof=a b c, uuid, len=10. The error messages from the validator are descriptive but not user-friendly — for production APIs, parse validator.ValidationErrors and return structured field-level errors.

For query parameters, use ShouldBindQuery:

type ListUsersQuery struct {
    Page  int    `form:"page"   binding:"min=1"`
    Limit int    `form:"limit"  binding:"min=1,max=100"`
    Role  string `form:"role"   binding:"omitempty,oneof=admin user viewer"`
}

func handleListUsers(c *gin.Context) {
    var q ListUsersQuery
    q.Page, q.Limit = 1, 20  // defaults

    if err := c.ShouldBindQuery(&q); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    users, total, err := userService.List(c.Request.Context(), q.Page, q.Limit, q.Role)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "users": users,
        "total": total,
        "page":  q.Page,
        "limit": q.Limit,
    })
}

Passing Data Between Middleware and Handlers

c.Set(key, value) stores arbitrary data in the request context. c.Get(key) retrieves it. This is how middleware passes authenticated user data to handlers without global state:

// In authMiddleware:
c.Set("user_id", claims.UserID)  // int
c.Set("role", claims.Role)       // string

// In a handler:
func handleProfile(c *gin.Context) {
    userID, exists := c.Get("user_id")
    if !exists {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
        return
    }

    profile, err := userService.GetProfile(c.Request.Context(), userID.(int))
    // ...
}

For type safety, use typed getters or define your own helpers to avoid the .(int) assertion:

func getUserID(c *gin.Context) (int, bool) {
    v, exists := c.Get("user_id")
    if !exists {
        return 0, false
    }
    id, ok := v.(int)
    return id, ok
}

File Upload Handling

Single file upload with size limit and type checking:

func handleUpload(c *gin.Context) {
    // Enforce size limit before processing
    c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 10<<20) // 10 MB

    file, header, err := c.Request.FormFile("file")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
        return
    }
    defer file.Close()

    // Check content type from the actual bytes, not the header
    buf := make([]byte, 512)
    n, _ := file.Read(buf)
    contentType := http.DetectContentType(buf[:n])
    if !strings.HasPrefix(contentType, "image/") {
        c.JSON(http.StatusBadRequest, gin.H{"error": "only images allowed"})
        return
    }

    dst := filepath.Join("uploads", filepath.Base(header.Filename))
    if err := c.SaveUploadedFile(header, dst); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "upload failed"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"filename": header.Filename, "size": header.Size})
}

Never trust the Content-Type header from the client — detect it from the file bytes with http.DetectContentType. Always sanitize the filename with filepath.Base to prevent path traversal attacks.

Custom Error Response Format

For consistent error responses across all handlers, define a central error type and a helper:

type APIError struct {
    Code    string `json:"code"`
    Message string `json:"message"`
}

func respondError(c *gin.Context, status int, code, message string) {
    c.JSON(status, APIError{Code: code, Message: message})
}

// Usage in handlers
func handleGetUser(c *gin.Context) {
    user, err := userService.Get(c.Request.Context(), c.Param("id"))
    if errors.Is(err, ErrNotFound) {
        respondError(c, http.StatusNotFound, "USER_NOT_FOUND", "user not found")
        return
    }
    if err != nil {
        respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to retrieve user")
        return
    }
    c.JSON(http.StatusOK, user)
}

Testing Gin Handlers

Test handlers without starting a real server using httptest:

func TestHandleGetUser(t *testing.T) {
    gin.SetMode(gin.TestMode)
    r := gin.New()
    r.GET("/users/:id", handleGetUser)

    w := httptest.NewRecorder()
    req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
    r.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)

    var resp map[string]any
    json.NewDecoder(w.Body).Decode(&resp)
    assert.Equal(t, "1", resp["id"])
}

Summary

  • Use gin.New() + explicit middleware in production; gin.Default() is convenient for development
  • Organize routes into groups by prefix and access level — group middleware applies to all routes in the group
  • ShouldBindJSON / ShouldBindQuery decode and validate in one call — use binding struct tags for field-level rules
  • Use c.Set / c.Get to pass data from middleware to handlers — always define typed helper functions to avoid scattered type assertions
  • c.Abort() stops the middleware chain; subsequent handlers do not run
  • Sanitize uploaded filenames with filepath.Base and detect content type from bytes, not the Content-Type header

Resources

Comments

👍 Was this article helpful?