Skip to content
gohacks.dev
APIs

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.

10 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the Gin logo.

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.

Go
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

GinStandard 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 returnwrite 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.Filehttp.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:

gin_app.goGo
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:

std_app.goGo
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.

main.goGo
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"))
}
Text
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, HEAD

Nine 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

  1. Add the parity test with a request list taken from real access logs, so it covers what clients actually send.
  2. Introduce the front mux with the Gin engine as the fallback, deploy, and confirm nothing changed.
  3. Move routes in small batches, each with its probes, starting with read-only ones.
  4. 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.
  5. 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.

Also inWebGin
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.