Skip to content
gohacks.dev
Web

Do You Need a Go Web Framework or Just net http Alone

Write one small notes API twice, with net/http and with gin, and compare the code you write, where the behavior differs and what the framework really saved.

9 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Arguments about frameworks tend to stay abstract: the standard library is enough, or it is not. The quickest way to take the air out of that is to build one thing both ways and count. So I wrote the same small notes API twice, once with the standard library and once with gin, gave both the same store and the same tests, and compared what I wrote, what each did differently without being asked, and where the framework earned its place.

Tested with Go 1.25.6 and gin v1.12.0, macOS on Apple Silicon (arm64)

The API is deliberately ordinary: create a note, fetch one, list by tag, validation, request logging and panic recovery. It is the kind of service where the question comes up. The wider comparison of routers and frameworks is in the router comparison and the framework roundup, and this article sits in the web cluster that ends in a production HTTP server. The tested line above has the versions.

The shared pieces

Both versions use one in-memory store, so the difference is only the HTTP layer.

store.goGo
package main

import "sync"

type Note struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Body  string `json:"body"`
	Tag   string `json:"tag,omitempty"`
}

type Store struct {
	mu    sync.Mutex
	notes []Note
}

func (s *Store) Add(n Note) Note {
	s.mu.Lock()
	defer s.mu.Unlock()
	n.ID = len(s.notes) + 1
	s.notes = append(s.notes, n)
	return n
}

func (s *Store) Get(id int) (Note, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if id < 1 || id > len(s.notes) {
		return Note{}, false
	}
	return s.notes[id-1], true
}

func (s *Store) List(tag string) []Note {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := []Note{}
	for _, n := range s.notes {
		if tag == "" || n.Tag == tag {
			out = append(out, n)
		}
	}
	return out
}

func (n Note) valid() bool { return n.Title != "" && len(n.Title) <= 100 }

Version one: net/http

std.goGo
package main

import (
	"encoding/json"
	"net/http"
	"strconv"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func fail(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

func recovery(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if recover() != nil {
				fail(w, http.StatusInternalServerError, "internal error")
			}
		}()
		next.ServeHTTP(w, r)
	})
}

type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (s *statusRecorder) WriteHeader(code int) {
	s.status = code
	s.ResponseWriter.WriteHeader(code)
}

func logging(next http.Handler, logf func(string)) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, r)
		logf(r.Method + " " + r.URL.Path + " " + strconv.Itoa(rec.status))
	})
}

func NewStd(s *Store, logf func(string)) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /notes", func(w http.ResponseWriter, r *http.Request) {
		var n Note
		dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16))
		dec.DisallowUnknownFields()
		if err := dec.Decode(&n); err != nil || !n.valid() {
			fail(w, http.StatusBadRequest, "invalid note")
			return
		}
		writeJSON(w, http.StatusCreated, s.Add(n))
	})
	mux.HandleFunc("GET /notes/{id}", func(w http.ResponseWriter, r *http.Request) {
		id, err := strconv.Atoi(r.PathValue("id"))
		if err != nil {
			fail(w, http.StatusBadRequest, "invalid id")
			return
		}
		n, ok := s.Get(id)
		if !ok {
			fail(w, http.StatusNotFound, "not found")
			return
		}
		writeJSON(w, http.StatusOK, n)
	})
	mux.HandleFunc("GET /notes", func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, s.List(r.URL.Query().Get("tag")))
	})
	mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) { panic("bug") })
	return logging(recovery(mux), logf)
}

Version two: gin

ginapp.goGo
package main

import (
	"io"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
)

func NewGin(s *Store, logf func(string)) http.Handler {
	gin.SetMode(gin.ReleaseMode)
	gin.DefaultErrorWriter = io.Discard // keep the recovery stack trace out of the demo output
	g := gin.New()
	g.Use(gin.CustomRecovery(func(c *gin.Context, _ any) {
		c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
	}))
	g.Use(func(c *gin.Context) {
		c.Next()
		logf(c.Request.Method + " " + c.Request.URL.Path + " " + strconv.Itoa(c.Writer.Status()))
	})
	g.POST("/notes", func(c *gin.Context) {
		var n Note
		if err := c.ShouldBindJSON(&n); err != nil || !n.valid() {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid note"})
			return
		}
		c.JSON(http.StatusCreated, s.Add(n))
	})
	g.GET("/notes/:id", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
			return
		}
		n, ok := s.Get(id)
		if !ok {
			c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
			return
		}
		c.JSON(http.StatusOK, n)
	})
	g.GET("/notes", func(c *gin.Context) { c.JSON(http.StatusOK, s.List(c.Query("tag"))) })
	g.GET("/boom", func(c *gin.Context) { panic("bug") })
	return g
}

The two are close to line for line, which is the first finding, and the line count below confirms it. The handler bodies hardly differ. The stdlib version spends its extra length on four helpers: writeJSON, fail, the recovery middleware and the logging middleware with its status-capturing wrapper. Those are the pieces gin ships. Everything that is the application, the routes and the logic, is the same size in both.

Same requests, both servers

The next program drives both with identical requests and prints the status each returned, then counts the lines in the three files.

main.goGo
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

