Go and Gin: when to use a framework and when to stick with the standard library

Comparison between net/http and Gin in Go. Routing, middlewares, validation and when it is worth adding a framework.

Cover for Go and Gin: when to use a framework and when to stick with the standard library

In Python you choose Flask or FastAPI. In Java, Spring Boot. In Node, Express or Fastify. You come to Go and the first question is different: do you need a framework? The answer is not obvious, and getting it wrong can lead you to add unnecessary dependencies or to reinvent the wheel with the standard library. I am going to compare net/http and Gin with real code so you have the criteria to decide based on data, not on habits from other ecosystems.

Go has a powerful standard library. Since Go 1.22, ServeMux supports route patterns with HTTP methods and path parameters. That changed the conversation. Before, using pure net/http for a REST API was uncomfortable. Now, for many cases, it is enough. Gin still adds value, but for a more concrete range of problems than two years ago.


net/http in Go 1.22+: the new ServeMux

Before Go 1.22, the standard router was basic. It did not distinguish HTTP methods and did not support path parameters. If you wanted GET /users/{id}, you had to parse the URL manually or use an external library. That pushed many people towards frameworks without questioning whether they really needed them.

Go 1.22 changed this. The new ServeMux supports:

  • HTTP methods in the route: "GET /users/{id}" instead of having to check r.Method inside the handler.
  • Path parameters: r.PathValue("id") extracts the value directly.
  • Wildcards: "GET /files/{path...}" captures the rest of the route.
  • Explicit precedence: more specific routes win over more general ones.
package main

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

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

var tasks = map[int]Task{
	1: {ID: 1, Title: "Review PR", Done: false},
	2: {ID: 2, Title: "Deploy to staging", Done: true},
}

func getTasks(w http.ResponseWriter, r *http.Request) {
	result := make([]Task, 0, len(tasks))
	for _, t := range tasks {
		result = append(result, t)
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(result)
}

func getTask(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		http.Error(w, "Invalid task ID", http.StatusBadRequest)
		return
	}
	task, exists := tasks[id]
	if !exists {
		http.Error(w, "Task not found", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(task)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /tasks", getTasks)
	mux.HandleFunc("GET /tasks/{id}", getTask)

	log.Println("Server on :8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

This is a functional HTTP server with method-based routing and path parameters. Zero external dependencies. No framework. The code is explicit, easy to follow and production-ready.

If you come from building a REST API with Go, this pattern will feel familiar. The post-1.22 standard library covers most routing needs without needing anything else.


What Gin adds on top of net/http

Gin is the most popular web framework for Go. It has more than 80,000 stars on GitHub and a broad ecosystem. But the relevant question is not whether it is popular, but what concrete problems it solves that net/http does not.

Radix tree based router

Gin’s router uses a radix tree (httprouter underneath), which is more efficient than the pattern matching of the standard ServeMux. In practice, the routing performance difference is irrelevant for most APIs. Where it does matter is in the router API:

r := gin.Default()

r.GET("/tasks", getTasks)
r.GET("/tasks/:id", getTask)
r.POST("/tasks", createTask)
r.PUT("/tasks/:id", updateTask)
r.DELETE("/tasks/:id", deleteTask)

// Route groups
api := r.Group("/api/v1")
{
    api.GET("/users", getUsers)
    api.GET("/users/:id", getUser)
}

Route groups (r.Group) are useful when you have a shared prefix or want to apply middleware to a set of endpoints. In net/http, you can do something similar with http.StripPrefix or by composing handlers manually, but it is more verbose.

Parameter binding and validation

This is where Gin starts to add real value. Gin can automatically bind query params, path params, headers and JSON body to structs with integrated validation:

type CreateTaskRequest struct {
	Title       string `json:"title" binding:"required,min=1,max=200"`
	Description string `json:"description" binding:"max=1000"`
	Priority    int    `json:"priority" binding:"required,min=1,max=5"`
}

func createTask(c *gin.Context) {
	var req CreateTaskRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	// req is validated, you can use it directly
	c.JSON(http.StatusCreated, gin.H{"title": req.Title, "priority": req.Priority})
}

The binding tag uses go-playground/validator underneath. You can validate required, min, max, email, url, regular expressions and much more. All declarative in the struct.

In pure net/http, validation is manual:

func createTask(w http.ResponseWriter, r *http.Request) {
	var req CreateTaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}
	if req.Title == "" {
		http.Error(w, "Title is required", http.StatusBadRequest)
		return
	}
	if len(req.Title) > 200 {
		http.Error(w, "Title too long", http.StatusBadRequest)
		return
	}
	if req.Priority < 1 || req.Priority > 5 {
		http.Error(w, "Priority must be between 1 and 5", http.StatusBadRequest)
		return
	}
	// ...
}

This works perfectly. But when you have twenty endpoints with different input structs, manual validation becomes repetitive boilerplate that is also easy to leave incomplete. Gin’s binding eliminates that class of errors.

Middleware chain

Gin has a middleware system with a clear execution order and the ability to abort the chain at any point:

r := gin.New()

// Global middlewares
r.Use(gin.Logger())
r.Use(gin.Recovery())
r.Use(corsMiddleware())

// Middleware per group
admin := r.Group("/admin")
admin.Use(authRequired())
{
    admin.GET("/stats", getStats)
    admin.DELETE("/users/:id", deleteUser)
}

The c.Abort() method stops the chain, c.Next() passes to the next middleware and c.Set()/c.Get() allows sharing data between middlewares and handlers within the same request.

Simplified JSON responses

Gin provides helpers for the most common responses:

// Gin
c.JSON(http.StatusOK, gin.H{"message": "ok"})
c.JSON(http.StatusOK, user)
c.String(http.StatusOK, "Hello %s", name)
c.XML(http.StatusOK, data)

// net/http equivalent
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "ok"})

Fewer lines, yes. But if you only use JSON (which is 95% of modern APIs), you can write a three-line helper for net/http and forget about it:

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

Side by side comparison: the same endpoint

Let us implement the same complete endpoint in both: a POST /tasks that receives JSON, validates the fields, creates the task and returns the response.

net/http

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"sync"
)

