You need an endpoint, not a script
You wrote a Go program that processes data. It runs perfectly when you execute it in the terminal. Now a teammate needs to trigger that processing from a web dashboard. You could reach for net/http and build a mux from scratch, but you also want path parameters, JSON binding, middleware for logging, and a clean way to group routes. You need a framework that gives you structure without hiding the Go runtime. Gin is a popular choice for this. It provides a fast router, a context object that carries request data, and helpers for common HTTP tasks. It stays close to the standard library while removing the boilerplate.
Gin adds structure to the standard library
Gin sits on top of Go's net/http. It does not replace the server implementation. It wraps the router and the request handling logic. The core concept is the router, which matches URL paths to handler functions. Each handler receives a *gin.Context. This context holds the request, the response writer, and a value map that persists across middleware and handlers.
Think of Gin as a structured pipeline. A request enters the router. Middleware functions inspect or modify the request in sequence. If the request passes all checks, the handler runs. The handler writes a response. Control flows back through the middleware, allowing post-processing like timing or metrics. The context object is the thread that ties this chain together. It carries data from one stage to the next.
Minimal server
Here's the smallest working server. It creates a router, defines a single route, and starts listening on port 8080.
package main
import (
// Gin provides the router and context helpers.
"github.com/gin-gonic/gin"
)
func main() {
// Default sets up logger and recovery middleware automatically.
r := gin.Default()
// GET registers a handler for the path.
r.GET("/hello", func(c *gin.Context) {
// JSON sends a response with Content-Type: application/json.
c.JSON(200, gin.H{"message": "Hello World"})
})
// Run starts the server and blocks until interrupted.
r.Run(":8080")
}
When you run this, gin.Default() creates an engine instance. It registers two middleware functions by default. The logger prints request details to stdout. The recovery middleware catches panics and returns a 500 error instead of crashing the server. The r.GET call registers an anonymous function to handle requests to /hello. The c parameter is the context. It gives you access to the request and response. The c.JSON call encodes the map to JSON and writes it to the response body. The r.Run call starts the HTTP server. It blocks the main goroutine, keeping the program alive.
Gofmt handles indentation and formatting. Configure your editor to run gofmt on save. The community expects code to follow the tool's output. Arguing about formatting wastes time. Trust the tool.
Realistic handler with binding
Real APIs handle dynamic data. You'll parse JSON bodies, extract path parameters, and return structured errors. Here's a handler that creates a task from a JSON request and returns it with a 201 status.
package main
import (
"github.com/gin-gonic/gin"
"net/http"
)
// Task represents a work item with a title and status.
type Task struct {
// Title is the name of the task.
Title string `json:"title"`
// Done tracks completion status.
Done bool `json:"done"`
}
// CreateTask handles POST /tasks.
func CreateTask(c *gin.Context) {
// BindJSON reads the body and decodes into the struct.
var t Task
if err := c.BindJSON(&t); err != nil {
// Abort stops the chain and sends an error response.
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// In a real app, save t to a database here.
// Echo the task back with a created status.
c.JSON(http.StatusCreated, t)
}
func main() {
r := gin.Default()
r.POST("/tasks", CreateTask)
r.Run(":8080")
}
The BindJSON method checks the Content-Type header and decodes the request body into the Task struct. If the JSON is malformed or required fields are missing, it returns an error. The handler checks the error and calls AbortWithStatusJSON. This stops the middleware chain and sends the response immediately. The struct tags json:"title" map Go fields to JSON keys. Go uses capital letters for exported fields. JSON conventions often use lowercase. The tags bridge that gap.
Error handling in Go is verbose by design. The if err != nil pattern makes the unhappy path visible. Don't hide errors in defer blocks or swallow them silently. Write the check. Return the error. The caller decides what to do.
Middleware controls the flow
Middleware runs before the handler. It can inspect headers, validate tokens, or inject values into the context. Middleware must call c.Next() to proceed to the next step. If it doesn't call c.Next(), the handler never runs. Middleware can also run code after the handler returns. This allows post-processing like measuring response time.
Here's a custom middleware that checks for an authorization token and stores a user ID in the context.
// AuthMiddleware validates the token and sets user info.
func AuthMiddleware(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
// In a real app, validate the token and extract the user ID.
// Store the ID in the context for downstream handlers.
c.Set("user_id", "123")
c.Next()
}
func main() {
r := gin.Default()
// Use registers middleware globally.
r.Use(AuthMiddleware)
r.GET("/protected", func(c *gin.Context) {
// Get retrieves the value set by middleware.
userID, exists := c.Get("user_id")
if !exists {
c.JSON(500, gin.H{"error": "user not found"})
return
}
c.JSON(200, gin.H{"user": userID})
})
r.Run(":8080")
}
The middleware checks the header. If the token is missing, it aborts with a 401 status. If the token is present, it stores the user ID in the context using c.Set. The handler retrieves the value using c.Get. The Get method returns the value and a boolean indicating existence. Always check the boolean. Accessing a missing key returns a zero value, which can hide bugs.
Middleware order matters. Middleware runs in the order you register it. If you register authentication after logging, the log runs first. If you register recovery after a handler that panics, the panic propagates. Register recovery early. Register authentication before routes that need it. Use route groups to apply middleware to subsets of routes.
Grouping routes keeps the router organized. You can apply middleware to a group and define paths relative to the group prefix.
func main() {
r := gin.Default()
// Group creates a sub-router with a prefix.
v1 := r.Group("/v1")
v1.Use(AuthMiddleware)
{
v1.GET("/users", ListUsers)
v1.POST("/users", CreateUser)
}
r.Run(":8080")
}
The group inherits the prefix /v1. Routes inside the group get the prefix automatically. Middleware registered on the group applies only to routes in the group. This keeps the main router clean and scopes middleware to where it belongs.
Context is plumbing. Run it through every long-lived call site. If you pass the context to database calls or external services, they can respect cancellation and deadlines. Functions that take a context should have it as the first parameter. Name it ctx. This convention allows tools to analyze context propagation and detect leaks.
Pitfalls and runtime behavior
Gin hides some runtime behavior that can trip you up. If you forget to write a response, the client hangs. Gin does not auto-send a 200 status. You must call c.JSON, c.String, c.Data, or another writer method. If you reach the end of a handler without writing, the connection stays open until the client times out. The server log will show a slow request. Always write a response or abort.
Path parameters require a colon prefix. If you define r.GET("/users/:id"), it matches /users/123. It does not match /users. The router is strict. A common mistake is mixing up path parameters and query parameters. Path parameters are part of the URL structure. Query parameters live after the question mark. Gin accesses query parameters via c.Query("key"). Path parameters are accessed via c.Param("key").
Goroutine leaks happen when you spawn a goroutine inside a handler and pass the request context. The request context gets cancelled when the response is sent. If the goroutine waits on that context, it stops. If the goroutine needs to run longer, you must detach the context. Use context.WithoutCancel or create a new background context. Be careful. Background goroutines must have their own cancellation path, or they leak memory and block shutdown. The worst goroutine bug is the one that never logs.
If you try to bind JSON to a struct with unexported fields, BindJSON fails. The compiler won't catch this. The error message at runtime mentions unexported fields. If you pass the wrong type to c.JSON, the compiler rejects it. For example, passing a string where a map is expected triggers cannot use "text" (untyped string constant) as map[string]any value in argument. Read the error. Fix the type.
Receiver naming follows Go convention. If you define methods on a struct, use a short name like (t *Task). Don't use (this *Task) or (self *Task). The community expects brevity. The receiver name should match the type initials.
When to use Gin
Use Gin when you need a fast, feature-rich router with built-in JSON binding and middleware. Use the standard library net/http when you want zero dependencies and full control over the mux implementation. Use Chi when you prefer a minimal router that stays close to the standard library interface. Use Echo when you need a framework with similar features to Gin but a different API style. Use Fiber when you prioritize raw performance and are comfortable with a Go implementation of the Express.js API.
Goroutines are cheap. Channels are not magic. Pick the tool that matches the problem.