Gin is a thin HTTP router over net/http with a fast radix-tree matcher, request binding, and a middleware chain. gin.Default() gets you a server in six lines. What the quick-start leaves out is everything that makes it survive contact with production: graceful shutdown, structured errors, request timeouts, and turning off the debug mode that logs every route on startup.
This is the setup worth copying — the shape a Gin service should have on day one, rather than the shape it acquires after three incidents.
Table of contents
- Routing and parameters
- Binding and validation
- Middleware that does something
- Graceful shutdown and timeouts
- Release mode and structured logging
- Configuration and deployment
- How this fits the rest of the stack
- FAQ
Routing and parameters
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default() // Logger + Recovery middleware
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
api := r.Group("/api/v1")
{
api.GET("/users", listUsers)
api.GET("/users/:id", getUser)
api.POST("/users", createUser)
api.DELETE("/users/:id", deleteUser)
}
r.Run(":8080")
}
func getUser(c *gin.Context) {
id := c.Param("id") // path parameter
verbose := c.DefaultQuery("verbose", "false") // query string
c.JSON(http.StatusOK, gin.H{"id": id, "verbose": verbose})
}
gin.Default() is gin.New() plus the Logger and Recovery middleware. Recovery catches panics and returns a 500 instead of killing the process, which you want. Logger writes a line per request, which you may want to replace with something structured.
Route groups are worth using from the start — they give you a place to attach middleware that applies to a subtree, which is how authentication ends up scoped correctly rather than applied by hand per route.
Binding and validation
This is Gin’s most useful feature and where most of its ergonomics live.
type CreateUserRequest struct {
Name string `json:"name" binding:"required,min=2,max=64"`
Email string `json:"email" binding:"required,email"`
Age int `json:"age" binding:"omitempty,gte=0,lte=130"`
Role string `json:"role" binding:"required,oneof=admin member viewer"`
}
func createUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// req is validated here
c.JSON(http.StatusCreated, gin.H{"name": req.Name})
}
Use ShouldBindJSON, not BindJSON. The Bind family writes a 400 and aborts automatically, which means you cannot control the error shape and it will happily write a response you did not design. ShouldBind returns the error and leaves the response to you.
The binding tags come from go-playground/validator and cover most of what you need: required, email, url, uuid, min, max, gte, lte, oneof, len. omitempty means “validate only if present”, which is how you express an optional field with constraints.
The raw validator error is unfriendly. For an API other people consume, translate it:
import "github.com/go-playground/validator/v10"
func bindErrors(err error) map[string]string {
out := map[string]string{}
var ve validator.ValidationErrors
if errors.As(err, &ve) {
for _, fe := range ve {
out[fe.Field()] = fe.Tag()
}
}
return out
}
Middleware that does something
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
c.Set("requestID", id)
c.Writer.Header().Set("X-Request-ID", id)
c.Next()
}
}
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
userID, err := verify(token)
if err != nil {
// AbortWithStatusJSON stops the chain -- return alone does not
c.AbortWithStatusJSON(http.StatusUnauthorized,
gin.H{"error": "invalid token"})
return
}
c.Set("userID", userID)
c.Next()
}
}
// Applied to a group
protected := r.Group("/api/v1", RequestID(), Auth())
c.Abort() is the part people get wrong. Returning from a middleware without calling Abort lets the chain continue to the handler. An auth middleware that returns 401 and forgets to abort still runs the protected handler, which is a security hole that tests rarely catch because the status code looks right.
c.Next() runs the rest of the chain, so anything after it executes on the way back out — that is where you put timing and response logging.
Graceful shutdown and timeouts
r.Run() is fine for development and wrong for production: it has no timeouts and no shutdown handling, so a deploy cuts in-flight requests off mid-response.
func main() {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery(), RequestID())
// ... routes
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("forced shutdown: %v", err)
}
}
ReadHeaderTimeout is the one that protects against Slowloris — a client that opens a connection and sends headers one byte at a time. Without it, a handful of clients can exhaust your connection capacity using almost no bandwidth.
The SIGTERM handler is what makes a deploy clean. Orchestrators send SIGTERM and wait before SIGKILL; catching it and calling Shutdown means in-flight requests finish rather than being severed.
Release mode and structured logging
// Set explicitly, or via the GIN_MODE=release environment variable
gin.SetMode(gin.ReleaseMode)
Debug mode prints every registered route at startup and adds a warning banner to the logs. It also affects error verbosity. Leaving it on in production is noisy and leaks routing structure into your logs.
Replace the default logger with something structured, so your logs are queryable fields rather than a formatted line:
func Logger(l *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
l.Info("request",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"status", c.Writer.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", c.GetString("requestID"),
)
}
}
log/slog is in the standard library now, so this needs no dependency. One JSON line per request with a request ID is what makes an incident searchable rather than a scrolling exercise.
Configuration and deployment
type Config struct {
Port string
DatabaseURL string
}
func load() Config {
return Config{
Port: getenv("PORT", "8080"),
DatabaseURL: mustEnv("DATABASE_URL"),
}
}
mustEnv failing loudly at startup is better than a nil database handle producing a confusing panic on the first request. Fail fast on missing configuration.
Go’s deployment story is genuinely simple: go build produces a single static binary with no runtime to install. A minimal Dockerfile with a build stage and a scratch or distroless final stage produces an image of a few megabytes.
That is the shape RunxBuild builds from a connected GitHub repository — a Go service, built from the repo, with a build log for the compile, a live route, environment variables for DATABASE_URL and friends, and runtime logs for the requests. Point it at a managed Postgres or MySQL instance on the private network and the DATABASE_URL your mustEnv reads is one of the environment variables rather than a secret in the image.
How this fits the rest of the stack
Use ShouldBindJSON rather than BindJSON so you own the error shape. Call c.Abort() in any middleware that rejects a request. Set gin.ReleaseMode, replace the default logger with structured output, and configure http.Server yourself so you get read and write timeouts and a graceful Shutdown on SIGTERM.
That is about forty lines beyond the README and it is the difference between a demo and a service. If you are working out what running it alongside a managed database costs, the RunxBuild hosting calculator shows the service, the database, the storage, and the bandwidth as separate line items.
Useful related references:
- Golang Hosting in 2026: Where a Go App Actually Wants to Live
- Golang Environment Variables: The Boring Truth About os.Getenv, the Library Most Teams Reach For, and One They Should Not
- Services on RunxBuild
FAQ
What is the difference between BindJSON and ShouldBindJSON in Gin?
BindJSON writes a 400 response and aborts automatically on failure, so you cannot control the error format. ShouldBindJSON returns the error and leaves the response to you. Use ShouldBindJSON in any API where the error shape matters to consumers, which is most of them.
How do I add middleware to specific routes in Gin?
Pass handlers to r.Group("/path", Middleware()), or call group.Use(Middleware()) after creating the group. Middleware applies to every route in that group and any nested groups, which is how authentication gets scoped correctly rather than repeated per route.
Why does my Gin middleware not stop the request?
Returning from a middleware function does not stop the chain — the handler still runs. You must call c.Abort() or c.AbortWithStatusJSON(...) to prevent it. This is a common security bug: an auth middleware returns 401 but the protected handler executes anyway.
How do I gracefully shut down a Gin server?
Do not use r.Run(). Create an http.Server with the Gin engine as its handler, start it in a goroutine, listen for SIGINT and SIGTERM, then call srv.Shutdown(ctx) with a timeout. In-flight requests complete instead of being cut off during a deploy or restart.
Should I set gin.ReleaseMode in production?
Yes. Debug mode logs every registered route at startup, prints a warning banner, and produces more verbose errors. Set it with gin.SetMode(gin.ReleaseMode) or the GIN_MODE=release environment variable so it can be configured per deployment.