type Task struct {
	ID          int    `json:"id"`
	Title       string `json:"title"`
	Description string `json:"description"`
	Priority    int    `json:"priority"`
}

type CreateTaskRequest struct {
	Title       string `json:"title"`
	Description string `json:"description"`
	Priority    int    `json:"priority"`
}

var (
	tasks   = make(map[int]Task)
	nextID  = 1
	tasksMu sync.Mutex
)

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

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

func createTask(w http.ResponseWriter, r *http.Request) {
	var req CreateTaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "Invalid JSON body")
		return
	}

	// Manual validation
	if req.Title == "" {
		writeError(w, http.StatusBadRequest, "title is required")
		return
	}
	if len(req.Title) > 200 {
		writeError(w, http.StatusBadRequest, "title must be 200 characters or less")
		return
	}
	if req.Priority < 1 || req.Priority > 5 {
		writeError(w, http.StatusBadRequest, "priority must be between 1 and 5")
		return
	}

	tasksMu.Lock()
	task := Task{
		ID:          nextID,
		Title:       req.Title,
		Description: req.Description,
		Priority:    req.Priority,
	}
	tasks[nextID] = task
	nextID++
	tasksMu.Unlock()

	writeJSON(w, http.StatusCreated, task)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /tasks", createTask)
	http.ListenAndServe(":8080", mux)
}

Gin

package main

import (
	"net/http"
	"sync"

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

type Task struct {
	ID          int    `json:"id"`
	Title       string `json:"title"`
	Description string `json:"description"`
	Priority    int    `json:"priority"`
}

type CreateTaskRequest struct {
	Title       string `json:"title" binding:"required,max=200"`
	Description string `json:"description"`
	Priority    int    `json:"priority" binding:"required,min=1,max=5"`
}

var (
	tasks   = make(map[int]Task)
	nextID  = 1
	tasksMu sync.Mutex
)

func createTask(c *gin.Context) {
	var req CreateTaskRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	tasksMu.Lock()
	task := Task{
		ID:          nextID,
		Title:       req.Title,
		Description: req.Description,
		Priority:    req.Priority,
	}
	tasks[nextID] = task
	nextID++
	tasksMu.Unlock()

	c.JSON(http.StatusCreated, task)
}

func main() {
	r := gin.Default()
	r.POST("/tasks", createTask)
	r.Run(":8080")
}

The main difference is not the number of lines. It is that in the Gin version, validation is declared in the struct and runs automatically when calling ShouldBindJSON. In net/http, validation is imperative code that you have to write, maintain and make sure you have not missed any field. With one endpoint it is manageable. With thirty, the difference is real.


Middlewares: net/http vs Gin

Middlewares are a central piece of any API. Logging, authentication, CORS, rate limiting, panic recovery. Both options support middlewares, but with different ergonomics.

Middleware in net/http

In net/http, a middleware is a function that receives an http.Handler and returns another http.Handler. It is pure function composition:

func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
	})
}

func authMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		if token == "" {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return // We don't call next: the chain stops
		}
		// Validate token...
		next.ServeHTTP(w, r)
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /tasks", getTasks)
	mux.HandleFunc("POST /tasks", createTask)

	// Compose middlewares (executed from outside in)
	handler := loggingMiddleware(authMiddleware(mux))

	http.ListenAndServe(":8080", handler)
}

The pattern is elegant and requires no framework. But nested composition gets complicated when you have five or six middlewares. And if you want to apply middleware to specific routes (not global), you have to compose manually.

To pass data between middlewares, you use context.WithValue:

func authMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		userID, err := validateToken(token)
		if err != nil {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
		ctx := context.WithValue(r.Context(), "userID", userID)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func getProfile(w http.ResponseWriter, r *http.Request) {
	userID := r.Context().Value("userID").(int) // Type assertion needed
	// ...
}

This works, but context.Value has no type safety. You need type assertions and the keys are any, which opens the door to subtle errors. The convention is to use custom types as keys to avoid collisions, but it is the developer’s responsibility.

If you want to dive deeper into middleware patterns in Go, I have an article where I explore this in detail.

Middleware in Gin

Gin has a more structured middleware model:

func loggingMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next() // Execute the rest of the chain
		log.Printf("%s %s %d %v",
			c.Request.Method,
			c.Request.URL.Path,
			c.Writer.Status(),
			time.Since(start),
		)
	}
}

func authMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		token := c.GetHeader("Authorization")
		if token == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
			return
		}
		userID, err := validateToken(token)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
			return
		}
		c.Set("userID", userID)
		c.Next()
	}
}

func main() {
	r := gin.New()
	r.Use(loggingMiddleware())
	r.Use(gin.Recovery())

	public := r.Group("/")
	{
		public.GET("/health", healthCheck)
	}

	protected := r.Group("/api")
	protected.Use(authMiddleware())
	{
		protected.GET("/profile", getProfile)
		protected.GET("/tasks", getTasks)
	}

	r.Run(":8080")
}

func getProfile(c *gin.Context) {
	userID, _ := c.Get("userID")
	// ...
}

Gin’s advantages here are clear:

  • c.Next() and c.Abort(): explicit control of the chain flow. You can execute logic before and after the handler.
  • c.Set() / c.Get(): share data between middlewares without using context.Value. Still lacks complete type safety, but the API is more direct.
  • Groups with middleware: r.Group("/api").Use(authMiddleware()) applies middleware to only a subset of routes declaratively.
  • c.Writer.Status(): access to the response status code in the logging middleware. In net/http you need a ResponseWriter wrapper to capture this.

The key: in net/http, a middleware that needs to read the response status code requires wrapping the ResponseWriter with a custom struct. In Gin you already have it available.

// net/http: ResponseWriter wrapper to capture status
type responseRecorder struct {
	http.ResponseWriter
	statusCode int
}

func (rr *responseRecorder) WriteHeader(code int) {
	rr.statusCode = code
	rr.ResponseWriter.WriteHeader(code)
}

func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rr := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
		start := time.Now()
		next.ServeHTTP(rr, r)
		log.Printf("%s %s %d %v", r.Method, r.URL.Path, rr.statusCode, time.Since(start))
	})
}

It is not difficult, but it is boilerplate that Gin saves you. And if you do not implement it, your logging middleware cannot log the status code. In Gin it is free.


JSON: serialization, deserialization and errors

JSON handling is the bread and butter of a REST API. Both options cover the case, but with differences in ergonomics.

net/http

// Deserialize
func createTask(w http.ResponseWriter, r *http.Request) {
	var req CreateTaskRequest

	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields() // Reject unexpected fields
	if err := decoder.Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error())
		return
	}
	// Validate manually...
}

// Serialize
func getTask(w http.ResponseWriter, r *http.Request) {
	// ...
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	if err := json.NewEncoder(w).Encode(task); err != nil {
		log.Printf("Error encoding response: %v", err)
	}
}

The DisallowUnknownFields() detail is important. By default, encoding/json ignores unknown fields in the body. That can be a problem if a client sends a field with a typo (priorty instead of priority) and nobody notices.

Gin

// Deserialize with binding
func createTask(c *gin.Context) {
	var req CreateTaskRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	// req is already validated by the binding tags
}

// Serialize
func getTask(c *gin.Context) {
	// ...
	c.JSON(http.StatusOK, task)
}

Gin simplifies serialization with c.JSON(). One line, content-type included. For deserialization, ShouldBindJSON combines decode and validation.

Binding from different sources