func main() {
	var stdLog, ginLog []string
	std := NewStd(&Store{}, func(s string) { stdLog = append(stdLog, s) })
	gn := NewGin(&Store{}, func(s string) { ginLog = append(ginLog, s) })

	steps := []struct{ label, method, path, body string }{
		{"create a note", "POST", "/notes", `{"title":"First","body":"hello","tag":"go"}`},
		{"create, no title", "POST", "/notes", `{"body":"untitled"}`},
		{"create, unknown field", "POST", "/notes", `{"title":"Second","admin":true}`},
		{"create, not JSON", "POST", "/notes", `title=Third`},
		{"get note 1", "GET", "/notes/1", ""},
		{"get note 99", "GET", "/notes/99", ""},
		{"get note abc", "GET", "/notes/abc", ""},
		{"list by tag", "GET", "/notes?tag=go", ""},
		{"wrong method", "DELETE", "/notes/1", ""},
		{"panicking handler", "GET", "/boom", ""},
	}
	do := func(h http.Handler, method, path, body string) (int, string) {
		req := httptest.NewRequest(method, path, strings.NewReader(body))
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		return rec.Code, strings.TrimSpace(rec.Body.String())
	}
	fmt.Printf("%-24s %-10s %-10s %s\n", "request", "net/http", "gin", "same body?")
	for _, s := range steps {
		c1, b1 := do(std, s.method, s.path, s.body)
		c2, b2 := do(gn, s.method, s.path, s.body)
		fmt.Printf("%-24s %-10d %-10d %v\n", s.label, c1, c2, b1 == b2)
	}
	fmt.Println("log lines written:", len(stdLog), len(ginLog))
}
Text
request                  net/http   gin        same body?
create a note            201        201        true
create, no title         400        400        true
create, unknown field    400        201        false
create, not JSON         400        400        true
get note 1               200        200        true
get note 99              404        404        true
get note abc             400        400        true
list by tag              200        200        true
wrong method             405        404        false
panicking handler        500        500        true
log lines written: 10 9
Shell
echo "lines of code (all, non-blank):"
for f in std.go ginapp.go; do printf '  %-10s %s\n' "$f" "$(grep -c . "$f")"; done
Text
lines of code (all, non-blank):
  std.go     70
  ginapp.go  43

Two rows deserve attention. For the unknown field, net/http returned 400 because the decoder was configured with DisallowUnknownFields, and gin returned 201, because its default binding accepts extra fields. Neither is wrong, but a client that sends a typo'd field name gets an error from one server and a silently ignored field from the other, and anyone moving from one to the other inherits the difference without noticing. Gin can be made strict (binding.EnableDecoderDisallowUnknownFields = true, a package-level switch that applies to the whole process), which is a global setting where the standard library version is a line in one handler. The rules for which is better are in rejecting unknown JSON fields.

The wrong-method row shows net/http answering 405 with an Allow header and gin answering 404, the difference documented in the router comparison. If your API contract says 405, the framework needs HandleMethodNotAllowed switched on.

The log line gin did not write

The last line of the output is a finding I did not plan. The standard library server logged ten requests and the gin server only nine. The missing one is the panic. In the gin version I registered recovery first and the logging middleware second, so the logger sits inside the recovery: the panic unwinds through it before it reaches its log statement, and the one request you most want in the log is the one that vanishes. In the standard library version the order is explicit in the last line of NewStd, logging(recovery(mux), logf), with logging outermost, and the 500 is logged. The same ordering mistake is demonstrated in testing middleware chains. A framework does not remove the need to think about order; it only changes where you write it down, and a built-in default stack that hides the order makes the mistake easier to repeat.

What the framework saved, honestly

Counting non-blank lines, the standard library file has 70 and the gin file 43 (the store is shared and left out), so the standard library version is about 60 percent longer, and the whole gap of 27 lines is the four helpers. That is a real saving, and it is also a one-time cost: write writeJSON, fail, recovery and logging once in a small internal package and every later service in the company has them. After that, the application code is the same size in both. What gin also offers, struct tag binding and validation, is not used here; I validated by hand in both so the comparison would be fair, and valid() is a one-liner. For a model with fifteen fields, tag-based validation does save lines, with the trade-offs described in the validator article.

What it cost: the handlers are tied to *gin.Context, so they cannot be called from a plain httptest request without the whole engine, the defaults are different from the standard library in the places shown, and the dependency graph grew from nothing to dozens of modules (see the weight measurement).

Moving between the two

The migration cost is the part of this comparison that lasts. Going from gin to the standard library meant rewriting each handler's first and last lines: the argument list, how the path parameter and query are read, and how the response is written. The bodies, the store calls and the branching stayed. Had the logic lived in a service layer taking plain arguments, the handlers would have been the only things to rewrite, and in an application with a hundred handlers that is an afternoon of mechanical work, not a project. A handler that reaches into the framework context deep in the logic (reading headers in a service method, storing values with c.Set) is the situation to avoid, because then the framework is the architecture.

So the practical advice is the same whichever side you pick. Keep handlers short, keep input parsing at the edge, return domain errors from the service layer and translate them to status codes in one place. The sections on handler dependency injection and route organization lay out how.

So, do you need one?

Not for this. A small team that writes its helpers once and tests against httptest loses nothing by staying on net/http, and it gains a dependency-free build, standard error behavior and handlers it can move anywhere. A larger team with many services and new developers arriving often might legitimately prefer a framework for the shared conventions: one way to bind, one way to fail, one middleware set. That is a social benefit, not a technical one, and it is worth paying for when it is real. My own default is the standard library plus a hand-rolled middleware chain, moving to chi if route groups start to matter. The official routing documentation that makes this viable is the Go team's enhanced routing blog post, and gin's documentation covers what the other column offers.

Taggednet http
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.