How to Build a REST API with Gin in Go
You've spent time building APIs in JavaScript or Python. You know the pattern: install a framework, define routes, return JSON. Go's standard library offers net/http, which is powerful but barebones. It feels like assembling an engine from raw metal. You want a dashboard. Gin is that dashboard. It's a third-party framework that wraps the standard library and gives you routing, middleware, and JSON handling out of the box.
Gin is not a language feature. It's a library hosted on GitHub. The core object is the gin.Engine. You create an engine, attach handler functions to URL paths, and start listening for requests. The engine manages the lifecycle. It parses the incoming HTTP request, matches the URL against your routes, runs any middleware, calls your handler, and writes the response back to the client.
The minimal server
Here's the simplest Gin server. It initializes the engine, defines one route, and starts listening.
package main
import (
"github.com/gin-gonic/gin"
)
func main() {
// gin.Default sets up a router with Logger and Recovery middleware enabled.
// Logger prints request details to stdout. Recovery catches panics and returns 500.
r := gin.Default()
// GET maps a path to a handler function.
// The handler receives a *gin.Context which holds request and response data.
r.GET("/", func(c *gin.Context) {
// JSON writes a map as a JSON response with Content-Type: application/json.
// 200 is the HTTP status code.
c.JSON(200, gin.H{"message": "Hello World"})
})
// Run starts the HTTP server on localhost:8080.
// It blocks until the process is killed.
r.Run(":8080")
}
Save this as main.go. Run go mod init myapi to initialize the module. Run go get github.com/gin-gonic/gin to download the dependency. Start the server with go run main.go. Visit http://localhost:8080 in your browser. You'll see the JSON response.
The gin.Default() call is the starting point. It creates a router and enables two middleware functions by default. The Logger middleware prints a line to standard output for every request, showing the method, path, status code, and latency. The Recovery middleware catches panics inside your handlers. If your code panics, Gin recovers and returns a 500 Internal Server Error instead of crashing the server.
The handler function takes one argument: c *gin.Context. This context is the heart of Gin. It holds the HTTP request, the response writer, URL parameters, query strings, and a way to abort the request. You use methods on c to read input and write output.
c.JSON serializes a Go value to JSON. The first argument is the HTTP status code. The second argument is the data. Gin uses the standard library's encoding/json package under the hood. You can pass a map, a struct, or a slice. Gin sets the Content-Type header to application/json automatically.
Gin is fast. The router uses a radix tree for efficient path matching. Trust the router. Define clean paths and let Gin handle the dispatch.
Path parameters
REST APIs rarely serve just one static resource. You need to fetch specific items by ID. Gin uses a colon prefix to mark path parameters.
package main
import (
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// GET handles dynamic routes with path parameters.
// The :id syntax captures the value at that segment.
r.GET("/users/:id", func(c *gin.Context) {
// Param retrieves the value of a named parameter from the URL.
// It returns an empty string if the parameter is missing.
id := c.Param("id")
// JSON returns the ID back to the client.
// In production, you would query the database using this ID.
c.JSON(200, gin.H{"id": id})
})
r.Run(":8080")
}
The route /users/:id matches /users/1, /users/abc, or /users/12345. The value after the slash is captured and stored in the context. You retrieve it with c.Param("id"). The key must match the name in the route definition exactly.
If you request /users/42, the handler receives "42" as a string. Gin does not convert types automatically. You must parse the string yourself if you need an integer. Use strconv.Atoi for integers or strconv.ParseFloat for floats.
Forgetting the colon creates a literal match. r.GET("/users/id") matches only the path /users/id. It does not capture a variable. The compiler won't catch this mistake. The route simply won't match your requests.
The router matches patterns. You extract values. Keep paths clean and predictable.
Binding JSON payloads
Real APIs accept data. Clients send JSON bodies, and your server needs to parse them. Gin provides BindJSON to decode the request body into a Go struct.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// User represents the JSON structure for user data.
// json tags map Go field names to JSON keys.
type User struct {
Name string `json:"name"`
Email string `json:"email"`
}
func main() {
r := gin.Default()
// POST handles incoming JSON payloads.
// The handler binds the request body to a User struct.
r.POST("/users", func(c *gin.Context) {
var user User
// BindJSON reads the request body and decodes it into the struct.
// It returns an error if the JSON is malformed or fields are missing.
if err := c.BindJSON(&user); err != nil {
// JSON returns an error response with the provided status code.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// In a real app, you would save the user to a database here.
// For now, echo the data back to verify binding worked.
c.JSON(http.StatusCreated, user)
})
r.Run(":8080")
}
The User struct defines the expected shape of the JSON. The backtick strings are struct tags. They tell the JSON decoder how to map JSON keys to Go fields. json:"name" means the JSON key "name" maps to the Name field. If you omit the tag, the decoder uses the field name as-is, but lowercase. Tags give you control over the API contract.
c.BindJSON takes a pointer to the struct. You must pass &user, not user. The decoder needs to modify the struct fields. If you pass a value, the compiler rejects the program with cannot use user (variable of struct type User) as *User value in argument.
BindJSON returns an error if the JSON is invalid or if required fields are missing. The handler checks the error and returns a 400 Bad Request response. This is the standard pattern for validation. You reject bad input early.
Gin also supports ShouldBindJSON. It does the same decoding but does not write an error response automatically. It returns the error so you can handle it however you like. Use BindJSON for simple validation. Use ShouldBindJSON when you need custom error messages or complex validation logic.
Structs enforce structure. Maps are flexible but fragile. Use structs for production APIs.
Context is not Context
Go has a standard context.Context package for cancellation and deadlines. Gin has gin.Context. They are different types. This distinction matters when you pass context to database drivers or other libraries.
gin.Context wraps the standard context. You can access it via c.Request.Context(). This returns the context.Context associated with the HTTP request. It includes cancellation signals if the client disconnects.
When you call a database function, pass c.Request.Context(), not c. Database drivers expect the standard context. Passing gin.Context causes a type mismatch. The compiler complains with cannot use c (variable of struct type gin.Context) as context.Context value in argument.
Some developers alias gin.Context to avoid confusion. It's a personal preference. The key is knowing which context you're using. Gin context holds request data. Standard context holds cancellation and values.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and conventions
Gin simplifies API development, but it introduces its own patterns. Ignoring them leads to bugs or messy code.
Blocking goroutines inside handlers is a common mistake. If you spawn a goroutine to do background work, do not use the gin.Context after the handler returns. Gin reuses context objects for performance. The context might be overwritten by the next request. Copy any data you need before starting the goroutine.
The worst goroutine bug is the one that never logs. Always capture errors and log them.
gin.H is a type alias for map[string]any. It's convenient for quick responses. However, maps don't enforce structure. If you typo a key, the client gets a missing field. Structs enforce keys at compile time. Use gin.H for debugging. Use structs for production.
Middleware runs before and after your handler. Gin's Use method registers middleware. The middleware function takes c *gin.Context. You call c.Next() to proceed to the next handler. Forgetting c.Next() stops the chain. The handler never runs. The client hangs.
Middleware is a chain. Break the chain and the request dies.
Error handling in Gin handlers is explicit. Handlers don't return errors. You write the error response and return. This is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally swallow an error.
if err != nil { return err } is the standard Go pattern. In Gin, it becomes if err != nil { c.JSON(400, gin.H{"error": err.Error()}); return }. It's longer, but it's clear.
Don't fight the type system. Wrap the value or change the design.
When to use Gin
Go offers multiple ways to build HTTP servers. Choosing the right tool depends on your needs.
Use Gin when you need rapid development with routing, middleware, and JSON binding. It's a great choice for microservices and REST APIs where you want a productive workflow without sacrificing performance.
Use net/http when you want zero dependencies and full control. The standard library is sufficient for simple servers. It's also the foundation for all Go HTTP frameworks. If you need to customize the server behavior deeply, the standard library gives you the knobs.
Use Echo or Fiber when you need specific features like WebSocket support or async patterns. These frameworks offer different trade-offs in API design and performance.
Use a microservice framework like Kratos or Go-Zero when building large distributed systems. These frameworks provide service mesh integration, tracing, and configuration management out of the box.
Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.