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/ShouldBindQuerydecode and validate in one call — use binding struct tags for field-level rules- Use
c.Set/c.Getto 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.Baseand detect content type from bytes, not the Content-Type header
Comments