Where Gin adds real value is when you need to extract data from multiple sources in a single request:

type SearchTasksRequest struct {
	Status   string `form:"status" binding:"omitempty,oneof=pending done"`
	Priority int    `form:"priority" binding:"omitempty,min=1,max=5"`
	Page     int    `form:"page" binding:"min=1"`
	Limit    int    `form:"limit" binding:"min=1,max=100"`
}

func searchTasks(c *gin.Context) {
	var req SearchTasksRequest
	req.Page = 1  // Default value
	req.Limit = 20

	if err := c.ShouldBindQuery(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	// req.Status, req.Priority, req.Page, req.Limit are already parsed and validated
}

ShouldBindQuery parses query parameters. ShouldBindUri parses path parameters. ShouldBindHeader parses headers. All with the same declarative validation system. In net/http you would have to do r.URL.Query().Get("status") for each field, convert types manually and validate each one.

// net/http equivalent for query params
func searchTasks(w http.ResponseWriter, r *http.Request) {
	status := r.URL.Query().Get("status")
	if status != "" && status != "pending" && status != "done" {
		writeError(w, http.StatusBadRequest, "status must be pending or done")
		return
	}

	page := 1
	if p := r.URL.Query().Get("page"); p != "" {
		var err error
		page, err = strconv.Atoi(p)
		if err != nil || page < 1 {
			writeError(w, http.StatusBadRequest, "invalid page")
			return
		}
	}

	limit := 20
	if l := r.URL.Query().Get("limit"); l != "" {
		var err error
		limit, err = strconv.Atoi(l)
		if err != nil || limit < 1 || limit > 100 {
			writeError(w, http.StatusBadRequest, "limit must be between 1 and 100")
			return
		}
	}

	// ... use status, page, limit
}

The difference is evident. It is not that it cannot be done with net/http. It is that with twenty endpoints with different filters, Gin’s declarative binding saves you time and errors.


Performance: both are fast

This point is short because the conclusion is simple: do not choose between net/http and Gin for performance. Both are fast.

Gin’s router (based on httprouter) is marginally more efficient for pure routing because it uses a radix tree instead of ServeMux’s pattern matching. In synthetic benchmarks, the difference exists. In a real API where the bottleneck is the database, a call to another service or response serialization, the routing difference is irrelevant.

Real numbers (indicative, depend on hardware and load):

Aspectnet/httpGin
Routing overhead~200 ns/op~150 ns/op
JSON encodingSame (encoding/json)Same (encoding/json)
Memory per request~2-3 KB~3-5 KB (Gin Context)
Throughput under loadExcellentExcellent

Gin adds a little overhead from its Context and the middleware chain, but we are talking about microseconds. If routing performance is your bottleneck, you have bigger problems than the framework choice.

The decision between net/http and Gin should be about ergonomics and maintainability, not performance. Both handle thousands of requests per second without breaking a sweat.


When net/http is enough

The standard library is enough (and preferable) in these scenarios:

Internal services with few endpoints

If your service has five endpoints, a health check and a couple of middlewares, net/http is all you need. Adding Gin would be adding an external dependency to save a few lines of validation.

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", healthCheck)
	mux.HandleFunc("GET /api/status", getStatus)
	mux.HandleFunc("POST /api/process", processJob)

	handler := withLogging(withAuth(mux))
	http.ListenAndServe(":8080", handler)
}

Five lines of setup. Zero external dependencies. Production-ready.

Reusable libraries and tools

If you are writing a Go library that exposes an HTTP endpoint (a Prometheus exporter, a webhook handler, an SDK), using net/http is the right decision. You do not want to force your users to depend on Gin. The http.Handler interface from the standard library is universal.

Projects where binary size matters

Gin brings several dependencies (go-playground/validator, bytedance/sonic or encoding/json, ugorji/go…). For CLI tools or embedded services where every megabyte counts, the standard library is lighter.

Learning Go

If you are learning Go, start with net/http. Understanding how Handler, ResponseWriter, Request and middleware composition work will make you a better Go developer. Gin abstracts things that are worth understanding before letting a framework do them for you.


When Gin is worth it

Gin adds real value in these cases:

APIs with many endpoints and complex validation

If your API has thirty endpoints, each with its own input struct, query param filters, pagination and validation, Gin’s declarative binding saves you hundreds of lines of manual validation and reduces the error surface.

Large teams or with frequent rotation

Gin’s group system, middleware and binding establish a convention that is easier to follow than manual net/http composition. When someone new joins the team, they know where to look for routes, where the middlewares are and how input is validated. It is structure imposed by the framework instead of conventions that each person can implement differently.

