Gin is a Go HTTP framework that adds a compact router, request binding, validation, middleware, and response helpers around the standard net/http ecosystem. It is useful when an API has enough routes and request types that writing those conveniences repeatedly becomes distracting.
This tutorial builds a small in-memory users API with consistent JSON errors and router-level tests. The application logic stays independent of gin.Context, so choosing Gin does not force the framework into every layer of the program.
This guide targets Gin v1.12.0 and Go 1.25 or newer, matching the current official Gin quickstart. The examples follow the official API documentation but were not executed locally in this workspace.
If you have not built an HTTP service without a framework yet, start with the complete REST API tutorial. It explains the request, response, storage, and error boundaries that Gin is about to shorten. If the Go syntax itself is unfamiliar, begin with the complete Go tutorial.
Create the module and install Gin
Start a module and add Gin:
mkdir gin-users-api
cd gin-users-api
go mod init example.com/gin-users-api
go get github.com/gin-gonic/[email protected]
Pinning the version makes the example reproducible. Run go get -u github.com/gin-gonic/gin later when you deliberately want to review and adopt a newer release.
Model the API separately from the router
The API stores a small user record. The request type is separate from the response type so clients cannot choose server-owned fields such as the ID.
package main
import (
"net/http"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type createUserRequest struct {
Name string `json:"name" binding:"required,min=2,max=80"`
}
type userStore struct {
mu sync.RWMutex
nextID int64
users map[int64]User
}
func newUserStore() *userStore {
return &userStore{nextID: 1, users: make(map[int64]User)}
}
func (s *userStore) create(name string) User {
s.mu.Lock()
defer s.mu.Unlock()
user := User{ID: s.nextID, Name: name}
s.users[user.ID] = user
s.nextID++
return user
}
func (s *userStore) find(id int64) (User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[id]
return user, ok
}
The mutex matters even in a tutorial. Gin serves requests concurrently, so an unprotected map would race as soon as two requests read and write together. A real application would replace this store with a database-backed repository, while the handlers could keep the same shape.
Build the router explicitly
gin.Default() installs Gin’s logger and recovery middleware automatically. gin.New() starts with no middleware, making the stack visible and easier to tailor. This tutorial uses the explicit form:
func newRouter(store *userStore) *gin.Engine {
router := gin.New()
router.Use(gin.Logger(), gin.Recovery(), requestID())
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
api := router.Group("/api/v1")
api.POST("/users", createUser(store))
api.GET("/users/:id", getUser(store))
return router
}
Route groups keep a shared prefix together and provide a natural place for group-specific authentication or rate limiting. Global middleware applies to every route, while middleware passed directly to a route applies only there.
Bind and validate JSON without losing error control
Gin offers Bind and ShouldBind families. The Bind methods write a 400 response automatically on failure. ShouldBindJSON returns the error, which is normally preferable when an API has its own error envelope.
func createUser(store *userStore) gin.HandlerFunc {
return func(c *gin.Context) {
var input createUserRequest
if err := c.ShouldBindJSON(&input); err != nil {
writeError(c, http.StatusBadRequest, "invalid_request", "name must contain 2 to 80 characters")
return
}
name := strings.TrimSpace(input.Name)
if len(name) < 2 {
writeError(c, http.StatusBadRequest, "invalid_request", "name must contain 2 to 80 characters")
return
}
user := store.create(name)
c.Header("Location", "/api/v1/users/"+strconv.FormatInt(user.ID, 10))
c.JSON(http.StatusCreated, user)
}
}
The binding tag handles representation rules. The explicit trim check is application policy. Keeping those ideas separate prevents framework validation tags from becoming the only place business rules live.
Do not return Gin’s raw binding error directly to public clients. It can expose field and validator details that are inconsistent across endpoints. Log diagnostic detail where appropriate, then return a stable code and safe message.
Parse path parameters and return consistent errors
Path parameters arrive as strings. Reject malformed identifiers separately from records that do not exist:
func getUser(store *userStore) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id < 1 {
writeError(c, http.StatusBadRequest, "invalid_id", "user id must be a positive integer")
return
}
user, ok := store.find(id)
if !ok {
writeError(c, http.StatusNotFound, "not_found", "user was not found")
return
}
c.JSON(http.StatusOK, user)
}
}
func writeError(c *gin.Context, status int, code, message string) {
c.JSON(status, gin.H{
"error": gin.H{
"code": code,
"message": message,
},
})
}
This creates a predictable error contract. Clients can branch on error.code without matching prose, and handlers map each known condition to an intentional status.
Add middleware without coupling services to Gin
Gin middleware is a gin.HandlerFunc. Code before c.Next() runs on the way into the handler; code after it runs on the way out.
func requestID() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader("X-Request-ID")
if id == "" {
id = "local-request"
}
c.Header("X-Request-ID", id)
c.Set("request_id", id)
c.Next()
}
}
Use middleware for cross-cutting HTTP concerns such as authentication, tracing, security headers, and recovery. Do not hide ordinary business decisions in middleware. The Go middleware guide explains ordering and composition in more depth.
When a service call needs cancellation or a deadline, pass c.Request.Context() into it. Application services should accept context.Context, not *gin.Context. That boundary keeps services reusable in background jobs, tests, and another transport.
Limit request bodies before binding
JSON binding reads the request body, so place a deliberate limit in front of endpoints that accept input. A small API should not accept an unlimited upload merely because the handler expects a tiny object.
func limitBody(maxBytes int64) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
c.Next()
}
}
Attach it globally with router.Use(limitBody(1 << 20)) for a 1 MiB ceiling, or apply a tighter value only to a route group. Choose the limit from the largest legitimate payload rather than copying a convenient number. If an endpoint truly accepts large files, stream them and give that route a separate policy.
Content type is another boundary. For a JSON-only endpoint, require Content-Type: application/json and return 415 Unsupported Media Type for anything else. Binding convenience should not make the accepted protocol ambiguous.
Start the server with explicit timeouts
router.Run() is convenient during local exploration, but a production-shaped program should configure http.Server directly:
func main() {
router := newRouter(newUserStore())
server := &http.Server{
Addr: ":8080",
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(server.ListenAndServe())
}
Add log and time to the imports in the complete program. Timeouts prevent slow clients from holding connections indefinitely. Graceful shutdown with Server.Shutdown is the next step for a deployed service.
Test the router with httptest
Gin implements http.Handler, so standard-library HTTP tests work without opening a port. Set test mode before building the router:
func TestCreateAndGetUser(t *testing.T) {
gin.SetMode(gin.TestMode)
router := newRouter(newUserStore())
create := httptest.NewRecorder()
createReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"name":"Kelly"}`),
)
createReq.Header.Set("Content-Type", "application/json")
router.ServeHTTP(create, createReq)
if create.Code != http.StatusCreated {
t.Fatalf("create status = %d; want %d", create.Code, http.StatusCreated)
}
get := httptest.NewRecorder()
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/users/1", nil)
router.ServeHTTP(get, getReq)
if get.Code != http.StatusOK {
t.Fatalf("get status = %d; want %d", get.Code, http.StatusOK)
}
if !strings.Contains(get.Body.String(), `"name":"Kelly"`) {
t.Fatalf("unexpected body: %s", get.Body.String())
}
}
Add net/http/httptest and testing to the test file imports. Write separate cases for malformed JSON, validation failure, an invalid path ID, and a missing user. Those failures are part of the API contract, not secondary edge cases.
A useful handler test checks four things: status, important headers, response shape, and externally visible state. Avoid comparing a complete JSON string when field order is irrelevant. Decode the response into a small struct or map and assert the fields clients depend on. Keep one test for the error envelope as well, because a handler that returns the right status with an inconsistent body still breaks clients.
For middleware, register a probe handler that records values and headers after the middleware runs. That is faster and clearer than starting a real server. Use an end-to-end server test only when behavior depends on sockets, TLS, connection handling, or server timeouts.
Keep the framework at the edge
The easiest Gin application to maintain has three visible layers. Handlers translate HTTP input into ordinary Go values. Services make business decisions using context.Context and domain types. Repositories handle persistence and translate driver failures into application errors. Only the first layer imports Gin.
That separation pays off immediately in tests. Service tests do not need a recorder or framework context, repository tests do not know which router called them, and router tests can replace both layers with small fakes. It also prevents a future framework migration from becoming a rewrite of the application core.
Avoid placing the store in package-level variables. Construct dependencies in main, pass them into newRouter, and capture them in handler closures as this tutorial does. Explicit construction makes ownership visible and avoids tests that affect one another through shared state.
Common Gin mistakes
Passing *gin.Context into repositories or services. Pass ordinary values and c.Request.Context() instead. Framework context belongs at the transport boundary.
Using automatic binding and then trying to change the response. Bind may already write a 400 status. Use ShouldBindJSON when you need a consistent custom response.
Returning internal errors directly. Translate known failures into stable public codes and log unexpected details server-side.
Starting with router.Run() and forgetting server limits. Use an explicit http.Server before deploying publicly.
Treating recovery as correctness. gin.Recovery() keeps a panic from ending the request process, but it does not make panics an acceptable substitute for ordinary errors.
When Gin is the right choice
Gin earns its dependency when route grouping, binding, validation, middleware, and response helpers remove repeated code across a growing API. It is also approachable for teams coming from frameworks with explicit route registration and request contexts.
Stay with net/http when the service has only a handful of routes, when minimizing dependencies is a firm requirement, or when standard-library portability matters more than convenience. The underlying performance difference is rarely the first decision to optimize. Handler design, database access, network calls, payload sizes, and observability normally matter more than router nanoseconds.
What next
Gin changes the HTTP adapter, not the architecture beneath it. Continue with:
- Building a REST API with Go for complete storage, error, and shutdown boundaries.
- Go HTTP middleware for reusable request processing and ordering.
- Testing in Go for table tests, HTTP tests, fakes, coverage, and fuzzing.
- Go context for cancellation and deadlines passed from requests into services.
Choose Gin when its router, binding, and middleware remove repeated transport code. Keep the core of the application written in ordinary Go so the framework remains a replaceable edge rather than the architecture itself.