Building a REST API in Go with Gin
Walk through building a working REST API in Go with Gin — routes, groups, middleware, JSON binding, and consistent error responses.

The first time you reach for Gin after writing Express, the routing syntax looks familiar enough that you expect the rest to follow. It mostly does — but a few things diverge in ways that are not obvious until you hit them. The body-parsing story is different. Middleware registration order fails silently rather than throwing. And the two functions for binding JSON — BindJSON and ShouldBindJSON — behave differently in a way that will surprise you the first time a client receives a response you never wrote.
This guide builds a complete REST API from scratch: Go module setup, routes for the four main HTTP methods, a versioned route group, two middleware functions, and JSON binding with struct-level validation. The equivalent Express walkthrough covers the same API shape — reading both side by side shows where the patterns converge and where they genuinely differ.
Quick answer: Install Gin with
go get github.com/gin-gonic/gin, create a router withgin.Default(), bind request bodies withc.ShouldBindJSON(&target)usingbinding:"required"struct tags for validation, and always write the error response andreturnafter a binding failure — Gin does not write the error response for you when you useShouldBindJSON.
On this page
- Setting up the Go module and installing Gin
- Defining routes and reading path parameters
- Grouping routes by version prefix
- Writing and registering middleware
- Binding JSON and validating request bodies
- Mistakes that cause silent 200s and confusing 400s
- Explicit beats implicit when the API hits production
Setting up the Go module and installing Gin
Go requires a module before you can add dependencies. Create a directory and initialise it:
mkdir notes-api
cd notes-api
go mod init notes-api
Then fetch Gin:
go get github.com/gin-gonic/gin
This writes github.com/gin-gonic/gin to go.mod and downloads the package into the module cache. Create main.go and wire up the minimal server:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
r.Run(":8080")
}
Run it with go run . and request http://localhost:8080/health. You get {"status":"ok"}.
gin.Default() vs gin.New() is the first choice you make. gin.Default() pre-registers two middleware functions automatically: a request logger that prints each request to stdout, and a recovery handler that catches panics and returns a 500 instead of crashing the process. gin.New() gives you a bare router with nothing attached. Start with gin.Default() — the panic recovery is valuable from the first request. If you need to replace either piece with a custom implementation, switch to gin.New() and add your own.
Defining routes and reading path parameters
Each route handler receives a *gin.Context. The context carries the request, writes the response, and provides helpers for reading every kind of parameter:
// Path parameter: GET /notes/42
r.GET("/notes/:id", func(c *gin.Context) {
id := c.Param("id")
c.JSON(http.StatusOK, gin.H{"id": id})
})
// Query string: GET /notes?limit=10
r.GET("/notes", func(c *gin.Context) {
limit := c.DefaultQuery("limit", "20")
c.JSON(http.StatusOK, gin.H{"limit": limit})
})
c.Param("id") reads the :id wildcard from the URL path — the colon prefix in the route definition marks it as a named segment. c.Query("key") reads a query string value and returns an empty string when the key is absent. c.DefaultQuery("key", "fallback") returns the fallback value instead, which is useful for optional pagination parameters.
For the other HTTP methods, the pattern is the same — just swap the method name:
r.POST("/notes", createNote)
r.PUT("/notes/:id", updateNote)
r.DELETE("/notes/:id", deleteNote)
Where createNote, updateNote, and deleteNote are functions with the signature func(c *gin.Context). Separating handlers into named functions keeps main.go readable as the API grows past a handful of routes.
Grouping routes by version prefix
router.Group() returns a *RouterGroup that accepts the same HTTP method functions and automatically prepends a shared prefix to every route registered on it:
func main() {
r := gin.Default()
v1 := r.Group("/api/v1")
{
v1.GET("/notes", listNotes)
v1.POST("/notes", createNote)
v1.GET("/notes/:id", getNote)
v1.PUT("/notes/:id", updateNote)
v1.DELETE("/notes/:id", deleteNote)
}
r.Run(":8080")
}
The inner braces are a Go block used for visual grouping — they are not a Gin construct and have no effect on behaviour. v1.GET("/notes", ...) responds to GET /api/v1/notes.
Versioning from the start has a low cost. The same principle applies to Express project structure — the changes that are painful after launch are usually the ones that touch every route. A group prefix added on day one costs five seconds; retrofitting it six months later touches every client.
Middleware registered on a group applies only to routes in that group:
v1 := r.Group("/api/v1")
v1.Use(authMiddleware())
// Routes added to v1 after this .Use() call run through authMiddleware.
// Routes outside v1 are unaffected.
Writing and registering middleware
A Gin middleware is a gin.HandlerFunc — which is just func(*gin.Context). The only meaningful addition over a regular handler is c.Next(). It runs all remaining handlers in the chain and then returns control to the middleware. Everything before c.Next() executes on the way in; everything after executes on the way out:
package main
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
)
func requestTimer() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next() // run the actual handler
elapsed := time.Since(start)
fmt.Printf("%s %s %d %v\n",
c.Request.Method,
c.Request.URL.Path,
c.Writer.Status(),
elapsed,
)
}
}
To stop the chain — for example when a required auth token is missing — call c.AbortWithStatusJSON(). It writes the response and prevents any remaining handlers from running:
func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "authorization header required",
})
return
}
c.Next()
}
}
Register middleware before the routes it needs to cover:
r := gin.New()
r.Use(gin.Recovery())
r.Use(requestTimer())
v1 := r.Group("/api/v1")
v1.Use(authMiddleware())
v1.GET("/notes", listNotes) // runs through Recovery, requestTimer, authMiddleware
Check this before moving on
- Middleware is registered before the routes it should protect
- Middleware that blocks a request calls
c.AbortWithStatusJSON, not justreturn -
c.Next()is called in middleware that should let the request continue
Binding JSON and validating request bodies
This is the sharpest difference from Express. With Express and express.json(), the middleware runs globally and populates req.body before your handler runs. In Gin, you declare a struct with field-level validation tags and call c.ShouldBindJSON inside the handler itself:
type CreateNoteInput struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Tags []string `json:"tags"`
}
func createNote(c *gin.Context) {
var input CreateNoteInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// input.Title and input.Content are guaranteed non-empty here
c.JSON(http.StatusCreated, gin.H{
"title": input.Title,
"content": input.Content,
"tags": input.Tags,
})
}
binding:"required" is a tag from go-playground/validator, which Gin uses internally. If a required field is missing or the JSON is malformed, ShouldBindJSON returns a non-nil error with a message naming the failing field. You handle that error and write the 400 response yourself — Gin does not write it for you.
The json:"title" tag maps the incoming JSON key "title" to the Go field Title. Without the tag, Gin looks for the capitalised form "Title" in the JSON body, which silently fails to bind.
Try this
- Start the API and send
POST /api/v1/noteswith headerContent-Type: application/jsonand body{"title": "Gin routing", "content": "Groups and middleware"}.- Send the same request with
contentremoved from the body.- Send the original request again without the
Content-Typeheader.Expected result: The first request returns 201. The second returns 400 with a message naming the missing
contentfield. The third also returns 400 —ShouldBindJSONchecks that the content type isapplication/json, so a valid JSON body with the wrong header fails binding too.
Mistakes that cause silent 200s and confusing 400s
Forgetting to return after the binding error. This is the most common mistake:
// WRONG — execution continues after writing the error response
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
// handler continues with a zero-value input struct
The handler writes the 400, then immediately continues with input holding its zero values. In the best case you get two writes to the same response and Gin logs a warning. In a worse case, a nil pointer in a zero-value struct causes a panic.
Using BindJSON instead of ShouldBindJSON. BindJSON calls c.AbortWithError automatically when binding fails, which sets the response status and marks the context as aborted. If you then try to write a custom error response in your if err != nil block, Gin drops the second write because the response has already started. Your client receives the default Gin error message, not the one you wrote. Use ShouldBindJSON whenever you want control over the error format — which is almost always.
Registering middleware after routes. This does nothing:
r.GET("/notes", listNotes)
r.Use(authMiddleware()) // too late — GET /notes is already registered
Middleware must be registered before the routes it should wrap. Gin does not warn you when middleware comes after an already-registered route; it simply skips the middleware for that route.
Missing the Content-Type header. Gin’s JSON binding requires Content-Type: application/json on the request. A POST with a valid JSON body but no content-type header fails binding with a message that resembles a parsing error rather than a missing header. The fix is always on the client — add the header.
| What you see | Actual cause | Where to fix |
|---|---|---|
| 400 on every POST | Missing Content-Type: application/json |
Client request |
| Zero values reaching the handler | Forgot return after the error check |
Handler code |
| Auth middleware has no effect | Middleware registered after the route | Router setup order |
| Client receives the default Gin error | Used BindJSON then tried to overwrite |
Switch to ShouldBindJSON |
Explicit beats implicit when the API hits production
The design of Gin is closer to the Go standard library than to Express: very little happens unless you ask for it. There is no global body parser middleware — you call ShouldBindJSON in each handler that needs it. There is no implicit validation — you declare it in struct tags alongside the fields being validated. There is no automatic error serialisation — you write it yourself, at the point where the error occurs.
That explicitness feels like friction in the first hour. Once the API is handling real traffic and you are tracing a 400 response at midnight, every error path is exactly where you looked for it: in the handler, next to the binding call, not buried in a middleware stack.
Start with gin.Default() to get logging and panic recovery for free. Use ShouldBindJSON over BindJSON from the first handler. Group routes under a version prefix before the API has more than a handful of endpoints. These three choices have minimal cost at the start and disproportionate payoff once the API grows.