Migrate a Go API From Gin to the Standard Library Mux
Move a Gin API to net/http route by route, using a parity test that finds the real differences: 405 versus 404 and trailing slashes.

Gin is a good framework, and most Gin applications do not need to leave it. Reasons to migrate are specific: you want handlers that are plain http.Handler, fewer dependencies, to drop a framework whose latest release needs a newer Go toolchain than you run, or to share middleware and tooling with services that already use the standard library. Go 1.22 made that move much easier by adding method and wildcard patterns to http.ServeMux, which covers most of what people used Gin's router for.
Tested with Go 1.25.6, gin v1.12.0, macOS on Apple Silicon (arm64)
This article is a practical path for the migration: how to do it route by route without a big-bang rewrite, a mapping table from Gin idioms to standard ones and a parity test that replays the same requests against both versions. Running that test turned up behaviour differences that no mapping table mentions, and they are the part worth reading before you start. If you are choosing for a new project, the comparison in the standard library as a framework is a better starting point; for the API shape, designing a REST API. It ran on Go 1.25.6 with Gin v1.12.0.
The strategy: strangle, do not rewrite
A Gin engine implements http.Handler. That makes a gradual migration possible: put a standard ServeMux in front, register the routes you have moved, and send everything else to Gin.
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/items/{id}", getItem) // migrated
mux.Handle("/", ginEngine) // everything else still served by Gin
http.ListenAndServe(":8080", mux)The mux prefers the most specific pattern, so a migrated route wins over the catch-all and the rest keep working unchanged. Each pull request moves one route or a small group, with its tests, and can be deployed and rolled back on its own. When the last route is gone, delete the fallback and the dependency. This is the same strangler pattern used for moving between services, applied inside one process.
Mapping the idioms
| Gin | Standard library |
|---|---|
r.GET("/items/:id", h) | mux.HandleFunc("GET /items/{id}", h) |
c.Param("id") | r.PathValue("id") |
c.Query("q"), c.DefaultQuery("limit", "10") | r.URL.Query().Get("q") and a default in your code |
c.ShouldBindJSON(&v) | json.NewDecoder(r.Body).Decode(&v), then validate |
binding:"required,max=40" | explicit checks, or validator called directly |
c.JSON(status, v) | a writeJSON(w, status, v) helper |
c.AbortWithStatusJSON(...) then return | write the response and return |
r.Group("/v1") | register full patterns, or wrap a sub-mux with http.StripPrefix |
r.Use(mw) and group.Use(mw) | func(http.Handler) http.Handler wrappers |
c.Set("user", u), c.Get("user") | context.WithValue with a private key type, read in the handler |
c.Next(), c.Abort() | call or skip next.ServeHTTP |
gin.Recovery() | a small recover middleware (recover middleware) |
c.FileAttachment, c.File | http.ServeFile, http.ServeContent |
c.ClientIP() | parse X-Forwarded-For with a trusted-proxy policy; do not trust it blindly |
Two items deserve care. Binding tags did two jobs, decoding and validating, and the replacement does both explicitly. You can keep go-playground/validator as a library without Gin and run validate.Struct(in) after decoding, which keeps the same tag syntax and the same rule names (validator in practice). And c.Set/c.Get stored request-scoped values on a map; the standard replacement is the request context, with a private key type, set in middleware and read with a typed helper.
The two apps
The lab contains the same API twice. The Gin version uses a group, a middleware group for auth, DefaultQuery, ShouldBindJSON with binding tags and AbortWithStatusJSON:
package main
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
}
type newItem struct {
Name string `json:"name" binding:"required,max=40"`
}
func ginApp() http.Handler {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
items := map[int]Item{1: {1, "keyboard"}, 2: {2, "mouse"}}
v1 := r.Group("/v1")
v1.GET("/items", func(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
out := []Item{}
for i := 1; i <= len(items) && len(out) < limit; i++ {
out = append(out, items[i])
}
c.JSON(http.StatusOK, out)
})
v1.GET("/items/:id", func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a number"})
return
}
it, ok := items[id]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, it)
})
auth := v1.Group("", func(c *gin.Context) {
if c.GetHeader("Authorization") != "Bearer ok" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
c.Next()
})
auth.POST("/items", func(c *gin.Context) {
var in newItem
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
it := Item{ID: len(items) + 1, Name: in.Name}
items[it.ID] = it
c.JSON(http.StatusCreated, it)
})
return r
}The standard version has the same routes with method patterns, PathValue, a requireAuth wrapper in place of the group middleware, and explicit validation:
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"sync"
)
func stdApp() http.Handler {
var mu sync.Mutex
items := map[int]Item{1: {1, "keyboard"}, 2: {2, "mouse"}}
writeJSON := func(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
type obj = map[string]string
// Middleware is an ordinary function from handler to handler; it replaces gin's Group(...) with a handler.
requireAuth := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer ok" {
writeJSON(w, http.StatusUnauthorized, obj{"error": "unauthorized"})
return
}
next(w, r)
}
}
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/items", func(w http.ResponseWriter, r *http.Request) {
limit := 10
if v := r.URL.Query().Get("limit"); v != "" {
limit, _ = strconv.Atoi(v)
}
mu.Lock()
defer mu.Unlock()
out := []Item{}
for i := 1; i <= len(items) && len(out) < limit; i++ {
out = append(out, items[i])
}
writeJSON(w, http.StatusOK, out)
})
mux.HandleFunc("GET /v1/items/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, obj{"error": "id must be a number"})
return
}
mu.Lock()
it, ok := items[id]
mu.Unlock()
if !ok {
writeJSON(w, http.StatusNotFound, obj{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, it)
})
mux.HandleFunc("POST /v1/items", requireAuth(func(w http.ResponseWriter, r *http.Request) {
var in newItem
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&in) != nil || strings.TrimSpace(in.Name) == "" || len(in.Name) > 40 {
writeJSON(w, http.StatusBadRequest, obj{"error": "invalid body"})
return
}
mu.Lock()
it := Item{ID: len(items) + 1, Name: in.Name}
items[it.ID] = it
mu.Unlock()
writeJSON(w, http.StatusCreated, it)
}))
return mux
}Notice what shrank and what grew. The routing and the auth middleware got simpler: patterns carry the method, and a wrapper function is the middleware. The handler bodies got a few lines longer, because decoding and validation are written out. A writeJSON helper is the only abstraction added. The standard version also wraps the body in http.MaxBytesReader, which the Gin version did not have, and the migration is a good moment to add it.
The parity test
Reading both versions and judging them equivalent is the usual way migrations go wrong. A better method is to make equivalence a program. The test sends the same request to both apps and compares the status and the body. Fresh app instances per request keep state from leaking between probes.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type probe struct {
label, method, path, auth, body string
}
func send(h http.Handler, p probe) (int, string, string) {
req := httptest.NewRequest(p.method, p.path, strings.NewReader(p.body))
if p.auth != "" {
req.Header.Set("Authorization", p.auth)
}
if p.body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code, strings.TrimSpace(rec.Body.String()), rec.Header().Get("Content-Type")
}
func main() {
probes := []probe{
{"list", "GET", "/v1/items", "", ""},
{"list with limit", "GET", "/v1/items?limit=1", "", ""},
{"get one", "GET", "/v1/items/2", "", ""},
{"get missing", "GET", "/v1/items/9", "", ""},
{"get bad id", "GET", "/v1/items/x", "", ""},
{"create without token", "POST", "/v1/items", "", `{"name":"monitor"}`},
{"create", "POST", "/v1/items", "Bearer ok", `{"name":"monitor"}`},
{"create invalid", "POST", "/v1/items", "Bearer ok", `{"name":""}`},
{"wrong method", "DELETE", "/v1/items/1", "Bearer ok", ""},
{"unknown path", "GET", "/v2/items", "", ""},
{"trailing slash", "GET", "/v1/items/", "", ""},
}
fmt.Printf("%-22s %-9s %-9s %s\n", "request", "gin", "net/http", "same body?")
for _, p := range probes {
g, std := ginApp(), stdApp() // fresh state for every probe, so the create does not affect later rows
gc, gb, _ := send(g, p)
sc, sb, _ := send(std, p)
fmt.Printf("%-22s %-9d %-9d %v\n", p.label, gc, sc, gb == sb)
}
fmt.Println()
g, std := ginApp(), stdApp()
for _, p := range []probe{probes[8], probes[9], probes[10], probes[4]} {
_, gb, gt := send(g, p)
_, sb, st := send(std, p)
fmt.Printf("%-16s gin: %-30q %-32s net/http: %-30q %s\n", p.label, gb, gt, sb, st)
}
rec := httptest.NewRecorder()
std.ServeHTTP(rec, httptest.NewRequest("DELETE", "/v1/items/1", nil))
fmt.Println("net/http Allow header on 405:", rec.Header().Get("Allow"))
}request gin net/http same body?
list 200 200 true
list with limit 200 200 true
get one 200 200 true
get missing 404 404 true
get bad id 400 400 true
create without token 401 401 true
create 201 201 true
create invalid 400 400 true
wrong method 404 405 false
unknown path 404 404 true
trailing slash 301 404 false
wrong method gin: "404 page not found" text/plain net/http: "Method Not Allowed" text/plain; charset=utf-8
unknown path gin: "404 page not found" text/plain net/http: "404 page not found" text/plain; charset=utf-8
trailing slash gin: "<a href=\"/v1/items\">Moved Permanently</a>." text/html; charset=utf-8 net/http: "404 page not found" text/plain; charset=utf-8
get bad id gin: "{\"error\":\"id must be a number\"}" application/json; charset=utf-8 net/http: "{\"error\":\"id must be a number\"}" application/json; charset=utf-8
net/http Allow header on 405: GET, HEADNine of the eleven rows are identical: the list with and without a limit, getting an existing, missing and malformed item, creating without a token (401), creating (201) and an invalid create (400), and an unknown path (404). That is the bulk of what an API does, and it migrated without any behaviour change, because the handlers wrote the same JSON.
The two differences
Wrong methods. DELETE /v1/items/1 is not a route in either app. Gin answered 404 404 page not found. The standard mux answered 405 Method Not Allowed and sent Allow: GET, HEAD. Gin only returns 405 if you set HandleMethodNotAllowed = true, which is off by default. The standard behaviour is the correct one, and clients should not depend on the old one, but a client that did branch on a 404 here will now see a 405. Check your logs for unexpected-method traffic before the cutover.
Trailing slashes. GET /v1/items/ made Gin answer 301 with an HTML redirect to /v1/items, because RedirectTrailingSlash is on by default. The standard mux has no such behaviour for these patterns and returned 404. If clients (or old documentation, or your own front end) call URLs with a trailing slash, they will break silently. Either register both forms, add a small middleware that redirects, or search your access logs for requests that ended in a slash and handle them on purpose.
Content types. The second table shows small header differences: Gin's JSON is application/json; charset=utf-8 and its plain 404 is text/plain with no charset, while the standard library writes text/plain; charset=utf-8. The standard writeJSON helper in the lab copies Gin's JSON header so existing clients see no change. Clients seldom care, but golden-file tests and strict proxies do.
Other things to check
JSON encoding details. Gin's c.JSON uses encoding/json with HTML escaping on, as does json.NewEncoder by default. If you used c.PureJSON or c.IndentedJSON, replace them with SetEscapeHTML(false) or SetIndent. Both add a trailing newline.
Error responses from bindings. Gin's ShouldBind error messages are validator messages you may have returned to clients. After the migration those messages change or vanish. If any client parses them, map the new errors to the same strings, or announce the change.
Panic recovery and logging. gin.Default() includes a logger and recovery. The standard mux has neither. Add a recover middleware and an access log (logging middleware) before the cutover, not after the first panic.
Graceful shutdown and timeouts. Gin leaves server configuration to you, same as before; take the opportunity to set ReadHeaderTimeout, IdleTimeout and the others (server timeouts).
Performance. For typical handlers the router is not the bottleneck; JSON encoding and the database are. Do not migrate expecting a speed-up, and do not avoid migrating fearing a slowdown. If it matters, benchmark the specific handlers (benchmarking a handler).
Tests. Handler tests built on httptest keep working because both are http.Handler. Tests that used gin.CreateTestContext need rewriting, and are best replaced by the parity style used here.
Rolling it out
- Add the parity test with a request list taken from real access logs, so it covers what clients actually send.
- Introduce the front mux with the Gin engine as the fallback, deploy, and confirm nothing changed.
- Move routes in small batches, each with its probes, starting with read-only ones.
- Watch status code distributions per route before and after each batch; a change in the share of 404 and 405 answers is the early warning of the differences above.
- Remove the fallback when no traffic reaches it, then remove the dependency and run
go mod tidy.
The routing patterns are described in the Go 1.22 routing enhancements post, and the Gin behaviours mentioned above are in the Gin documentation.


