Beginner
Group 1: Getting Started
Example 1: Minimal Gin Server
A Gin application starts with gin.Default(), which creates an engine with the Logger and Recovery middleware pre-attached. This example shows the smallest possible working Gin server.
package main
import "github.com/gin-gonic/gin" // => Import Gin framework
func main() {
r := gin.Default() // => Creates engine with Logger + Recovery middleware
// => Logger logs every request to stdout
// => Recovery catches panics and returns 500
r.GET("/", func(c *gin.Context) { // => Register GET handler for "/"
// => c is *gin.Context, central API for the handler
c.String(200, "Hello, Gin!") // => Writes status 200 and plain text response
// => Equivalent to HTTP 200 OK + body "Hello, Gin!"
})
r.Run(":8080") // => Starts HTTP server on port 8080
// => Blocks until server exits or panics
// => Equivalent to http.ListenAndServe(":8080", r)
}
// Curl: curl http://localhost:8080/
// => Hello, Gin!Key Takeaway: gin.Default() gives you a ready-to-use router with logging and panic recovery. Call r.Run(addr) to start serving.
Why It Matters: Every production Gin service starts from this foundation. The built-in Logger middleware gives you instant request visibility without configuration—critical for debugging. Recovery middleware prevents a panicking handler from crashing your entire server process, which is essential when running Go services that must remain available 24/7. Understanding this minimal structure lets you confidently add features without mystery.
Example 2: HTTP Method Handlers
Gin provides dedicated methods for each HTTP verb: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Each maps a URL pattern to a handler function.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default() // => Creates router with default middleware
// GET - retrieve a resource
r.GET("/users", func(c *gin.Context) { // => Handles GET /users
c.JSON(http.StatusOK, gin.H{ // => gin.H is map[string]any shorthand
"users": []string{"alice", "bob"}, // => Returns JSON array in "users" key
})
})
// POST - create a resource
r.POST("/users", func(c *gin.Context) { // => Handles POST /users
c.JSON(http.StatusCreated, gin.H{ // => 201 Created signals resource created
"message": "user created",
})
})
// PUT - replace a resource entirely
r.PUT("/users/:id", func(c *gin.Context) { // => Handles PUT /users/42
id := c.Param("id") // => Extracts :id path parameter => "42"
c.JSON(http.StatusOK, gin.H{"id": id, "updated": true})
})
// DELETE - remove a resource
r.DELETE("/users/:id", func(c *gin.Context) { // => Handles DELETE /users/42
c.Status(http.StatusNoContent) // => 204 No Content, no body
})
// PATCH - partial update
r.PATCH("/users/:id", func(c *gin.Context) { // => Handles PATCH /users/42
c.JSON(http.StatusOK, gin.H{"patched": true})
})
r.Run(":8080")
}
// GET /users => {"users":["alice","bob"]}
// POST /users => {"message":"user created"} (201)
// PUT /users/42 => {"id":"42","updated":true}
// DELETE /users/42 => (empty body, 204)
// PATCH /users/42 => {"patched":true}Key Takeaway: Gin exposes one method per HTTP verb. Each call registers a route in the radix tree and associates it with your handler function.
Why It Matters: Using semantically correct HTTP methods is foundational to REST API design. Clients and infrastructure (proxies, caches, load balancers) behave differently based on HTTP method semantics—GET requests are cacheable, POST requests are not. Teams that use the correct methods enable HTTP caching, simplify API contracts, and align with OpenAPI documentation tooling from day one.
Example 3: Path Parameters
Path parameters embed variable segments directly in the URL pattern using the :name syntax. Gin extracts them via c.Param("name").
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// Single path parameter
r.GET("/users/:id", func(c *gin.Context) { // => :id matches any single segment
// => GET /users/42 matches, /users/ does not
id := c.Param("id") // => Extracts the ":id" segment => "42"
c.JSON(http.StatusOK, gin.H{"user_id": id}) // => Returns {"user_id":"42"}
})
// Multiple path parameters
r.GET("/orgs/:org/repos/:repo", func(c *gin.Context) {
// => Matches /orgs/golang/repos/gin
org := c.Param("org") // => "golang"
repo := c.Param("repo") // => "gin"
c.JSON(http.StatusOK, gin.H{
"org": org, // => "golang"
"repo": repo, // => "gin"
})
})
// Wildcard parameter - matches remaining path including slashes
r.GET("/files/*filepath", func(c *gin.Context) { // => *filepath matches /any/path/here
// => Includes the leading slash
fp := c.Param("filepath") // => "/static/img/logo.png"
c.JSON(http.StatusOK, gin.H{"file": fp})
})
r.Run(":8080")
}
// GET /users/99 => {"user_id":"99"}
// GET /orgs/golang/repos/gin => {"org":"golang","repo":"gin"}
// GET /files/img/logo.png => {"file":"/img/logo.png"}Key Takeaway: :param matches a single URL segment; *param matches the rest of the path including slashes. Both are extracted with c.Param("name").
Why It Matters: RESTful API design expresses resource identity through path parameters—/users/42 is cleaner and more cacheable than /users?id=42. Wildcard parameters enable file server routes and proxy pass-through without enumerating every possible path. Understanding the distinction between single-segment and wildcard patterns prevents accidental route conflicts that only surface at runtime.
Example 4: Query Parameters
Query parameters appear after ? in the URL. Gin’s c.Query(), c.DefaultQuery(), and c.QueryArray() provide convenient extraction without manual URL parsing.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/search", func(c *gin.Context) {
// c.Query returns "" if parameter missing
q := c.Query("q") // => GET /search?q=gin => "gin"
// => GET /search => ""
// c.DefaultQuery returns fallback if parameter missing
page := c.DefaultQuery("page", "1") // => GET /search?page=2 => "2"
// => GET /search => "1" (default)
// c.QueryArray returns all values for repeated params
tags := c.QueryArray("tag") // => ?tag=go&tag=web => ["go","web"]
// => No params => [] (empty slice)
// c.QueryMap returns a map for params with bracket notation
filters := c.QueryMap("filter") // => ?filter[status]=active => {"status":"active"}
c.JSON(http.StatusOK, gin.H{
"q": q,
"page": page,
"tags": tags,
"filters": filters,
})
})
r.Run(":8080")
}
// GET /search?q=gin&page=2&tag=go&tag=web&filter[status]=active
// => {
// "q":"gin",
// "page":"2",
// "tags":["go","web"],
// "filters":{"status":"active"}
// }Key Takeaway: Use c.Query() for optional params, c.DefaultQuery() for params with fallback values, and c.QueryArray() for multi-value params.
Why It Matters: Query parameters drive pagination, filtering, and search across virtually every API. Using c.DefaultQuery() instead of manual nil checks reduces handler boilerplate and keeps default values explicit and reviewable. Correctly handling repeated parameters (?tag=a&tag=b) is necessary for any API that accepts multi-select filters—omitting this breaks client integrations silently.
Example 5: JSON Response
Gin’s c.JSON() serializes any Go value to JSON, sets Content-Type: application/json, and writes the HTTP status code in a single call.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// User is a domain struct used in responses
type User struct {
ID int `json:"id"` // => "id" in JSON output (lowercase)
Name string `json:"name"`
Email string `json:"email,omitempty"` // => omitted from JSON if empty string
Age int `json:"age,omitempty"` // => omitted from JSON if zero value (0)
}
func main() {
r := gin.Default()
// Return a struct as JSON
r.GET("/user", func(c *gin.Context) {
user := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
// => User{ID:1, Name:"Alice", Email:"alice@example.com", Age:0}
c.JSON(http.StatusOK, user)
// => HTTP 200, Content-Type: application/json
// => Body: {"id":1,"name":"Alice","email":"alice@example.com"}
// => Note: Age omitted because age:0 and tag says omitempty
})
// Return a map literal using gin.H shorthand
r.GET("/info", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ // => gin.H is map[string]any
"version": "1.0.0", // => string value
"features": []string{"auth", "db"}, // => JSON array
"debug": false, // => JSON boolean
})
// => {"version":"1.0.0","features":["auth","db"],"debug":false}
})
// Return HTTP error codes with error detail
r.GET("/error", func(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{ // => 400 Bad Request
"error": "invalid parameter",
"code": 40001,
})
// => HTTP 400, {"error":"invalid parameter","code":40001}
})
r.Run(":8080")
}Key Takeaway: c.JSON(statusCode, data) handles serialization, content-type header, and status code atomically. Use json struct tags to control field names and omit zero values.
Why It Matters: Consistent JSON serialization is the contract your API clients depend on. The omitempty tag prevents null values from surprising clients—they receive only meaningful fields. Using json:"snake_case" tags ensures your Go code follows Go naming conventions internally while your API follows REST naming conventions externally. These small decisions compound into maintainable API contracts at scale.
Example 6: JSON Request Binding
Gin binds incoming JSON request bodies to Go structs using c.ShouldBindJSON(). Struct field tags declare validation rules that Gin enforces automatically.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CreateUserRequest defines the expected JSON shape and validation rules
type CreateUserRequest struct {
Name string `json:"name" binding:"required"` // => required: non-empty
Email string `json:"email" binding:"required,email"` // => must be valid email
Password string `json:"password" binding:"required,min=8"` // => min 8 characters
Age int `json:"age" binding:"omitempty,min=0,max=150"` // => optional, 0-150
}
func main() {
r := gin.Default()
r.POST("/users", func(c *gin.Context) {
var req CreateUserRequest // => Declare zero-value struct to bind into
// ShouldBindJSON reads body, parses JSON, validates binding tags
if err := c.ShouldBindJSON(&req); err != nil { // => err != nil if body invalid
// => Validation failure is also an error
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(), // => Human-readable validation message
// => e.g. "Key: 'CreateUserRequest.Email' failed..."
})
return // => Stop handler execution after error response
}
// req is now a validated CreateUserRequest struct
// => req.Name = "Alice"
// => req.Email = "alice@example.com"
// => req.Password = "secret123"
c.JSON(http.StatusCreated, gin.H{
"message": "user created",
"user": req.Name, // => "Alice"
})
})
r.Run(":8080")
}
// POST /users {"name":"Alice","email":"alice@example.com","password":"secret123"}
// => 201 {"message":"user created","user":"Alice"}
// POST /users {"name":"Alice","email":"not-an-email","password":"short"}
// => 400 {"error":"Key: 'CreateUserRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag"}Key Takeaway: c.ShouldBindJSON(&req) deserializes and validates in one call. Use binding: struct tags to declare constraints; return early on error with a 400 response.
Why It Matters: Server-side validation is non-negotiable security practice. Client-side validation is user experience—server-side validation is defense. Binding validation in struct tags centralizes constraints next to the type definition, making them impossible to miss during code review. Using ShouldBindJSON instead of BindJSON gives you control over the error response format rather than Gin sending a raw 400 automatically.
Example 7: Form Data and URL-Encoded Input
Gin handles HTML form submissions and application/x-www-form-urlencoded data through c.PostForm() and struct binding with form: tags.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// LoginForm binds HTML form fields
type LoginForm struct {
Username string `form:"username" binding:"required"` // => form field name
Password string `form:"password" binding:"required,min=6"` // => minimum 6 chars
Remember bool `form:"remember"` // => optional checkbox
}
func main() {
r := gin.Default()
// Manual field extraction - useful for simple cases
r.POST("/login-simple", func(c *gin.Context) {
username := c.PostForm("username") // => "alice" from form field
password := c.PostForm("password") // => "secret" from form field
remember := c.DefaultPostForm("remember", "0") // => "1" or "0" (default)
// => All values are strings; convert as needed
c.JSON(http.StatusOK, gin.H{
"username": username,
"remember": remember == "1", // => Convert string to bool
})
_ = password // => Use password for authentication in real code
})
// Struct binding - validates and maps form fields automatically
r.POST("/login", func(c *gin.Context) {
var form LoginForm
if err := c.ShouldBind(&form); err != nil { // => ShouldBind detects Content-Type
// => Handles form, JSON, and query params
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// => form.Username = "alice"
// => form.Password = "secret123"
// => form.Remember = false
c.JSON(http.StatusOK, gin.H{"logged_in": true, "user": form.Username})
})
r.Run(":8080")
}
// curl -X POST http://localhost:8080/login -d "username=alice&password=secret123"
// => {"logged_in":true,"user":"alice"}Key Takeaway: c.ShouldBind() is content-type aware and works for form data, JSON, and query params using the same struct. c.PostForm() provides direct field access for simple cases.
Why It Matters: Web APIs often must accept both JSON (from JavaScript clients) and form data (from HTML forms or curl). Using ShouldBind() with the appropriate struct tags handles both content types through one code path, reducing duplication. This becomes essential when building APIs consumed by both native mobile apps (JSON) and traditional web browsers (form submissions).
Group 2: Response Types and Status Codes
Example 8: XML and YAML Responses
Beyond JSON, Gin supports XML and YAML responses natively. These matter for legacy system integration, configuration APIs, and clients that negotiate content type.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Article uses both json and xml struct tags
type Article struct {
Title string `json:"title" xml:"Title"` // => XML uses capitalized element names
Content string `json:"content" xml:"Content"`
Views int `json:"views" xml:"Views"`
}
func main() {
r := gin.Default()
// XML response
r.GET("/article/xml", func(c *gin.Context) {
article := Article{Title: "Go Tips", Content: "Use interfaces.", Views: 100}
c.XML(http.StatusOK, article) // => Content-Type: application/xml
// => <?xml version="1.0" encoding="UTF-8"?>
// => <Article><Title>Go Tips</Title><Content>Use interfaces.</Content><Views>100</Views></Article>
})
// YAML response
r.GET("/article/yaml", func(c *gin.Context) {
article := Article{Title: "Go Tips", Content: "Use interfaces.", Views: 100}
c.YAML(http.StatusOK, article) // => Content-Type: application/x-yaml
// => title: Go Tips
// => content: Use interfaces.
// => views: 100
})
// IndentedJSON - pretty-printed JSON for debugging
r.GET("/article/pretty", func(c *gin.Context) {
article := Article{Title: "Go Tips", Content: "Use interfaces.", Views: 100}
c.IndentedJSON(http.StatusOK, article) // => Pretty-printed with 4-space indents
// => {
// => "title": "Go Tips",
// => "content": "Use interfaces.",
// => "views": 100
// => }
})
r.Run(":8080")
}Key Takeaway: Gin’s c.XML(), c.YAML(), and c.IndentedJSON() all set the correct Content-Type header automatically.
Why It Matters: Enterprise integrations often require XML—legacy SOAP clients, financial data feeds, and configuration management tools all use XML. YAML is common in infrastructure APIs and configuration endpoints. Offering multiple response formats without code duplication keeps your handler logic clean while satisfying diverse client needs across your organization.
Example 9: HTTP Status Codes and Empty Responses
Gin provides c.Status() for responses without bodies and c.AbortWithStatus() for middleware that must terminate the chain immediately.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// 204 No Content - successful operation, no body to return
r.DELETE("/items/:id", func(c *gin.Context) {
// Perform deletion...
c.Status(http.StatusNoContent) // => HTTP 204, no response body written
// => Content-Length: 0
})
// 201 Created with Location header
r.POST("/items", func(c *gin.Context) {
newID := 42 // => ID assigned by database
c.Header("Location", "/items/42") // => Tells client where new resource lives
// => Standard REST practice for 201 responses
c.JSON(http.StatusCreated, gin.H{"id": newID})
// => HTTP 201, Location: /items/42, body: {"id":42}
})
// 202 Accepted - request queued for async processing
r.POST("/reports/generate", func(c *gin.Context) {
jobID := "job-xyz-789" // => Async job created
c.JSON(http.StatusAccepted, gin.H{ // => 202 signals async acceptance
"job_id": jobID,
"status": "queued",
"poll_at": "/jobs/job-xyz-789", // => URL to check job status
})
})
// 304 Not Modified - for conditional GET (ETag / If-None-Match)
r.GET("/cached-resource", func(c *gin.Context) {
etag := `"v1-abc123"` // => Resource version identifier
if c.GetHeader("If-None-Match") == etag { // => Client has current version
c.Status(http.StatusNotModified) // => 304, no body; client uses cache
return
}
c.Header("ETag", etag) // => Set ETag for next conditional request
c.JSON(http.StatusOK, gin.H{"data": "resource content"})
})
r.Run(":8080")
}Key Takeaway: Use semantically correct status codes: 201 for creation, 202 for async acceptance, 204 for bodyless success, 304 for cache validation.
Why It Matters: Clients—especially mobile apps and browsers—act on HTTP status codes. A 201 with a Location header lets the client immediately fetch the created resource without a second lookup. 304 responses enable HTTP caching that reduces bandwidth and server load. Getting status codes right from the start prevents subtle bugs in client integrations that are hard to trace after deployment.
Example 10: HTML Templates
Gin renders Go’s standard html/template files, providing auto-escaping for XSS prevention. Templates are loaded at startup and rendered per request.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// LoadHTMLGlob loads all matching template files at startup
r.LoadHTMLGlob("templates/*") // => Loads templates/*.html into memory
// => Fails fast if glob matches no files
// => Templates are parsed once, reused per request
r.GET("/home", func(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ // => Renders templates/index.html
// => Third arg is template data
"Title": "Welcome", // => {{.Title}} in template => "Welcome"
"Username": "Alice", // => {{.Username}} => "Alice"
"Items": []string{"Go", "Gin", "Docker"}, // => Range in template
})
// => Content-Type: text/html; charset=utf-8
})
// Templates in subdirectories need the directory prefix
r.LoadHTMLGlob("templates/**/*") // => Loads all nested templates
r.GET("/profile", func(c *gin.Context) {
c.HTML(http.StatusOK, "users/profile.html", gin.H{ // => Subdirectory template
"Name": "Bob",
})
})
r.Run(":8080")
}
// templates/index.html content:
// <!DOCTYPE html>
// <html>
// <head><title>{{.Title}}</title></head>
// <body>
// <h1>Hello, {{.Username}}!</h1>
// <ul>
// {{range .Items}}<li>{{.}}</li>{{end}}
// </ul>
// </body>
// </html>
// => Renders auto-escaped HTML; .Username is safe against XSSKey Takeaway: Load templates at startup with LoadHTMLGlob; render them per request with c.HTML(). Go templates auto-escape output, protecting against XSS.
Why It Matters: Server-rendered HTML remains the most SEO-friendly and accessible approach for content-heavy pages. Go’s html/template package escapes user input by default, making XSS protection opt-out rather than opt-in—the opposite of string concatenation or many JavaScript template engines. For internal tools, admin panels, and public-facing content, HTML rendering in Gin provides a complete solution without client-side complexity.
Group 3: Routing Patterns
Example 11: Route Groups
Route groups namespace related routes under a common path prefix and apply shared middleware to all routes in the group without repeating configuration.
%% Route group hierarchy
graph TD
A[Router /] --> B[Group /api/v1]
A --> C[Group /admin]
B --> D[GET /api/v1/users]
B --> E[POST /api/v1/users]
B --> F[GET /api/v1/posts]
C --> G[GET /admin/dashboard]
C --> H[POST /admin/users]
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#029E73,color:#fff
style D fill:#CC78BC,color:#fff
style E fill:#CC78BC,color:#fff
style F fill:#CC78BC,color:#fff
style G fill:#CA9161,color:#fff
style H fill:#CA9161,color:#fff
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// Group creates a sub-router with a shared path prefix
v1 := r.Group("/api/v1") // => All routes inside share /api/v1 prefix
{
// Curly braces are optional but document grouping intent
v1.GET("/users", func(c *gin.Context) { // => Handles GET /api/v1/users
c.JSON(http.StatusOK, gin.H{"users": []string{"alice", "bob"}})
})
v1.POST("/users", func(c *gin.Context) { // => Handles POST /api/v1/users
c.JSON(http.StatusCreated, gin.H{"message": "created"})
})
v1.GET("/posts", func(c *gin.Context) { // => Handles GET /api/v1/posts
c.JSON(http.StatusOK, gin.H{"posts": []string{}})
})
}
// Nested groups - apply common middleware to admin routes
admin := r.Group("/admin")
admin.Use(func(c *gin.Context) { // => Middleware applied to ALL admin routes
if c.GetHeader("X-Admin-Key") == "" { // => Require admin key header
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
// => AbortWithStatusJSON stops middleware chain + sends JSON response
return
}
c.Next() // => Proceed to next middleware or handler
})
{
admin.GET("/dashboard", func(c *gin.Context) { // => GET /admin/dashboard
c.JSON(http.StatusOK, gin.H{"stats": "ok"})
})
admin.POST("/users/disable/:id", func(c *gin.Context) { // => POST /admin/users/disable/42
c.JSON(http.StatusOK, gin.H{"disabled": true})
})
}
r.Run(":8080")
}
// GET /api/v1/users => {"users":["alice","bob"]}
// GET /admin/dashboard without X-Admin-Key => 401 {"error":"unauthorized"}
// GET /admin/dashboard with X-Admin-Key: secret => {"stats":"ok"}Key Takeaway: r.Group(prefix) creates a sub-router inheriting the parent’s middleware. Call .Use() on a group to apply additional middleware only to that group’s routes.
Why It Matters: Route groups are the primary architectural tool for organizing Gin APIs. Grouping by version (/v1, /v2) enables API versioning without touching existing routes. Grouping by access level (/admin, /api) lets you attach authorization middleware at the group level rather than duplicating it in every handler. This composability is central to keeping large codebases maintainable as they grow.
Example 12: Redirects
Gin supports HTTP redirects for both temporary (302) and permanent (301) moves, plus internal route forwarding that keeps the URL unchanged.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// 301 Permanent redirect - browser caches this; use for domain moves
r.GET("/old-path", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/new-path") // => 301 Location: /new-path
// => Browsers remember this redirect
})
// 302 Temporary redirect - browser does not cache
r.GET("/temp", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/current-page") // => 302 Location: /current-page
// => Use for auth redirects, A/B tests
})
r.GET("/new-path", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"page": "new"})
})
r.GET("/current-page", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"page": "current"})
})
// External redirect - sends client to another domain
r.GET("/github", func(c *gin.Context) {
c.Redirect(http.StatusFound, "https://github.com/gin-gonic/gin")
// => 302 Location: https://github.com/gin-gonic/gin
})
// Internal router redirect - processes another route without round-trip
r.GET("/forward", func(c *gin.Context) {
// HandleContext re-uses current context with a new path
// No HTTP round-trip; the client sees the /forward response
c.Request.URL.Path = "/new-path" // => Mutates the request path in-place
r.HandleContext(c) // => Dispatches to GET /new-path internally
})
r.Run(":8080")
}
// GET /old-path => HTTP 301 Location: /new-path
// GET /temp => HTTP 302 Location: /current-page
// GET /forward => {"page":"new"} (no redirect visible to client)Key Takeaway: Use 301 for permanent URL changes (domain migration, path renaming) and 302 for temporary or conditional redirects (login flows, feature flags).
Why It Matters: Misusing 301 versus 302 has lasting consequences. Search engines permanently update their index for 301 redirects—using 301 for a temporary condition locks you into the redirect forever in cached indexes. Authentication redirects must use 302 or users will be sent to the login page every time even after authenticating. Understanding redirect semantics prevents SEO damage and broken auth flows in production.
Example 13: Static File Serving
Gin serves static files from the filesystem, enabling single-page app hosting, image delivery, and asset serving without a separate web server.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// Serve a single file at a specific URL path
r.StaticFile("/favicon.ico", "./static/favicon.ico")
// => GET /favicon.ico reads ./static/favicon.ico from disk
// => Sets Content-Type based on file extension
// => Handles If-Modified-Since and ETag caching automatically
// Serve all files in a directory under a URL prefix
r.Static("/assets", "./static/assets")
// => GET /assets/css/app.css reads ./static/assets/css/app.css
// => GET /assets/js/main.js reads ./static/assets/js/main.js
// => Directory listing disabled (returns 404 for /assets/)
// Serve all files from an http.FileSystem (supports embed.FS)
r.StaticFS("/files", http.Dir("./uploads"))
// => Wraps os.DirFS with directory listing ENABLED
// => Prefer r.Static for production (no directory listing)
// SPA pattern - serve index.html for all unmatched routes
r.NoRoute(func(c *gin.Context) {
c.File("./static/index.html") // => Serves SPA shell for client-side routing
// => All /app/* paths handled by frontend router
})
r.Run(":8080")
}
// GET /favicon.ico => ./static/favicon.ico
// GET /assets/css/app.css => ./static/assets/css/app.css
// GET /nonexistent-api-path => ./static/index.html (SPA fallback)Key Takeaway: r.Static(url, dir) serves directories; r.StaticFile(url, file) serves a single file. Both handle HTTP caching headers automatically.
Why It Matters: Static file serving in Gin enables full-stack deployment from a single Go binary—your API and SPA frontend ship together. The automatic ETag and Last-Modified headers enable browser caching, reducing bandwidth for returning users. The NoRoute SPA fallback pattern is essential for React/Vue apps using HTML5 history routing, where the server must return index.html for paths like /users/42 that the frontend router handles.
Group 4: Headers, Cookies, and Context
Example 14: Request Headers
Gin provides c.GetHeader(), c.Request.Header.Get(), and c.Request.Header for reading headers, plus c.Header() for writing response headers.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/headers", func(c *gin.Context) {
// Reading request headers
contentType := c.GetHeader("Content-Type") // => Shorthand for header lookup
// => Returns "" if header missing
auth := c.GetHeader("Authorization") // => "Bearer eyJhbGc..." or ""
userAgent := c.Request.Header.Get("User-Agent")
// => "Mozilla/5.0..." or "curl/7.x" - direct stdlib access
// Reading all values of a multi-value header
acceptTypes := c.Request.Header["Accept"]
// => ["application/json", "text/html"] - slice of all values
// => Note: use map key access, not .Get(), for multi-value headers
// Writing response headers BEFORE calling c.JSON/c.String
c.Header("X-Request-ID", "req-abc-123") // => Custom header in response
c.Header("Cache-Control", "no-cache") // => Prevent caching
c.Header("X-RateLimit-Remaining", "99") // => Rate limit info for client
c.JSON(http.StatusOK, gin.H{
"content_type": contentType,
"auth_present": auth != "", // => true if Authorization header set
"user_agent": userAgent,
"accept": acceptTypes,
})
})
r.Run(":8080")
}
// curl -H "Authorization: Bearer token123" -H "Accept: application/json" http://localhost:8080/headers
// => Headers: X-Request-ID: req-abc-123, Cache-Control: no-cache, X-RateLimit-Remaining: 99
// => Body: {"content_type":"","auth_present":true,"user_agent":"curl/7.x","accept":["application/json"]}Key Takeaway: Use c.GetHeader("Name") for single-value headers and c.Request.Header["Name"] for multi-value headers. Write response headers with c.Header() before the response body.
Why It Matters: HTTP headers carry authentication tokens, content negotiation preferences, caching directives, and tracing metadata. Building custom request ID propagation (X-Request-ID) from the start enables log correlation across microservices—impossible to retrofit painlessly after deployment. Rate limit headers give clients the information to back off gracefully, reducing 429 errors from naive retry storms.
Example 15: Cookies
Gin reads cookies via c.Cookie() and sets them via c.SetCookie(). Cookie security attributes (HttpOnly, Secure, SameSite) protect against common web attacks.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// Set a cookie
r.GET("/set-cookie", func(c *gin.Context) {
c.SetCookie(
"session_id", // => Cookie name
"abc123xyz", // => Cookie value
3600, // => Max-Age in seconds (1 hour); 0 = session cookie; -1 = delete
"/", // => Path - cookie sent for all paths under /
"example.com", // => Domain - omit for current domain
true, // => Secure: only sent over HTTPS
true, // => HttpOnly: inaccessible to JavaScript (XSS protection)
)
// => Set-Cookie: session_id=abc123xyz; Path=/; Domain=example.com; Max-Age=3600; HttpOnly; Secure
c.JSON(http.StatusOK, gin.H{"message": "cookie set"})
})
// Read a cookie
r.GET("/get-cookie", func(c *gin.Context) {
val, err := c.Cookie("session_id") // => Returns (value, nil) if found
// => Returns ("", error) if missing
if err != nil { // => http.ErrNoCookie when missing
c.JSON(http.StatusBadRequest, gin.H{"error": "no session cookie"})
return
}
c.JSON(http.StatusOK, gin.H{"session_id": val}) // => {"session_id":"abc123xyz"}
})
// Delete a cookie by setting Max-Age to -1
r.GET("/logout", func(c *gin.Context) {
c.SetCookie("session_id", "", -1, "/", "", false, true)
// => Max-Age=-1 instructs browser to delete the cookie immediately
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
})
r.Run(":8080")
}Key Takeaway: c.SetCookie() sets cookies with security attributes inline. Always set HttpOnly: true to prevent JavaScript access and Secure: true in production HTTPS environments.
Why It Matters: Cookie security attributes are your first defense against session hijacking. HttpOnly blocks XSS attacks from reading the session cookie—a single missed output escape cannot drain all user sessions. Secure prevents session cookies from leaking over HTTP on the same domain. SameSite=Strict blocks CSRF by preventing cross-origin requests from sending cookies. These are not optional hardening—they are baseline security for any authenticated application.
Example 16: Context Values (Key-Value Store)
gin.Context provides a per-request key-value store via c.Set() and c.Get(). Middleware uses this to pass values (user ID, trace ID, parsed data) to downstream handlers.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// AuthMiddleware reads a token and stores the user ID in context
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization") // => Read token from header
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
// => AbortWithStatusJSON sends response and stops handler chain
return
}
// In real code: validate token, extract claims
userID := "user-42" // => Simulated user ID from token validation
c.Set("userID", userID) // => Store in context; available to all downstream handlers
// => Key is string, value is any
c.Set("isAdmin", false) // => Store boolean flag
c.Next() // => Continue to next middleware/handler
}
}
func main() {
r := gin.Default()
protected := r.Group("/protected")
protected.Use(AuthMiddleware()) // => Apply auth middleware to this group
{
protected.GET("/profile", func(c *gin.Context) {
// Retrieve values set by upstream middleware
userID, exists := c.Get("userID") // => ("user-42", true) if set
// => (nil, false) if not set
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "no user in context"})
return
}
isAdmin := c.GetBool("isAdmin") // => false; typed helpers avoid type assertion
userIDStr := c.GetString("userID") // => "user-42"; no type assertion needed
c.JSON(http.StatusOK, gin.H{
"user_id": userIDStr, // => "user-42"
"is_admin": isAdmin, // => false
"exists": exists, // => true
})
_ = userID // => userID is any; use GetString/GetInt for typed access
})
}
r.Run(":8080")
}
// GET /protected/profile with Authorization: Bearer token
// => {"user_id":"user-42","is_admin":false,"exists":true}Key Takeaway: c.Set(key, value) stores per-request data in context; c.GetString(), c.GetBool(), c.GetInt() retrieve it with type safety. Middleware sets values, handlers consume them.
Why It Matters: Request context propagation is the primary mechanism for sharing computed state across the middleware chain. Passing user identity, trace IDs, and parsed permissions through context eliminates the need to reparse tokens or headers in every handler. This pattern is so fundamental to Go web development that the standard library’s context.Context package codifies it—Gin’s context wraps and extends this concept for HTTP.
Example 17: Basic Authentication
Gin’s built-in gin.BasicAuth() middleware implements HTTP Basic Authentication with a credentials map. It handles the WWW-Authenticate challenge automatically.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// gin.Accounts is map[string]string of username->password
accounts := gin.Accounts{
"admin": "secret123", // => username: admin, password: secret123
"readonly": "pass456", // => second user for read-only access
}
// gin.BasicAuth returns middleware that challenges unauthenticated requests
authorized := r.Group("/secure", gin.BasicAuth(accounts))
// => Unauthenticated: 401 + WWW-Authenticate: Basic realm="Authorization Required"
// => Authenticated: sets gin.AuthUserKey in context
{
authorized.GET("/data", func(c *gin.Context) {
// gin.AuthUserKey is the constant "user" - the authenticated username
user := c.MustGet(gin.AuthUserKey).(string)
// => "admin" or "readonly" depending on credentials used
// => MustGet panics if key missing; safe here because middleware guarantees it
c.JSON(http.StatusOK, gin.H{
"message": "access granted",
"user": user, // => "admin"
"data": "sensitive information",
})
})
authorized.DELETE("/items/:id", func(c *gin.Context) {
user := c.MustGet(gin.AuthUserKey).(string) // => "admin"
id := c.Param("id")
c.JSON(http.StatusOK, gin.H{
"deleted_by": user, // => "admin"
"item_id": id,
})
})
}
r.Run(":8080")
}
// curl -u admin:secret123 http://localhost:8080/secure/data
// => {"data":"sensitive information","message":"access granted","user":"admin"}
// curl http://localhost:8080/secure/data
// => 401 + WWW-Authenticate headerKey Takeaway: gin.BasicAuth(accounts) wraps a route group with HTTP Basic Auth. The authenticated username is stored under gin.AuthUserKey in the context.
Why It Matters: HTTP Basic Auth provides instant, zero-dependency authentication for internal tools, admin APIs, and machine-to-machine integrations where managing JWT infrastructure is overkill. Gin’s built-in implementation handles the browser challenge/response cycle, base64 decoding, and credential lookup, eliminating four to six lines of boilerplate per protected route. Always pair with HTTPS in production, as Basic Auth credentials are only base64-encoded, not encrypted.
Group 5: Middleware Fundamentals
Example 18: Logger Middleware
Gin’s built-in logger middleware prints request details to stdout. Understanding its behavior lets you customize or replace it with structured logging.
package main
import (
"fmt"
"time"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
// gin.New() creates an engine WITHOUT any middleware
r := gin.New()
// gin.Logger() adds structured request logging
r.Use(gin.Logger()) // => Logs: [GIN] 2026/03/19 - 12:00:00 | 200 | 1.2ms | 127.0.0.1 | GET /api/users
// => Format: status | latency | client_ip | method | path
// Customize logger format using LoggerWithFormatter
r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
// param.TimeStamp => time.Time of request completion
// param.StatusCode => HTTP status code
// param.Latency => time.Duration of request
// param.ClientIP => client IP string
// param.Method => "GET", "POST", etc.
// param.Path => "/api/users"
// param.ErrorMessage => Error string if any
return fmt.Sprintf("[%s] %d %s %s %s\n",
param.TimeStamp.Format(time.RFC3339), // => "2026-03-19T12:00:00+07:00"
param.StatusCode, // => 200
param.Latency, // => 1.2ms
param.Method, // => "GET"
param.Path, // => "/api/users"
)
}))
r.Use(gin.Recovery()) // => Panic recovery; must come after Logger for accurate timing
r.GET("/api/users", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"users": []string{"alice"}})
// => Logger prints: [2026-03-19T12:00:00+07:00] 200 1.2ms GET /api/users
})
r.Run(":8080")
}
// The default gin.Default() is equivalent to:
// r := gin.New()
// r.Use(gin.Logger())
// r.Use(gin.Recovery())Key Takeaway: gin.New() gives you a blank engine; add gin.Logger() and gin.Recovery() manually to replicate gin.Default(). LoggerWithFormatter customizes the log format.
Why It Matters: Request logging is your primary observability tool in production. The default Gin logger outputs human-readable text—fine for development but problematic in production where log aggregation systems (Elasticsearch, CloudWatch, Datadog) expect structured JSON. Understanding how the logger middleware works lets you replace it with a structured logger like zap or zerolog, unlocking log-based alerting, tracing correlation, and performance dashboards from day one.
Example 19: Recovery Middleware
The Recovery middleware catches panics in handlers, logs the stack trace, and returns a 500 response instead of crashing the server process.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.New()
r.Use(gin.Logger())
// gin.Recovery() recovers from panics and returns HTTP 500
r.Use(gin.Recovery())
// => When a handler panics, Recovery:
// => 1. Catches the panic with recover()
// => 2. Logs the stack trace to stderr
// => 3. Writes HTTP 500 response to client
// => 4. Continues serving subsequent requests (server stays up)
// gin.CustomRecovery lets you control the 500 response format
r.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
// recovered is the value passed to panic()
// => recovered could be error, string, or any value
if err, ok := recovered.(string); ok {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "server error",
"detail": err, // => Include panic message in JSON (dev only!)
})
} else {
c.AbortWithStatus(http.StatusInternalServerError)
}
}))
r.GET("/safe", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true}) // => Normal response
})
r.GET("/panic", func(c *gin.Context) {
panic("something went very wrong") // => Recovery catches this panic
// => Server continues running
// => Returns 500 to this request only
})
r.Run(":8080")
}
// GET /safe => {"ok":true}
// GET /panic => {"error":"server error","detail":"something went very wrong"}
// GET /safe => {"ok":true} (server still running after panic)Key Takeaway: gin.Recovery() prevents one handler panic from taking down your entire server. gin.CustomRecovery() lets you control the 500 response format and add error reporting integration.
Why It Matters: In production Go servers, a nil pointer dereference or type assertion failure in one request should not crash the process and drop thousands of concurrent connections. Recovery middleware transforms panics from service outages into logged 500 errors. Plugging in error tracking (Sentry, Rollbar) via CustomRecovery turns every panic into an actionable alert with stack trace, dramatically reducing mean time to detection for runtime errors.
Example 20: Middleware Execution Order
Understanding middleware execution order—including how c.Next() creates a pre/post pattern—is essential for building middleware chains that share data correctly.
%% Middleware execution order with c.Next()
graph TD
A["Request arrives"] --> B["Middleware 1 - before c.Next#40;#41;"]
B --> C["Middleware 2 - before c.Next#40;#41;"]
C --> D["Handler executes"]
D --> E["Middleware 2 - after c.Next#40;#41;"]
E --> F["Middleware 1 - after c.Next#40;#41;"]
F --> G["Response sent"]
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#DE8F05,color:#fff
style D fill:#029E73,color:#fff
style E fill:#DE8F05,color:#fff
style F fill:#DE8F05,color:#fff
style G fill:#0173B2,color:#fff
package main
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
func TimingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println("1. TimingMiddleware: before Next") // => Runs first
c.Next() // => Calls next middleware/handler
// => Execution PAUSES here until
// => downstream handlers complete
fmt.Println("4. TimingMiddleware: after Next") // => Runs last, after handler
// => c.Writer.Status() available here - handler already ran
fmt.Printf(" Status: %d\n", c.Writer.Status()) // => "4. Status: 200"
}
}
func LoggingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println("2. LoggingMiddleware: before Next") // => Runs second
c.Next() // => Calls next handler
fmt.Println("3. LoggingMiddleware: after Next") // => Runs third (LIFO order)
}
}
func main() {
r := gin.New()
r.Use(TimingMiddleware()) // => Added first, outermost wrapper
r.Use(LoggingMiddleware()) // => Added second, inner wrapper
r.GET("/order", func(c *gin.Context) {
fmt.Println(" Handler: running") // => Runs in the middle
c.JSON(http.StatusOK, gin.H{"ok": true})
})
r.Run(":8080")
}
// Request to GET /order prints:
// 1. TimingMiddleware: before Next
// 2. LoggingMiddleware: before Next
// Handler: running
// 3. LoggingMiddleware: after Next
// 4. TimingMiddleware: after Next
// Status: 200Key Takeaway: Code before c.Next() runs in registration order (outermost first); code after c.Next() runs in reverse order (innermost first). This enables request wrapping for timing, logging, and transaction management.
Why It Matters: Middleware execution order determines correctness for many production patterns. A transaction middleware must commit or rollback after the handler runs—the post-c.Next() position guarantees this. Timing middleware must record start time before and end time after the handler. Authentication middleware must abort before calling c.Next() to prevent handlers from executing. Misunderstanding order produces bugs that only manifest under specific sequences that are hard to reproduce in tests.
Example 21: Aborting Middleware
c.Abort() and c.AbortWithStatus() stop the middleware chain from proceeding to downstream handlers. This is the primary pattern for authentication and authorization guards.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// RateLimitMiddleware demonstrates Abort pattern for policy enforcement
func RateLimitMiddleware(maxRequests int) gin.HandlerFunc {
requestCount := 0 // => Simplified counter; use sync.Mutex in production
return func(c *gin.Context) {
requestCount++
if requestCount > maxRequests {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
// => AbortWithStatusJSON calls c.Abort() + c.JSON()
// => c.Abort() sets index to abortIndex constant
// => All subsequent handlers in chain are SKIPPED
"error": "rate limit exceeded",
"retry_after": 60,
})
return // => return is good practice; handler body finished
}
c.Next() // => Allow request through
}
}
// RequireRoleMiddleware demonstrates fine-grained authorization abort
func RequireRoleMiddleware(role string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole := c.GetHeader("X-User-Role") // => In real apps: read from JWT claims
if userRole != role {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "insufficient permissions",
"required": role, // => "admin"
"actual": userRole, // => "viewer"
})
return // => Handler chain stopped; GET /admin/report never runs
}
c.Next() // => User has the required role; proceed
}
}
func main() {
r := gin.Default()
r.Use(RateLimitMiddleware(100)) // => Apply rate limit globally
admin := r.Group("/admin")
admin.Use(RequireRoleMiddleware("admin")) // => Require "admin" role for all /admin routes
{
admin.GET("/report", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"report": "sensitive data"})
// => Only reached if role == "admin" AND requestCount <= 100
})
}
r.Run(":8080")
}
// GET /admin/report with X-User-Role: viewer
// => 403 {"error":"insufficient permissions","required":"admin","actual":"viewer"}
// GET /admin/report with X-User-Role: admin
// => {"report":"sensitive data"}Key Takeaway: c.AbortWithStatusJSON() stops the middleware chain and sends a response. Code after c.Abort() in the same middleware function still runs; downstream handlers do not.
Why It Matters: Authorization enforcement belongs in middleware, not in handler code. Handlers that check permissions inline create a maintenance hazard—it is easy to add a new route and forget to copy the permission check. Centralizing authorization in middleware attached to route groups guarantees that every route in the group undergoes the same check automatically. This separation of concerns is foundational to building secure APIs that remain secure as they grow.
Group 6: Input Validation Basics
Example 22: Struct Validation Tags
Gin uses the go-playground/validator package for struct field validation. Tags express constraints declaratively on the struct definition.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Product demonstrates common validation tags
type Product struct {
Name string `json:"name" binding:"required,min=2,max=100"`
// => required: not empty string
// => min=2: at least 2 characters
// => max=100: at most 100 characters
Price float64 `json:"price" binding:"required,gt=0"`
// => gt=0: greater than 0 (not free)
Stock int `json:"stock" binding:"min=0"`
// => min=0: stock cannot be negative; not required (0 is valid)
Category string `json:"category" binding:"required,oneof=electronics clothing food"`
// => oneof: value must be one of the listed options
Tags []string `json:"tags" binding:"max=5,dive,min=1,max=20"`
// => max=5: at most 5 tags in the slice
// => dive: apply following rules to each slice ELEMENT
// => min=1,max=20: each tag must be 1-20 characters
Email string `json:"email" binding:"omitempty,email"`
// => omitempty: skip validation if field is zero value (empty string)
// => email: validate email format only when provided
}
func main() {
r := gin.Default()
r.POST("/products", func(c *gin.Context) {
var p Product
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
// => "Key: 'Product.Price' Error:Field validation for 'Price' failed on the 'gt' tag"
return
}
c.JSON(http.StatusCreated, gin.H{
"id": 99,
"product": p,
})
})
r.Run(":8080")
}
// POST /products {"name":"Laptop","price":-1,"category":"electronics"}
// => 400 {"error":"...Price...gt tag"}
// POST /products {"name":"Laptop","price":999.99,"stock":10,"category":"electronics","tags":["tech","new"]}
// => 201 {"id":99,"product":{"name":"Laptop","price":999.99,"stock":10,"category":"electronics","tags":["tech","new"],"email":""}}Key Takeaway: Binding tags like required, min, max, gt, oneof, email, and dive declaratively constrain struct fields. Validation errors describe which field failed which tag.
Why It Matters: Validation at the struct level keeps business rules visible, versionable, and testable. When a new engineer adds a field, the validation tag is right next to the field definition—impossible to miss. Validator’s tag-based approach eliminates hundreds of lines of if price <= 0 { return error } code in handler bodies, reducing both bugs and cognitive load. The dive tag for slice elements is particularly powerful for APIs that accept lists of items.
Example 23: Binding Query Parameters to Structs
Beyond JSON, ShouldBindQuery binds query string parameters directly to a struct, providing validation and type conversion in one step.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// SearchParams declares the expected query parameters
type SearchParams struct {
Query string `form:"q" binding:"required,min=1"`
// => ?q=gin => Query="gin"
Page int `form:"page" binding:"omitempty,min=1"`
// => ?page=2 => Page=2; missing => Page=0 (zero value, skipped by omitempty)
PageSize int `form:"per_page" binding:"omitempty,min=1,max=100"`
// => ?per_page=20 => PageSize=20
SortBy string `form:"sort" binding:"omitempty,oneof=name price date"`
// => ?sort=price => SortBy="price"
// => ?sort=invalid => validation error
}
func main() {
r := gin.Default()
r.GET("/search", func(c *gin.Context) {
var params SearchParams
if err := c.ShouldBindQuery(¶ms); err != nil { // => Binds query params to struct
// => Validates binding tags
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Apply defaults for optional fields
if params.Page == 0 { // => page not provided
params.Page = 1 // => Default to page 1
}
if params.PageSize == 0 { // => per_page not provided
params.PageSize = 20 // => Default to 20 items per page
}
c.JSON(http.StatusOK, gin.H{
"query": params.Query, // => "gin"
"page": params.Page, // => 1
"page_size": params.PageSize, // => 20
"sort_by": params.SortBy, // => ""
})
})
r.Run(":8080")
}
// GET /search?q=gin&sort=price
// => {"query":"gin","page":1,"page_size":20,"sort_by":"price"}
// GET /search?sort=invalid
// => 400 {"error":"...SortBy...oneof tag"}
// GET /search (missing required q)
// => 400 {"error":"...Query...required tag"}Key Takeaway: c.ShouldBindQuery(¶ms) binds and validates query parameters using the same binding: tags as JSON binding. Use form: tags to map URL parameter names to struct fields.
Why It Matters: Parsing query parameters manually—extracting strings, converting types, checking ranges—is tedious, error-prone, and scattered across handler bodies. Binding to a typed struct centralizes the contract, catches type errors at the entry point, and provides a single place to review what a handler accepts. When you add a new query parameter, the struct definition, validation, and documentation all update together.
Group 7: File Operations
Example 24: Single File Upload
Gin handles multipart file uploads via c.FormFile(). This example shows safe file upload with type checking and size limits.
package main
import (
"fmt"
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// Set maximum memory for multipart form parsing
r.MaxMultipartMemory = 8 << 20 // => 8 MB maximum; larger files go to temp files on disk
// => Default is 32 MB
r.POST("/upload", func(c *gin.Context) {
file, err := c.FormFile("file") // => "file" is the form field name
// => Returns *multipart.FileHeader and error
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file field required"})
return
}
// Validate file extension
ext := filepath.Ext(file.Filename) // => ".jpg", ".png", ".pdf" etc.
allowed := map[string]bool{".jpg": true, ".png": true, ".gif": true}
if !allowed[ext] {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("file type %s not allowed", ext),
})
return
}
// Validate file size (also check MaxMultipartMemory above)
if file.Size > 5<<20 { // => 5 MB limit at application layer
c.JSON(http.StatusBadRequest, gin.H{"error": "file too large, max 5MB"})
return
}
// Save file to disk
dst := fmt.Sprintf("uploads/%s", filepath.Base(file.Filename))
// => filepath.Base prevents path traversal attacks by stripping directory components
// => "../../etc/passwd" => "passwd"
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save file"})
return
}
c.JSON(http.StatusOK, gin.H{
"filename": file.Filename, // => "photo.jpg" (original name)
"size": file.Size, // => 102400 (bytes)
"saved_to": dst, // => "uploads/photo.jpg"
})
})
r.Run(":8080")
}
// curl -X POST http://localhost:8080/upload -F "file=@/path/to/photo.jpg"
// => {"filename":"photo.jpg","size":102400,"saved_to":"uploads/photo.jpg"}Key Takeaway: c.FormFile("field") returns the uploaded file metadata. Always validate extension and size before saving. Use filepath.Base() to prevent path traversal attacks.
Why It Matters: Unrestricted file upload is one of OWASP’s most critical web vulnerabilities. Accepting executable files (.exe, .php, .sh) can turn your server into an attack vector. Path traversal via filenames like ../../etc/passwd can overwrite system files. Size limits prevent disk exhaustion denial-of-service attacks. These three validations—type, size, path sanitization—are the minimum required before saving any uploaded file in production.
Example 25: Multiple File Upload
For forms that upload several files simultaneously, c.MultipartForm() provides access to the complete parsed form including all uploaded files.
package main
import (
"fmt"
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.MaxMultipartMemory = 32 << 20 // => 32 MB total for all files in the request
r.POST("/upload/multiple", func(c *gin.Context) {
form, err := c.MultipartForm() // => Parses entire multipart form
// => Returns *multipart.Form with Files and Value maps
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid multipart form"})
return
}
files := form.File["files"] // => "files" is the form field name
// => []*multipart.FileHeader slice
// => Empty slice if field not present
if len(files) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no files provided"})
return
}
if len(files) > 10 { // => Limit number of files per request
c.JSON(http.StatusBadRequest, gin.H{"error": "max 10 files per upload"})
return
}
var saved []string
for _, file := range files { // => Iterate each uploaded file
dst := fmt.Sprintf("uploads/%s", filepath.Base(file.Filename))
// => filepath.Base sanitizes path components
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "save failed",
"file": file.Filename,
})
return
}
saved = append(saved, file.Filename) // => Track successfully saved files
}
c.JSON(http.StatusOK, gin.H{
"uploaded": len(saved), // => 3 (number of files saved)
"files": saved, // => ["photo1.jpg","photo2.png","doc.pdf"]
})
})
r.Run(":8080")
}
// curl -X POST http://localhost:8080/upload/multiple \
// -F "files=@photo1.jpg" -F "files=@photo2.png"
// => {"uploaded":2,"files":["photo1.jpg","photo2.png"]}Key Takeaway: c.MultipartForm() gives you the full parsed form. Access multiple files via form.File["fieldname"], which returns a slice of *multipart.FileHeader.
Why It Matters: Batch upload dramatically improves user experience for media management, document processing, and bulk import workflows. Enforcing a per-request file count limit (max 10) prevents resource exhaustion from requests that upload thousands of files. Processing uploads in a loop with early-exit on error prevents partial saves that leave the server in an inconsistent state—better to reject the whole batch and let the client retry.
Group 8: Error Handling Basics
Example 26: Centralized Error Handling
Gin’s c.Error() method accumulates errors during request processing. A final middleware can inspect them and return a consistent error response.
package main
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
)
// AppError carries error code and HTTP status for structured error responses
type AppError struct {
Code int // => Application-specific error code
Message string // => Human-readable message
Status int // => HTTP status code
}
func (e *AppError) Error() string { return e.Message } // => Implements error interface
var (
ErrNotFound = &AppError{Code: 1001, Message: "resource not found", Status: http.StatusNotFound}
ErrForbidden = &AppError{Code: 1002, Message: "access denied", Status: http.StatusForbidden}
ErrBadRequest = &AppError{Code: 1003, Message: "invalid request", Status: http.StatusBadRequest}
)
// ErrorHandlerMiddleware reads accumulated errors and writes unified response
func ErrorHandlerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next() // => Execute handler first; errors accumulate via c.Error()
errs := c.Errors // => []*gin.Error slice, populated by c.Error() calls
if len(errs) == 0 {
return // => No errors; response already written
}
// Check if any error is an AppError
for _, e := range errs {
var appErr *AppError
if errors.As(e.Err, &appErr) { // => Unwraps to AppError if possible
c.JSON(appErr.Status, gin.H{
"error": appErr.Message,
"code": appErr.Code,
})
return
}
}
// Fallback for unexpected errors
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
}
func main() {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.Use(ErrorHandlerMiddleware()) // => Centralized error handler runs after all handlers
r.GET("/items/:id", func(c *gin.Context) {
id := c.Param("id")
if id == "0" {
_ = c.Error(ErrNotFound) // => Appends error to c.Errors; does NOT stop execution
return // => Handler returns; ErrorHandlerMiddleware sends response
}
c.JSON(http.StatusOK, gin.H{"id": id})
})
r.Run(":8080")
}
// GET /items/42 => {"id":"42"}
// GET /items/0 => 404 {"error":"resource not found","code":1001}Key Takeaway: c.Error(err) accumulates errors without writing a response. A post-c.Next() middleware inspects c.Errors to produce a unified error response format.
Why It Matters: Consistent error response structure is fundamental to client library design. If /users returns {"error": "not found"} and /posts returns {"message": "Post not found", "status": 404}, every client must handle both formats. Centralizing error serialization in middleware enforces a single contract, and adding new error codes requires changing only the error type definitions—not every handler. This consistency is non-negotiable for public APIs with third-party clients.
Example 27: NoRoute and NoMethod Handlers
Gin’s r.NoRoute() and r.NoMethod() register handlers for unmatched routes and incorrect HTTP methods, enabling consistent 404 and 405 responses.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/users", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"users": []string{"alice"}})
})
// r.NoRoute handles all requests that match no registered route
r.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{ // => 404 Not Found
"error": "route not found",
"path": c.Request.URL.Path, // => "/api/nonexistent" - useful for debugging
"method": c.Request.Method, // => "GET"
})
// => Without NoRoute, Gin returns a plain-text 404
// => With NoRoute, you control the response format
})
// r.NoMethod handles requests where the path matches but the method does not
// Requires r.HandleMethodNotAllowed = true to activate
r.HandleMethodNotAllowed = true // => Enable 405 detection (default: false)
r.NoMethod(func(c *gin.Context) {
c.JSON(http.StatusMethodNotAllowed, gin.H{ // => 405 Method Not Allowed
"error": "method not allowed",
"method": c.Request.Method, // => "DELETE"
"path": c.Request.URL.Path, // => "/users"
"allowed": c.Request.Header.Get("Allow"), // => "GET, OPTIONS"
})
})
r.Run(":8080")
}
// GET /nonexistent => 404 {"error":"route not found","path":"/nonexistent","method":"GET"}
// DELETE /users => 405 {"error":"method not allowed","method":"DELETE","path":"/users"}
// GET /users => {"users":["alice"]}Key Takeaway: r.NoRoute() handles 404s; r.NoMethod() (with r.HandleMethodNotAllowed = true) handles 405s. Both provide consistent JSON error responses instead of Gin’s default plain text.
Why It Matters: Every production API needs consistent 404 and 405 responses in the same format as other errors. API clients that parse error responses cannot handle a mix of plain-text 404s and JSON 400s—they need uniformity. NoRoute is also essential for SPA deployments where all non-API paths must return index.html. Logging the unmatched path and method in the 404 response helps identify broken client-side links and outdated mobile app versions calling deprecated endpoints.