APIs with complex middleware

If you need middlewares that read the response status code, that execute logic before and after the handler, that abort the chain with a structured JSON response, Gin gives you that out of the box. In net/http, you can do it, but you will end up writing abstractions that look suspiciously like what Gin already does.

When coming from other frameworks

If your team comes from Express, Flask or Spring Boot, Gin’s API will feel familiar. Route groups, middleware chain, parameter binding. The learning curve is lower than the pure functional composition of net/http, which requires understanding the standard package interfaces.


Other frameworks: Chi, Echo, Fiber

Gin is not the only option. There are other Go frameworks worth knowing.

Chi

Chi is a lightweight router compatible with net/http. That means your handlers are still func(w http.ResponseWriter, r *http.Request), not functions with a custom context. If you want better routing and middleware without leaving the standard interface, Chi is the most pragmatic option.

r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)

r.Route("/api/tasks", func(r chi.Router) {
    r.Get("/", listTasks)
    r.Post("/", createTask)
    r.Route("/{id}", func(r chi.Router) {
        r.Get("/", getTask)
        r.Put("/", updateTask)
    })
})

Chi is my recommendation if you want an intermediate step between pure net/http and a full framework like Gin. You get routing with groups, a mature middleware system and full compatibility with the standard ecosystem.

Echo

Echo is similar to Gin in philosophy: custom context, binding, validation, included middlewares. Performance is comparable. The choice between Gin and Echo is mainly about API preference and ecosystem. Gin has a larger community and more third-party middleware. Echo has excellent documentation.

Fiber

Fiber is based on fasthttp, not on net/http. It is the fastest in synthetic benchmarks, but that speed comes at a cost: it is not compatible with Go’s standard ecosystem. Any middleware written for net/http does not work with Fiber without adapters. Any library that expects an http.Request needs conversion. Unless your use case requires squeezing every nanosecond of routing, the tradeoff is not worth it.


My recommendation: start with net/http, move to Gin when you feel the pain

This is not a cowardly answer. It is the approach that works best in Go.

Go is not Java, where without Spring Boot you are lost. Go is not Python, where without FastAPI or Flask you have no structure. Go’s standard library is a production HTTP server. Start with it. Write your first handlers, your first middlewares, your manual validation. Understand how http.Handler works, how functions are composed, how context is used.

When you start to feel the pain — repetitive validation, limited routing, middleware boilerplate — then evaluate Gin or Chi. At that point you will know exactly what problem the framework solves for you and how much of the magic you are willing to accept.

The sequence I recommend:

  1. Start with pure net/http. Build an API with four or five endpoints, a couple of middlewares and error handling.
  2. Identify the boilerplate. If you write the same validation in every handler, if you need to wrap ResponseWriter for logging, if middleware composition becomes uncomfortable, take note.
  3. Try Chi. If what bothers you is only the routing and middleware composition, Chi solves that without leaving the standard.
  4. Try Gin. If you need declarative binding, integrated validation and a more complete middleware system, Gin is the most mature option.
  5. Do not look back without reason. Once you choose, stay until you have a real reason to change.

For new projects with a team that already knows Go, my criteria:

ScenarioRecommendation
Internal service, <10 endpointsnet/http
Public API, 20+ endpointsGin or Chi
Library that exposes HTTPnet/http (always)
Team new to Gonet/http to learn, then evaluate
Large team, frequent rotationGin (imposed conventions > implicit conventions)
Lightweight cloud-native microservicenet/http or Chi
API with complex input validationGin

If you want to see this in practice with a complete project, start by building a REST API with Go using net/http and then apply what we have seen here to decide whether Gin adds value in your case. And if you are interested in how to structure testing, in Go testing I cover the patterns that work with both net/http and Gin.


Choose with criteria, not dogma

In Go, the question “do I need a framework?” has an answer that does not exist in other languages: probably not, at least at the beginning. The post-Go 1.22 standard library is a complete HTTP server with method-based routing, path parameters and a composition interface that allows building production APIs without external dependencies.

Gin adds real value in concrete scenarios: declarative validation with struct binding, ergonomic middleware system with access to the status code, route groups with selective middleware and a familiar API for teams coming from frameworks in other languages. It is not that Gin is better or worse than net/http. It is that it solves different problems.

The worst decision is to choose Gin “because in other languages I always use a framework”. The second worst is rejecting Gin “because in Go we do not use frameworks” when your API has forty endpoints and you are writing the same manual validation in every handler. Choose with criteria, not with dogma.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved