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?
net/http package. At its core, Gin provides a radix-tree router (the httprouter fork) that matches URL paths up to 40x faster than the default http.ServeMux, plus a rich ecosystem of middleware, request binding, validation, rendering, and error management. The framework revolves around a single central type — *gin.Context — which carries the request, response writer, path parameters, query values, bound data, and middleware state through the entire request lifecycle.- Martini-like API: clean, expressive syntax inspired by the earlier Martini framework but 40x faster thanks to zero-allocation routing.
- Middleware chain: stackable handlers via
r.Use(...)withc.Next()andc.Abort()flow control. - Built-in binding:
c.ShouldBindJSON,ShouldBindQuery,ShouldBindUri, auto-dispatching by Content-Type viaShouldBind. - Validation: integrated
go-playground/validatorvia struct tags (binding:"required,email"). - Rendering: JSON, XML, YAML, ProtoBuf, HTML templates, SSE, static files — all via one-line
c.JSON(),c.HTML(), etc. - Route grouping: nest routes under common prefixes and middleware with
r.Group("/api/v1"). - Crash-free: bundled
Recovery()middleware catches panics and returns 500 instead of killing the server.
- vs raw
net/http: Gin adds routing with path params (/users/:id), middleware chains, binding, and a richer context. Rawnet/httpis more verbose but has zero dependencies. - vs Echo: Echo has a nearly identical API and performance, slightly more opinionated error handling (handlers return
error), and built-in OpenAPI tooling. Gin has a larger community and more middleware plugins. - vs Chi: Chi is closer to idiomatic
net/http— handlers arehttp.HandlerFuncso any standard library middleware works unchanged. Gin uses its owngin.HandlerFuncsignature and*gin.Context, so you trade compatibility for convenience. - vs Fiber: Fiber mimics Express and runs on
fasthttp(notnet/http), trading standards compliance for raw speed. Gin works with the standard HTTP ecosystem (http.Handler,httptest, etc.). - vs Beego: Beego is a full-stack MVC framework with ORM, cache, config, and scaffolding included. Gin is minimal — you pick GORM, Viper, Zap yourself.
- vs Express (Node): Similar middleware philosophy but Gin is compiled, statically typed, and handles concurrency via goroutines instead of an event loop.
- vs FastAPI (Python): FastAPI auto-generates OpenAPI/Swagger from type hints; Gin requires swaggo annotations. FastAPI is async Python; Gin is compiled Go with native concurrency.
- vs Spring Boot (Java): Spring Boot is a heavy DI-driven enterprise framework with annotations, AOP, and auto-config. Gin is 100x lighter — no DI container, no reflection magic, ~10ms startup vs multi-second Spring warmup.
- Don't forget
c.Abort(): callingc.JSON(401, ...)in middleware withoutAbort()still runs downstream handlers — which may write a second response and panic. - Context is not
context.Context:*gin.Contextimplements the standardcontext.Contextinterface but they're distinct — passc.Request.Context()(orcitself carefully) to DB/HTTP calls. - Binding consumes the body:
ShouldBindJSONreadsc.Request.Bodyonce. Call it twice and the second fails. UseShouldBindBodyWithif you need multiple reads.
Gin is the most popular Go web framework. ~81K GitHub stars, used by ~48% of Go developers. It's a thin, fast wrapper around net/http with routing, middleware, binding, and validation built in.
- Fast — zero-allocation router (httprouter), 40x faster than Martini
- Minimal — learn it in a day, not a week
- Middleware — plug-and-play: logging, recovery, CORS, auth
- Binding — auto-parse JSON/XML/Form into structs
- Validation — struct tag validation out of the box
- Battle-tested — Uber, Airbnb, Twitch, Dropbox, Cloudflare
| Framework | GitHub API (203 routes) | Allocs |
|---|---|---|
| Gin | ~10,000 ns/op | 0 |
| Echo | ~11,000 ns/op | 0 |
| Chi | ~90,000 ns/op | varies |
| Gorilla Mux | ~1,000,000 ns/op | many |
02 Setup & Hello World
go mod init), add Gin as a dependency (go get github.com/gin-gonic/gin), create an engine (gin.Default() or gin.New()), and call r.Run(":8080"). The resulting server is a single statically compiled binary ready to run on any Linux/macOS/Windows host with no external runtime.gin.Default()— returns an*Enginepre-loaded with Logger and Recovery middleware. Best for local development.gin.New()— returns a bare engine with no middleware. Use in production when you want to plug in your own structured logger (Zap, Zerolog) and custom panic recovery.
- vs net/http setup: raw
net/httprequires manually constructinghttp.ServeMux, registering handlers withHandleFunc, and starting withhttp.ListenAndServe. Gin wraps all that intor := gin.Default(); r.Run(). - vs Echo: nearly identical —
e := echo.New(); e.Start(":8080"). Both require manual middleware registration if you skipDefault(). - vs Express:
const app = express()is equivalent tor := gin.New(); Express needsbody-parserandcorsinstalled separately, while Gin bundles binding and CORS-ready middleware hooks. - vs Spring Boot: Spring uses annotations like
@SpringBootApplicationplus a Maven/Gradle build. Gin has no classpath scanning — handlers are registered explicitly at startup.
GIN_MODE environment variable: debug (default, verbose logs and colored output), release (no debug logs, faster), and test (for unit tests). Set with gin.SetMode(gin.ReleaseMode) or export GIN_MODE=release. Forgetting to set release mode in production is the #1 warning Gin prints on startup.- Port conflicts:
r.Run()defaults to:8080. If another process holds it, you getbind: address already in use— kill withlsof -i :8080. - Trusted proxies warning: Gin v1.9+ prints a warning if you don't call
r.SetTrustedProxies(nil)— important for correctc.ClientIP()behind load balancers. - Go version: Gin requires Go 1.20+. Older versions may compile but lose generics-based helpers.
gin.New() with custom Zap logging, Prometheus metrics middleware, Sentry panic reporting, and graceful shutdown via http.Server{Handler: r} instead of r.Run(), giving full control over timeouts (ReadTimeout, WriteTimeout, IdleTimeout) which r.Run() hides.# Create project
mkdir gin-api && cd gin-api # create project folder & enter it
go mod init github.com/yourname/gin-api # initialise Go module
# Install Gin
go get -u github.com/gin-gonic/gin # download & add Gin dependency
package main // every executable starts with package main
import "github.com/gin-gonic/gin" // import the Gin framework
func main() {
r := gin.Default() // includes Logger + Recovery middleware
r.GET("/ping", func(c *gin.Context) { // register GET /ping route
c.JSON(200, gin.H{"message": "pong"}) // respond with JSON 200
})
r.Run(":8080") // listen on 0.0.0.0:8080
}
go run main.go # compile & run the server
# curl http://localhost:8080/ping
# {"message":"pong"}
gin.Default() = gin.New() + Logger + Recovery middleware. Use gin.New() when you want full control over which middleware to attach.
gin.SetMode(gin.DebugMode)— default, verbose logginggin.SetMode(gin.ReleaseMode)— production, minimal logginggin.SetMode(gin.TestMode)— for tests- Or set env var:
GIN_MODE=release
03 Routing
- All HTTP verbs:
GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS, plusAny()to match all methods. - Path parameters:
/users/:idcaptures a named segment accessible viac.Param("id"). - Wildcard / catch-all:
/files/*filepathmatches everything after the slash, useful for serving static directories. - Route groups:
r.Group("/api/v1")lets you share middleware and path prefixes across related endpoints. - Method-not-allowed handling: enable with
r.HandleMethodNotAllowed = trueto auto-return 405 instead of 404 when the path exists but the verb doesn't.
- vs raw
net/http:http.ServeMuxonly supports exact prefix matches — no:idparameters, no method routing. You'd write a bigswitch r.Methodinside every handler. - vs Echo: Echo uses the same radix tree approach and has nearly identical performance. API is very similar —
e.GET("/users/:id", h)vsr.GET("/users/:id", h). - vs Chi: Chi uses a trie too but embraces the standard
http.Handlerinterface, so Chi middleware is compatible with any Go HTTP code. Gin middleware only works inside Gin. - vs Express: Express uses a linear regex-based matcher — O(n) per request. Gin is literally 40x faster on synthetic benchmarks.
- vs FastAPI/Spring Boot: those frameworks use decorators/annotations; Gin uses plain function calls, which means routes are visible at runtime and easier to generate programmatically.
- Conflicting routes: you cannot register both
/users/:idand/users/newon the same engine — Gin will panic at startup because the radix tree cannot disambiguate. Use/users/newand/users/id/:idinstead, or put/newin a different route group. - Trailing slash redirect: by default Gin auto-redirects
/users/to/userswith a 301. Disable viar.RedirectTrailingSlash = falseif building a strict REST API. - Case sensitivity: routes are case-sensitive —
/Usersand/usersare different. Enabler.RedirectFixedPath = trueto redirect case-mismatched paths.
/v1/customers/:id/charges), GraphQL gateways routing POST /graphql, static file servers with /static/*filepath, multi-tenant SaaS apps using /:tenant/dashboard. Companies like Uber, Bytedance (TikTok backend), and Medium route millions of requests/sec through Gin routers.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
/users/:id are part of the URL structure and are typically required identifiers. Query strings like ?page=2&sort=desc live after the ? and are typically optional filters, pagination, or flags. Gin exposes them via c.Param("id") and c.Query("page"), with variations for defaults (c.DefaultQuery), arrays (c.QueryArray), and maps (c.QueryMap).c.Param("id")— reads a single named path param; returns empty string if missing.c.Query("q")— reads a single query value; returns empty if missing.c.DefaultQuery("page", "1")— returns a fallback when the key is absent.c.QueryArray("tags")— reads repeated values from?tags=a&tags=b.c.QueryMap("filter")— reads nested keys from?filter[name]=go.c.GetQuery("q")— returns(value, exists bool)so you can distinguish "missing" from "empty string".
- vs raw
net/http: with stdlib you dor.URL.Query().Get("q")which is verbose and returns typed string; Gin wraps this in one-liners plus adds path param support (stdlib has none). - vs Echo: essentially identical —
c.QueryParam("q")in Echo vsc.Query("q")in Gin. - vs Express: Express exposes
req.params.idandreq.query.pageas object properties; Gin uses method calls which forces you to think about the "missing" case. - vs FastAPI: FastAPI uses function signature type hints (
page: int = 1) to auto-parse and validate. Gin requires manualstrconv.Atoior a struct binding viac.ShouldBindQuery().
- Everything is a string:
c.Query("page")returns""not0. You muststrconv.Atoior useShouldBindQuerywith struct tags to get typed values. - URL-encoded slashes:
:iddoes not match a slash, so/users/foo/barwon't match/users/:id. Use*wildcardif you need to capture slashes. - Case-sensitive query keys:
c.Query("Page")andc.Query("page")are different. - Query arrays: some clients send
?tags=a,b(comma-separated) — Gin treats this as a single string "a,b", not two values. Only?tags=a&tags=bpopulatesQueryArray.
ShouldBindQuery with a struct for anything beyond trivial cases — you get typed fields, default values, and validation in one shot. Use c.GetQuery when zero-value vs missing matters (e.g., ?active=false is meaningful). Keep path params for required resource IDs and query params for filters/pagination.// 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
json.NewDecoder(r.Body).Decode(&v), you write c.ShouldBindJSON(&req) and Gin handles content-type detection, deserialization, and (optionally) validation in one call. Gin provides two flavors: Bind* (aborts with 400 on error) and ShouldBind* (returns error so you control the response).ShouldBindJSON— parsesapplication/jsonrequest bodies.ShouldBindXML— parsesapplication/xml.ShouldBindQuery— parses URL query string into struct fields (viaform:"page"tag).ShouldBindUri— parses path parameters viauri:"id"tag.ShouldBindHeader— parses HTTP headers into a struct.ShouldBind— auto-detects based onContent-Typeheader (json → JSON, form → form, etc.).ShouldBindWith— explicit binder choice (e.g.,binding.MsgPack).
c.AbortWithError(400, err) internally — your handler keeps running but the response is already committed. The ShouldBind* family just returns the error and leaves the response untouched, which is almost always what you want. Rule of thumb: always use ShouldBind* so you can return custom error shapes (e.g., {"error": "validation failed", "fields": [...]}) that match your API contract.- vs raw
net/http: stdlib requiresjson.NewDecoder(r.Body).Decode(&v)plus manual validation — no unified path/query/body binding. - vs Echo: Echo's
c.Bind(&req)is similar but auto-detects less reliably; Gin's explicit methods are clearer. - vs FastAPI: FastAPI uses Pydantic models via function signatures — even more automatic, with auto-generated OpenAPI docs. Gin requires explicit Swagger annotations.
- vs Spring Boot: Spring uses
@RequestBody+ Jackson. Both are reflection-based, but Gin's validator syntax is more concise.
- Pointer vs value: you must pass
&req, notreq. Passing by value silently "works" but fills a copy that's discarded. - Body can only be read once:
c.Request.Bodyis anio.ReadCloser. If middleware already read it, binding fails. Usec.ShouldBindBodyWithto cache the body for re-reads. - Zero values vs missing: an absent JSON field and
"field": 0both produce0. Use pointers (*int) to distinguish. - Unknown fields: by default Gin ignores unknown JSON keys; enable
DisallowUnknownFieldson the decoder to reject them (you'll need a custom binder).
POST /users, PUT /orders/:id), form-based login pages, file upload metadata, webhook receivers for Stripe/GitHub payloads, and API clients that send query-string filters like ?status=paid&limit=50.// Binding = auto-parse request body into a struct
type CreateUserReq struct { // struct Gin binds request JSON into
Name string `json:"name" binding:"required"` // name must be present
Email string `json:"email" binding:"required,email"` // must be valid email
Age int `json:"age" binding:"gte=0,lte=130"` // 0–130 inclusive
}
// ShouldBindJSON — returns error (you handle it)
r.POST("/users", func(c *gin.Context) { // POST /users accepts JSON body
var req CreateUserReq // declare empty struct to fill
if err := c.ShouldBindJSON(&req); err != nil { // parse JSON into req
c.JSON(400, gin.H{"error": err.Error()}) // bad request if invalid
return // stop — don't continue to success path
}
c.JSON(201, gin.H{"user": req}) // 201 Created with new resource
})
// Bind from different sources
c.ShouldBindJSON(&req) // JSON body
c.ShouldBindXML(&req) // XML body
c.ShouldBind(&req) // auto-detect (JSON/XML/Form)
c.ShouldBindQuery(&req) // query string only
c.ShouldBindUri(&req) // path params only
// Bind URI params
type UserUri struct { // maps :id in path to struct field
ID int `uri:"id" binding:"required"` // :id auto-converted to int
}
r.GET("/users/:id", func(c *gin.Context) { // GET /users/42
var uri UserUri // empty struct to receive path param
if err := c.ShouldBindUri(&uri); err != nil { // bind :id → uri.ID
c.JSON(400, gin.H{"error": err.Error()}) // invalid id format
return
}
c.JSON(200, gin.H{"id": uri.ID}) // respond with the parsed id
})
// Bind form data (POST form / multipart)
type LoginForm struct { // maps HTML form fields to struct
Username string `form:"username" binding:"required"` // must be present
Password string `form:"password" binding:"required"` // must be present
}
r.POST("/login", func(c *gin.Context) { // handle login form submission
var form LoginForm // empty struct to receive form data
if err := c.ShouldBind(&form); err != nil { // auto-detects form/JSON
c.JSON(400, gin.H{"error": err.Error()}) // return validation error
return
}
})
ShouldBind* returns the error — you decide what to do. Bind* (without Should) auto-responds with 400 and aborts. Always use ShouldBind* for control.
06 Validation
binding:"required,email,min=3" tags, and when Gin parses a request via ShouldBind*, it runs validator against the resulting struct. If any rule fails, binding returns a validator.ValidationErrors slice you can iterate over for per-field error messages. Validation happens after deserialization, so type errors (sending a string where an int is expected) show up as binding errors, not validation errors.- Presence:
required,required_with,required_without,omitempty. - Strings:
min=3,max=100,len=10,alpha,alphanum,email,url,uuid4,contains=foo. - Numbers:
gt=0,gte=18,lt=100,lte=65,eq,ne. - Collections:
min=1,dive,required(appliesrequiredto each slice element). - Comparisons:
eqfield=Password,nefield=Old(cross-field validation). - Enums:
oneof=admin user guest.
binding.Validator.Engine().(*validator.Validate).RegisterValidation("phone", myPhoneFunc). Your function receives validator.FieldLevel and returns a bool. Use this for domain-specific rules like "valid ISO country code", "stripe customer ID format", or "future timestamp only".- vs manual
ifchecks: declarative, co-located with the type definition, and impossible to forget when you add a new endpoint. - vs Echo: Echo also supports go-playground/validator but requires explicit
e.Validator = &MyValidator{}setup. Gin wires it up automatically. - vs FastAPI/Pydantic: Pydantic uses Python type hints + class-based models, which auto-generates OpenAPI schemas. Gin's validator is string-tag-based and doesn't produce schemas without a separate tool (swaggo).
- vs Spring Boot: Spring uses JSR-303 (
@NotNull,@Size,@Email) — conceptually identical, syntactically more verbose.
- Zero values trip
required:requiredmeans "not the zero value", sointfield with value0fails required. Use*intif 0 is legal. - Error messages are developer-facing: the default
err.Error()string is ugly ("Key: 'User.Email' Error:Field validation for 'Email' failed on the 'email' tag"). Convert to user-friendly messages by iteratingerr.(validator.ValidationErrors). - Tag order doesn't matter for most rules, but
omitemptymust come first if you use it. - Nested structs: validation descends automatically, but slices of structs need
diveto validate each element.
// Gin uses go-playground/validator under the hood
type Product struct { // validator tags on every field
Name string `json:"name" binding:"required,min=2,max=100"` // 2–100 chars
Price float64 `json:"price" binding:"required,gt=0"` // must be > 0
Category string `json:"category" binding:"required,oneof=electronics clothing food"` // allowlist
SKU string `json:"sku" binding:"required,alphanum,len=8"` // exactly 8 chars
Email string `json:"email" binding:"omitempty,email"` // optional email
URL string `json:"url" binding:"omitempty,url"` // optional URL
IP string `json:"ip" binding:"omitempty,ip"` // optional IP addr
}
Common Validation Tags
| Tag | What it does |
|---|---|
required | Field must be present and non-zero |
email | Must be valid email |
url | Must be valid URL |
min=5 | Minimum length (string) or value (number) |
max=100 | Maximum length or value |
len=8 | Exact length |
gt=0 | Greater than |
gte=0 | Greater than or equal |
oneof=a b c | Must be one of the listed values |
alphanum | Alphanumeric only |
omitempty | Skip validation if field is empty |
eqfield=Password | Must equal another field |
Custom Validator
import (
"github.com/gin-gonic/gin/binding" // to access Gin's validator
"github.com/go-playground/validator/v10" // underlying validator lib
)
// Custom validation function
var noSpaces validator.Func = func(fl validator.FieldLevel) bool { // returns true = valid
value := fl.Field().String() // get the field's string value
return !strings.Contains(value, " ") // fail if any space found
}
func main() {
r := gin.Default() // create router with default middleware
// Register custom validator
if v, ok := binding.Validator.Engine().(*validator.Validate); ok { // type-assert to concrete type
v.RegisterValidation("nospaces", noSpaces) // give it a tag name
}
}
// Use it
type User struct { // struct using the custom validator
Username string `json:"username" binding:"required,nospaces"` // "nospaces" runs our func
}
07 Response Types
*gin.Context exposes a family of response rendering methods that handle status code, Content-Type header, serialization, and writing to the underlying http.ResponseWriter in a single call. Each method is a thin wrapper around a render.Render implementation — you can write custom renderers if you need to support exotic formats like Protocol Buffers or MessagePack.c.JSON(200, obj)— marshals to JSON, setsContent-Type: application/json. Most common.c.IndentedJSON— pretty-printed JSON (2-space indent). ~30% slower; debug only.c.SecureJSON— prefixes arrays withwhile(1);to defend against JSON hijacking in legacy browsers.c.AsciiJSON— escapes non-ASCII characters to\usequences.c.JSONP— JSON with a callback wrapper for cross-domain JS.c.XML,c.YAML,c.ProtoBuf— alternative serializations.c.String(200, "hi %s", name)— plain text with printf-style formatting.c.HTML(200, "index.tmpl", data)— render a registered HTML template.c.Redirect(302, "/login")— HTTP redirect.c.Data(200, "image/png", bytes)— raw bytes with explicit Content-Type.c.File("./report.pdf")— stream a file from disk.c.Stream(func(w io.Writer) bool {...})— server-sent events or chunked streaming.
- vs raw
net/http: stdlib requires 3 lines minimum: set header, set status, calljson.NewEncoder(w).Encode(v). Gin is one line. - vs Echo:
c.JSON(200, obj)is identical. Echo exposes slightly more formats out of the box (JSON Pretty, JSONP). - vs Fiber: Fiber uses
c.JSON(obj)(no status arg) andc.Status(200).JSON(obj)— a fluent chain. - vs Express:
res.status(200).json(obj)— also fluent, with the same underlying concept.
- Double write: calling
c.JSONtwice in one handler silently writes the second payload after the first — producing invalid JSON. Usereturnafter writing a response. - Can't change status after writing: once the first byte is flushed,
c.Status(500)is a no-op (headers are locked). Set status via the render method itself. - Nil slice vs empty array: a
nilGo slice marshals tonullin JSON, not[]. Initialize withmake([]T, 0)if clients expect an array. - Time zones:
time.Timemarshals to RFC 3339 by default. Use a custom type if you need unix timestamps or a specific format.
encoding/json by default. For JSON-heavy APIs, swap in jsoniter (drop-in replacement, 2x faster) by building with -tags=jsoniter, or use go-json / sonic for even more speed. Avoid IndentedJSON in production — indentation doubles the payload size and serialization cost.// 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 per-request god object that flows through every middleware and handler in Gin. It is a struct (not an interface) that embeds or references: the raw *http.Request, the underlying http.ResponseWriter (wrapped to track status and bytes written), a sync.RWMutex-protected key-value store, the handler chain, path parameters, bound forms, query cache, and a context.Context for deadlines and cancellation. Gin allocates one Context per request and recycles it via sync.Pool to avoid GC pressure — this is a major performance win.- Request access:
c.Request,c.ClientIP(),c.ContentType(),c.GetHeader("Auth"),c.Cookie("session"). - Parameter reads:
c.Param,c.Query,c.PostForm,c.FormFile. - Binding:
c.ShouldBindJSON,c.ShouldBindQuery, etc. - Response:
c.JSON,c.String,c.Status,c.Header,c.SetCookie. - Flow control:
c.Next(),c.Abort(),c.AbortWithStatus,c.AbortWithStatusJSON. - Key-value store:
c.Set("user", user)in middleware,c.MustGet("user").(User)in the handler. Typed helpers:GetString,GetInt,GetBool. - Errors:
c.Error(err)appends to a per-request error list for centralized logging. - Go context:
c.Request.Context()— pass this to DB calls for cancellation on client disconnect.
*gin.Context via sync.Pool, you must NOT keep a reference to c after the handler returns — spawning a goroutine that reads c.Query asynchronously will likely read stale data from the next request. The fix: cCp := c.Copy() creates a detached, read-only copy safe for goroutines, or extract the values you need before launching the goroutine.- vs Go's
context.Context: Gin's Context implements the standard interface, so you can passcdirectly to any function expectingcontext.Context. But it's much richer — HTTP-specific sugar, not just cancellation. - vs Echo:
echo.Contextis an interface, not a struct, which makes mocking in tests easier but adds a virtual call per access. - vs Chi: Chi doesn't have a custom context at all — you use
r.Context()directly and read URL params viachi.URLParam(r, "id"). - vs Express: Express's
req/res/nexttriad is roughly equivalent but split into three objects instead of one.
c.Request.Context() for DB/HTTP calls so they cancel when the client disconnects. Never stash business data on c.Keys as a substitute for function arguments — use it only for cross-cutting concerns like the authenticated user or trace ID. If you need the context in a goroutine, call c.Copy() first.*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
gin.HandlerFunc (a function taking *gin.Context) inserted into the request lifecycle before or after your actual handler. Middleware forms a chain — requests travel down the chain toward the handler, and responses travel back up. Typical uses are cross-cutting concerns: logging, authentication, rate limiting, panic recovery, CORS headers, request IDs, metrics collection, and database transaction wrapping. Gin middleware is registered with r.Use(mw) for global application, group.Use(mw) for a route group, or by passing it as an extra argument to r.GET(path, mw1, mw2, handler) for a single route.- Onion model:
c.Next()inside a middleware yields control to the next middleware/handler; code afterc.Next()runs on the way back up. - Abort:
c.Abort()stops the chain — no subsequent handlers run. Combine withc.JSONto short-circuit with an error response. - Built-in middleware:
gin.Logger()(access log),gin.Recovery()(catches panics, returns 500).gin.Default()=gin.New() + Logger + Recovery. - Route groups: apply middleware to a logical set —
r.Group("/api", authMW, rateLimitMW).
- vs raw
net/http: stdlib middleware wraps handlers:func(next http.Handler) http.Handler. It works but is verbose and doesn't natively understand aborting. - vs Chi: Chi middleware is stdlib-compatible (
func(next http.Handler) http.Handler), so you can reuse any community middleware. Gin middleware is Gin-specific. - vs Echo: Echo middleware signature is
func(next echo.HandlerFunc) echo.HandlerFunc— slightly different shape but same concept. - vs Express: Express uses
(req, res, next) => { ...; next() }— the same onion model with anext()callback. - vs Spring Boot filters/interceptors: Spring splits middleware into
Filter,HandlerInterceptor, and@Aspect. Gin has one unified concept.
- Forgetting
c.Next(): if your middleware doesn't callNext(), the chain still continues because Gin auto-advances after the function returns. But if you need "post" logic (timing, logging), you MUST callNext()explicitly. - Forgetting
c.Abort(): writing an error response (c.JSON(401, ...)) without callingAbort()allows the handler to also run — producing a double write. - Order matters: middleware registered before the route applies; registered after does not. Always
r.Use()beforer.GET.
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
gin.HandlerFunc and gets wired into the request chain via r.Use, a group, or a per-route argument. The classic pattern is the closure factory: an outer function takes configuration (DB handle, logger, JWT secret), and returns a closure that implements the middleware logic with that config baked in. This is Go's idiomatic alternative to dependency injection frameworks.func RateLimit(max int) gin.HandlerFunc {
bucket := newTokenBucket(max)
return func(c *gin.Context) {
if !bucket.Allow() {
c.AbortWithStatusJSON(429, gin.H{"error": "rate limited"})
return
}
c.Next()
}
}The outer function runs once (at setup), the inner closure runs per request.- Before-only: validate, set a key on context, call
c.Next(). - After-only: call
c.Next()first, then log status/duration fromc.Writer.Status(). - Short-circuit: call
c.AbortWithStatusJSONto halt the chain with an error. - Inject values:
c.Set("userID", uid)beforeNext()so handlers can read it. - Wrap the writer: replace
c.Writerwith a customgin.ResponseWriterto capture the response body for audit logs or caching.
- vs stdlib wrapping: stdlib uses
func(next http.Handler) http.Handlerwhich composes nicely but can't easily share state across before/after phases without a context key. - vs Chi: Chi middleware is stdlib-compatible, so you can literally reuse middleware from libraries like
github.com/felixge/httpsnooporrs/cors. Gin middleware is a closed ecosystem. - vs Echo: Echo's
echo.MiddlewareFunctakes and returns aHandlerFunc— more explicit composition, but requires returning the next call. - vs Express: Express middleware takes
(req, res, next); the same mental model. JavaScript's dynamic typing means Express middleware is more flexible but less type-safe.
- Shared state races: if your middleware mutates a shared map or counter, use
sync.Mutexorsync/atomic. Remember: Gin runs handlers concurrently. - Returning before
c.Next(): if you want to cancel, callc.Abort()ANDreturn. Otherwise the chain keeps going. - Reading the request body: body is a stream and can only be read once. If your middleware reads it, cache it with
c.Request.Body = io.NopCloser(bytes.NewBuffer(data))for downstream handlers. - Heavy work in middleware: middleware runs on every request — don't open DB connections or parse config inside the closure. Do that in the factory.
X-Tenant-ID, gzip compression, CSRF token checker, IP-based geoblocking, and audit log writer capturing request+response bodies.// 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)
header.payload.signature. The server signs the token with a secret (HMAC) or private key (RS256/ES256) at login, the client stores it, and sends it back with each request in an Authorization: Bearer <token> header. Gin doesn't ship JWT support itself — you use github.com/golang-jwt/jwt/v5 to parse/verify tokens, then write a middleware that extracts the header, validates the signature, and stores the user ID on c.Keys.- Stateless: server doesn't store sessions — the token itself is the credential.
- Self-contained: payload carries user ID, role, expiration, and custom claims.
- Signed: HMAC-SHA256 (shared secret) or RSA/ECDSA (public/private key pair).
- Expiration:
expclaim — tokens auto-expire so leaked tokens have limited lifetime. - Standard claims:
sub,iat,exp,nbf,iss,aud,jti.
- vs sessions: session IDs require a server-side store (Redis, DB). JWTs are stateless — no store needed — but cannot be revoked before expiration without a blocklist.
- vs OAuth2 access tokens: OAuth2 access tokens are often JWTs, but OAuth2 adds the whole authorization flow (authorize → code → token exchange). JWT is just the token format.
- vs API keys: API keys are opaque random strings; JWTs carry claims. API keys typically identify apps; JWTs typically identify users.
- vs Gin's
BasicAuth: Gin shipsgin.BasicAuthfor HTTP Basic Authentication, fine for internal dashboards but not for public APIs.
- Algorithm confusion (alg=none): always assert the expected algorithm in the
Keyfunc. Not doing so lets an attacker forge tokens by settingalg: none. - Secret leakage: never hardcode the signing secret. Use env vars or a secrets manager.
- Storing JWTs in
localStorage: vulnerable to XSS. UseHttpOnlycookies for browser clients. - No revocation: a stolen token is valid until
exp. Mitigate with short expirations (15 min) + refresh tokens, or a Redis-backed blocklist for forced logout. - Clock skew:
exp/nbfchecks can fail if server clocks drift — allow a small leeway withjwt.WithLeeway(30*time.Second).
// 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
https://app.example.com) from making HTTP requests to a different origin (e.g., https://api.example.com) unless the server explicitly opts in. The server opts in by returning specific response headers: Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Allow-Credentials. For non-simple requests, the browser first sends a preflight OPTIONS request to check permissions before the real request.- Origin allowlist:
AllowOrigins: []string{"https://app.example.com"}. - Credentials:
AllowCredentials: truelets cookies andAuthorizationheaders flow across origins (requires a specific origin, not*). - Methods & headers:
AllowMethods,AllowHeadersdeclare what the browser can send. - Exposed headers:
ExposeHeaderslets JS read custom response headers likeX-Total-Count. - Max age:
MaxAgecaches preflight responses to reduce round trips.
r.Use(cors.Default()) (allows everything — dev only). For production, call cors.New(cors.Config{...}) with an explicit origin list. Alternatively you can write 10 lines of middleware yourself — CORS is just a few headers.- vs
rs/cors(stdlib):github.com/rs/corsis a stdlib-compatible middleware — works with Chi, Echo, plainnet/http. Gin'sgin-contrib/corsis Gin-specific but integrates more cleanly. - vs Express
cors: identical config shape. - vs Spring Boot: Spring uses
@CrossOriginannotations per-controller, or a globalCorsConfigurationbean.
*+ credentials is illegal: browsers rejectAccess-Control-Allow-Origin: *whenAllow-Credentials: true. You must return the exact origin instead.- Preflight fails silently: if
OPTIONSreturns 404, the browser aborts the real request with a cryptic CORS error. Ensure your CORS middleware is before any route registration. - Custom headers need explicit allowlisting: sending
X-My-Custom-Headerrequires it inAllowHeaders, otherwise the preflight fails. - SameSite cookies: cross-origin cookies need
SameSite=None; Secure— CORS config alone isn't enough. - Server-side requests don't need CORS: CORS is a browser-only concept. curl, Postman, and server-to-server calls ignore it entirely.
https://app.example.com) calling an API on a separate subdomain (https://api.example.com), public read-only APIs that allow any origin (*), and multi-tenant SaaS where each customer has their own subdomain and the API allowlists a regex pattern.// 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
if err != nil applies in handlers too. But Gin provides two helpful primitives: c.Error(err), which appends to a per-request c.Errors slice (useful for logging), and gin.Recovery(), a built-in middleware that catches panics and converts them to 500 responses so one buggy handler doesn't crash the entire server. The typical production pattern is a centralized error middleware that inspects c.Errors after the handler runs and formats a consistent JSON error response.- Inline error response:
c.JSON(400, gin.H{"error": err.Error()}); return— simple and explicit. - Abort with status:
c.AbortWithStatusJSON(500, gin.H{...})— stops the chain AND writes a response. - Error accumulation:
c.Error(err)appends without writing a response; a later middleware readsc.Errorsto format. - Typed errors: define sentinel errors (
var ErrNotFound = errors.New(...)) and switch on them in middleware to map to HTTP status codes. - Panic recovery:
r.Use(gin.Recovery())(orCustomRecovery) catches panics and returns 500.
- vs Go 1.20+ error wrapping: Gin predates
errors.Is/Asbut composes perfectly with them. Useerrors.Is(err, ErrNotFound)to unwrap wrapped errors in your centralized handler. - vs Echo: Echo has
e.HTTPErrorHandler— a single function set globally that handles all errors, cleaner than Gin'sc.Errorsaccumulation approach. - vs FastAPI: FastAPI raises
HTTPException(status_code, detail)which bubbles to a global handler. Gin requires explicit middleware. - vs Spring Boot: Spring uses
@ControllerAdvice+@ExceptionHandlerfor global exception mapping. Gin's equivalent is middleware + switch/type-assertion.
- Leaking internals:
err.Error()may contain stack traces, SQL queries, or file paths. Log the full error server-side but return a sanitized message to clients. - Forgetting to
return: afterc.JSON(400, ...), you MUSTreturnor the handler keeps running and may write another response. - Panic in goroutines:
gin.Recovery()only catches panics in the request goroutine. Panics in goroutines you spawn are NOT caught — wrap them in their owndefer recover(). - Nil pointer panics: the most common panic source. Always check pointers before dereferencing.
ErrNotFound, ErrUnauthorized, ErrValidation) and map them to HTTP status in one place. Return a consistent JSON shape: {"error": {"code": "NOT_FOUND", "message": "..."}}. Log the full error with request ID for correlation. Never expose stack traces in production responses — use Sentry/Datadog/etc. for server-side visibility.// 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)
*gorm.DB once at startup and inject it into your handlers via a closure, middleware, or a repository struct.- Struct-to-table mapping:
type User struct { ID uint; Name string }becomes theuserstable. - Auto-migrate:
db.AutoMigrate(&User{})creates/updates the schema to match the struct. - CRUD helpers:
db.Create,db.First,db.Find,db.Where(...).Find(&users),db.Save,db.Delete. - Associations:
Preload("Orders")eager-loads related rows in one extra query. - Transactions:
db.Transaction(func(tx *gorm.DB) error {...})with auto commit/rollback. - Hooks:
BeforeCreate,AfterUpdatefor business logic (e.g., hash password before save). - Soft delete: add
gorm.DeletedAtfield — deletes set the timestamp instead of removing the row.
- vs
database/sql: stdlib is low-level — you write raw SQL and manually scan rows. GORM is high-level and concise but can hide expensive queries. - vs sqlx:
jmoiron/sqlxextendsdatabase/sqlwith struct scanning but no query builder or migrations. Faster and more predictable than GORM. - vs sqlc: sqlc generates type-safe Go code from SQL queries you write — best of both worlds (raw SQL + type safety) but no runtime query builder.
- vs ent: Facebook's ent is a schema-first ORM with code generation and graph-like API. More opinionated than GORM.
- vs Hibernate/JPA: Java's Hibernate is conceptually similar but much heavier; GORM is leaner but supports fewer advanced features.
- vs ActiveRecord (Rails): GORM borrows the ActiveRecord pattern — chainable query builder, implicit persistence — but with Go's type safety.
- N+1 queries: iterating over a slice and calling
db.Firstper row is the #1 GORM performance trap. UsePreloadorJoins. - Zero-value updates are ignored:
db.Updates(&User{Name: "", Age: 0})skips zero fields. Usemap[string]any{...}orSelect("Name", "Age")to update zero values. - Context cancellation: always use
db.WithContext(c.Request.Context())so queries abort when the client disconnects. - Connection pooling: configure
sqlDB.SetMaxOpenConns,SetMaxIdleConns,SetConnMaxLifetime— defaults are often wrong for production. - Auto-migrate in production: safe for adding columns, dangerous for schema changes. Use proper migrations (goose, atlas, golang-migrate).
db.Scopes(tenantScope), event sourcing backends, and admin panels. Used in production by hundreds of Go teams; generally considered acceptable up to medium-traffic workloads (10k RPS). For extreme performance, teams migrate to sqlx/sqlc + hand-tuned SQL.// 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
multipart/form-data content type — the browser splits the body into boundary-separated "parts", each with its own headers and payload. Gin wraps Go's standard mime/multipart parsing with friendly helpers: c.FormFile("file") returns a *multipart.FileHeader for a single upload, c.MultipartForm() gives you all files and fields at once, and c.SaveUploadedFile(fh, dst) writes a file to disk in one line. For downloads, c.File("./report.pdf") streams a file with the correct Content-Type, and c.FileAttachment forces a browser download with a custom filename.c.FormFile("file")— single file upload. Returns*multipart.FileHeader.c.MultipartForm()— parses the entire form; access files viaform.File["files"].c.SaveUploadedFile(fh, dst)— one-line disk save.c.File(path)— streams a file to the response.c.FileAttachment(path, name)— forcesContent-Disposition: attachment.c.FileFromFS(path, fs)— serve from anhttp.FileSystem(useful withembed.FS).r.Static("/assets", "./public")— serve a whole directory as static files.r.StaticFS("/embedded", http.FS(embedFS))— serve an embedded filesystem.
- vs raw
net/http: stdlib requiresr.ParseMultipartForm(maxMemory), thenr.FormFile, then manualio.Copyto save — 10+ lines vs Gin's 3. - vs Echo:
c.FormFile,c.SaveUploadedFile— identical. - vs Express multer: Express needs a separate
multermiddleware; Gin has it built-in. - vs FastAPI: FastAPI uses
UploadFileas a type hint — more declarative.
- Memory limit: default
MaxMultipartMemoryis 32 MB — larger files spool to disk, but extremely large uploads can still exhaust disk. Setr.MaxMultipartMemory = 8 << 20(8 MB) explicitly. - Filename trust:
file.Filenamecomes from the client — sanitize before using as a path! An attacker can send../../etc/passwd. - Content-type trust: a file claiming
image/pngmight be an executable. Sniff withhttp.DetectContentTypeon the first 512 bytes. - Disk usage: save to a temp directory with cleanup logic, or stream directly to S3/GCS instead of touching local disk.
- Virus scanning: integrate with ClamAV or a commercial scanner before accepting files.
// 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
html/template package. You load templates once at startup with r.LoadHTMLGlob("templates/*") or r.LoadHTMLFiles(...), and render them inside a handler with c.HTML(200, "index.html", data). The html/template engine provides automatic context-aware escaping — it understands whether you're inserting into HTML text, an attribute, a URL, a CSS block, or JavaScript, and escapes accordingly to prevent XSS.- Template actions:
{{.User.Name}},{{if .Active}}...{{end}},{{range .Items}}...{{end}},{{with .Profile}}...{{end}}. - Template inheritance:
{{define "base"}}+{{template "content" .}}for layouts. - Custom funcs:
r.SetFuncMap(template.FuncMap{"upper": strings.ToUpper})— then{{.Name | upper}}. - Multi-template: use
multitemplate(gin-contrib) for Django-style layouts with multiple blocks. - Hot reload in dev: call
r.LoadHTMLGlobon every request in debug mode.
- vs
text/template:text/templatedoesn't escape — use it for config files, emails, YAML. Never use it for HTML output (XSS risk). - vs Jinja2/Twig: Go templates are more restrictive by design — no arbitrary expressions, fewer built-in filters. This keeps logic out of templates.
- vs Handlebars/Mustache: Go templates support conditionals and loops; Mustache is strictly "logic-less".
- vs React/Vue: Go templates are server-rendered — no client-side JS runtime. Pair with HTMX for modern interactive server-rendered apps.
- vs templ: a-h/templ is a newer type-safe alternative — Go code generates HTML with compile-time checks. No runtime parsing. Highly recommended for greenfield projects.
- Unescaping with
template.HTML: wrapping a string intemplate.HTML(s)bypasses escaping. Only do this for trusted content. - Template name clashes:
LoadHTMLGlob("templates/*")uses the filename as the template name, so two files namedindex.htmlin different subdirs collide. - Missing fields: referencing a field that doesn't exist on the data struct is a runtime error. Consider using
{{with}}or default values. - Performance: templates are parsed once and cached. Don't call
LoadHTMLGlobinside handlers in production.
html/template separately), server-rendered marketing sites (Hugo is Go-templated), HTMX apps, and the classic "login page + form post" flow for simple CRUD admin tools.// 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
testing package plus net/http/httptest, which provides an in-memory response recorder so you can exercise handlers without binding to a real port. The typical flow: create a router with your routes, wrap it in a test, call router.ServeHTTP(recorder, request), and assert on recorder.Code and recorder.Body. Because Gin is just an http.Handler, every tool that works with stdlib HTTP works with Gin.- Set test mode:
gin.SetMode(gin.TestMode)silences log output during tests. - Build request:
req := httptest.NewRequest("POST", "/users", strings.NewReader(body)). - Record response:
w := httptest.NewRecorder(); thenrouter.ServeHTTP(w, req). - Assert:
assert.Equal(t, 200, w.Code),assert.JSONEq(t, expected, w.Body.String())(usinggithub.com/stretchr/testify). - Table-driven tests: define a slice of test cases and loop — idiomatic Go pattern.
- Mocks: inject interfaces for DB/HTTP clients so handlers can be tested without real dependencies.
- vs
supertest(Node): same concept — spin up the app in-memory, send requests, assert responses. - vs pytest + FastAPI TestClient: FastAPI's TestClient uses
httpxunder the hood; same idea. - vs Spring
MockMvc: Spring tests inject a mock dispatcher servlet; conceptually identical. - vs Rack::Test (Ruby): same in-memory pattern.
- testify (stretchr/testify):
assert,require,mock,suite. De facto standard for Go tests. - gomock (uber-go/mock): interface mocking with code generation.
- httpexpect: fluent HTTP test DSL —
e.POST("/users").WithJSON(u).Expect().Status(201). - testcontainers-go: spin up real Postgres/Redis in Docker for integration tests.
- Forgetting to set Content-Type:
req.Header.Set("Content-Type", "application/json")— without it, Gin'sShouldBindmay choose the wrong binder. - Global state: tests that share a singleton DB or logger can cross-contaminate. Prefer per-test setup.
- Parallel tests: use
t.Parallel()to speed up I/O-bound tests, but only when handlers have no shared mutable state. - Flaky time-based tests: inject a clock interface instead of calling
time.Now()directly.
vegeta or k6.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
cmd/ holds the main.go entry points, internal/ holds private packages, pkg/ holds reusable packages, and domain-specific folders like handlers/, services/, repositories/, models/, and middleware/ split concerns. The goal is simple dependency flow: handlers depend on services, services depend on repositories, repositories depend on the database.cmd/api/main.go— thin entry point that wires dependencies and starts the server.internal/— private packages, not importable by other modules. Go enforces this at compile time.internal/handlers/— HTTP handlers (thin layer, no business logic).internal/services/— business logic, orchestration.internal/repositories/— database access (orinternal/store/).internal/models/— domain types and DTOs.internal/middleware/— custom Gin middleware.internal/config/— config loading (env vars, files).migrations/— SQL migration files.pkg/— public reusable packages (optional).api/— OpenAPI/protobuf specs.
- Flat (small apps): everything in
mainor 2-3 files — fastest to start, fine up to ~500 LOC. - Layered (MVC-ish): handlers → services → repositories. Easy to understand, works for most CRUD apps.
- Clean Architecture / Hexagonal: domain types at the center, infrastructure on the outside. Enforces testability via interfaces. Overkill for small apps.
- Domain-Driven Design: group by bounded context (e.g.,
billing/,catalog/,auth/) instead of by technical layer.
- vs Django/Rails: those frameworks enforce a layout (
models/,views/,controllers/). Go gives you freedom — and responsibility. - vs Spring Boot: Spring uses package-based organization too, but relies heavily on annotations and dependency injection containers. Go uses explicit construction in
main. - vs Node/Express: Node is equally unopinionated, but NPM's flat dependency model differs from Go modules.
- Over-engineering: don't copy Uber-scale layouts for a 500-line API. Start flat, refactor when pain appears.
- Circular imports: Go forbids them. Careful with shared types — put them in a
modelsordomainpackage that nothing else imports from. - Global state: avoid package-level variables for DB/config. Pass dependencies explicitly via constructors.
pkg/is controversial: some Go teams considerpkg/unnecessary — everything that's notcmd/orinternal/is implicitly "public". Use it only if you publish reusable libraries.
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
SIGTERM, the server: (1) stops accepting new connections, (2) waits for existing requests to finish (up to a timeout), (3) closes the listener, and (4) exits cleanly. This is essential for zero-downtime deploys, where Kubernetes or a load balancer sends SIGTERM, waits a few seconds, then removes the pod.http.Server.Shutdown(ctx). The pattern: create an http.Server manually (don't call r.Run()), start it in a goroutine, listen for SIGINT/SIGTERM on a channel, and when received, call srv.Shutdown(ctx) with a timeout context. The shutdown method blocks until all active connections finish or the context is cancelled.- Build
http.Server:srv := &http.Server{Addr: ":8080", Handler: r}. - Start in goroutine:
go srv.ListenAndServe()so main can wait on the signal. - Trap signals:
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM). - Shutdown with timeout:
ctx, _ := context.WithTimeout(ctx, 30*time.Second); srv.Shutdown(ctx). - Close resources: after shutdown returns, close DB pools, flush logs, drain background workers.
- vs
r.Run(): the one-linerr.Run()blocks forever and has no shutdown hook. Fine for scripts, bad for production. - vs Node.js:
server.close()is the equivalent, but Node requires extra care because pending callbacks keep the event loop alive. - vs nginx
quit: nginx's graceful quit is similar — stop accepting, drain, exit. - vs Kubernetes: K8s sends
SIGTERM, waits forterminationGracePeriodSeconds(default 30s), thenSIGKILL. Your shutdown timeout must be shorter.
- SIGKILL can't be trapped: if you only handle SIGTERM,
kill -9still kills abruptly. - Long-running requests block shutdown: a 60s database query blocks shutdown past the timeout. Propagate
c.Request.Context()to the DB so it cancels. - WebSocket/SSE connections:
Shutdown()doesn't close hijacked connections. Track them manually and close them before calling Shutdown. - Pre-stop hook: in Kubernetes, use a
preStopsleep of ~5s before SIGTERM so the service removal can propagate through the LB before you stop accepting. - Goroutines leaking past shutdown: Shutdown doesn't wait for background goroutines you spawned — track them with
sync.WaitGroup.
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
node_modules, no JVM runtime. A production Dockerfile for Gin is typically ~10 lines and produces an image under 15 MB using scratch or distroless as the base.- Stage 1 (builder):
FROM golang:1.22-alpine— compile with the full toolchain. - Stage 2 (runtime):
FROM scratchorgcr.io/distroless/static— copy only the compiled binary. - Build flags:
CGO_ENABLED=0for a fully static binary,-ldflags="-s -w"to strip debug symbols (~30% smaller). - Cache optimization: copy
go.mod/go.sumbefore source sogo mod downloadis cached when only code changes.
- Kubernetes: the de facto standard for Gin services. Use Deployments, Services, Ingress, HPA.
- Cloud Run / App Runner: serverless containers — pay per request, auto-scale to zero. Great for low-traffic APIs.
- Fly.io / Railway / Render: developer-friendly PaaS that runs your Dockerfile directly.
- Lambda: Gin works via the
awslabs/aws-lambda-go-api-proxyadapter — it translates Lambda events tohttp.Request. - systemd: a plain binary + systemd unit file is perfectly valid for small deployments.
- vs Node.js: Go images are 10-20x smaller because there's no runtime bundled. A Node Docker image is typically 100-300 MB; Gin can be ~10 MB.
- vs Java/Spring: Spring Boot images are 200-500 MB (JVM + dependencies). Go apps start in milliseconds vs seconds for JVM warm-up.
- vs Python (FastAPI): Python images need the interpreter + pip packages (~150 MB). Go is dramatically smaller.
- vs serverless frameworks: Go's fast cold start (<50ms) makes it ideal for Lambda/Cloud Run, whereas JVM cold starts can be seconds.
- Timezones:
scratchhas no/usr/share/zoneinfo. Either usealpine, copy tzdata into scratch, or import_ "time/tzdata"to embed it in the binary. - CA certificates:
scratchalso lacks CA certs — outbound HTTPS fails. Copy/etc/ssl/certs/ca-certificates.crtfrom the builder, or use distroless which includes them. - Health checks: expose
/healthzfor Kubernetes liveness/readiness probes. Liveness should be cheap; readiness should check DB connectivity. - Logging: write to stdout/stderr (not files) — containers collect logs that way. Use structured JSON logs (zap, zerolog).
- Config: 12-factor — read from env vars. Never bake secrets into images.
# Dockerfile — multi-stage build
FROM golang:1.22-alpine AS builder # stage 1: compile with full Go toolchain
WORKDIR /app # set working dir inside container
COPY go.mod go.sum ./ # copy dependency manifests first
RUN go mod download # cache deps before copying source
COPY . . # copy all source code
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/api # static binary, stripped
FROM alpine:latest # stage 2: tiny final image
RUN apk --no-cache add ca-certificates # needed for HTTPS requests
WORKDIR /app # working dir in final image
COPY --from=builder /app/server . # copy only the compiled binary
EXPOSE 8080 # document the port (doesn't publish it)
ENV GIN_MODE=release # disable debug output in prod
CMD ["./server"] # start the binary
# Build and run
docker build -t gin-api . # build image tagged "gin-api"
docker run -p 8080:8080 --env-file .env gin-api # run, expose port, load env vars
# Final image size: ~15-20MB (vs ~1GB with full Go image)
- Set
GIN_MODE=release - Use graceful shutdown
- Add health check:
GET /health - Set request size limits:
r.MaxMultipartMemory - Use HTTPS (or terminate TLS at load balancer)
- Add rate limiting middleware
- Add request ID middleware for tracing
- Structured logging (zerolog / slog)
21 Interview Questions
- Self-test: toggle "Hide All" and try to answer each question from memory before peeking.
- Depth over breadth: interviewers care more about "why" than "what". If you know why Gin's radix tree beats
http.ServeMux, you'll answer ten related questions correctly. - Practice aloud: rubber-duck every answer in your own words before a real interview.
- Map to your experience: every concept here has a production anchor — tie each answer to something you've built or debugged.
- Core concepts: what is Gin, how does it compare to Echo/Chi/Fiber, what is
*gin.Context, how does middleware work. - Routing: radix tree, path params vs query params, route groups, route conflicts.
- Request handling: Bind vs ShouldBind, validation tags, file uploads, multipart forms.
- Middleware: custom middleware, Abort vs Next, onion model, error middleware.
- Auth: JWT flow, refresh tokens, storing tokens, revocation strategies.
- Production concerns: graceful shutdown, logging, health checks, observability, rate limiting.
- Testing: httptest, mocking, integration vs unit tests.
- System design: multi-tenant SaaS APIs, rate-limited public APIs, real-time WebSocket services, high-throughput event ingestion.
gin.H?" and stop. This section focuses on reasoning questions: why does Gin pool contexts, what breaks if you capture c in a goroutine, how would you implement per-tenant rate limiting, what's the difference between c.Abort and return. These are the questions senior Go engineers actually get asked.- Read the Gin source:
gin.go,context.go, andtree.gotogether are fewer than 3000 lines. You'll understand the framework better than 90% of candidates. - Build something non-trivial: a REST API with JWT + GORM + tests + Docker. Reference it in interviews.
- Benchmarks: be able to explain why Gin is fast (radix tree + sync.Pool + minimal allocations) and where it isn't (JSON encoding is the bottleneck).
- Compare frameworks honestly: know when Chi or Echo might be a better fit so you can answer "why Gin?" confidently.
Core Concepts
gin.H is simply map[string]any. It's a shortcut for building quick JSON responses without defining a struct. Use structs for complex/reusable responses, gin.H for simple one-offs.
ShouldBindJSON returns the error to you — you decide the response. BindJSON auto-responds with 400 and calls c.Abort(). Always prefer ShouldBind* for full control over error responses.
Gin stores handlers as a chain (slice). c.Next() advances to the next handler — code after Next() runs on the way BACK. c.Abort() stops the chain; remaining handlers won't execute. This is how logging middleware measures latency (before/after Next()) and auth middleware blocks unauthorized requests.
Use c.Set("key", value) in middleware and c.Get("key") or typed getters like c.GetInt("key") in the handler. The context's key-value store is request-scoped and lives for one request only.
Separate concerns: handler (HTTP layer, knows Gin), service (business logic, no HTTP), repository (data access, no business logic). Wire them with dependency injection in main.go. Use internal/ to enforce boundaries. Route groups map to resource types.
Advanced
No. *gin.Context is NOT goroutine-safe. If you need to use it in a goroutine, copy it first with c.Copy():
cp := c.Copy() // snapshot current request data safely
go func() { // run work in a background goroutine
// use cp, not c
log.Println(cp.ClientIP()) // safe: reading from copied context
}()
Gin uses a radix tree (compressed trie) for routing via httprouter. Route parameters are extracted in-place without allocating new strings for each request. The Context object is pooled with sync.Pool — reused across requests instead of being garbage collected each time.
Create an http.Server manually, start it in a goroutine, then listen for OS signals (SIGINT/SIGTERM). On signal, call srv.Shutdown(ctx) with a timeout context. This lets in-flight requests finish (up to the timeout) before the server stops. Critical for Kubernetes deployments where pods get SIGTERM before being killed.
Gin: largest ecosystem, most tutorials, best for teams. Slightly opinionated. Echo: similar perf, cleaner API for some, smaller community. Chi: lightweight, stdlib-compatible (net/http handlers), best for projects that want minimal framework lock-in. Choose Gin for most new projects due to ecosystem. Choose Chi if you want to stay close to stdlib.
- Build a CRUD API with Gin + GORM
- Add JWT auth + middleware
- Write tests with
httptest - Add Swagger docs (
swaggo/gin-swagger) - Deploy with Docker + graceful shutdown
- Add WebSocket support (
gorilla/websocket)
You know Go. You know Gin. Now ship something.