Go y Gin: cuándo usar un framework y cuándo quedarte con la librería estándar
Comparación entre net/http y Gin en Go. Routing, middlewares, validación y cuándo merece la pena añadir un framework.

En Python eliges Flask o FastAPI. En Java, Spring Boot. En Node, Express o Fastify. Llegas a Go y la primera pregunta es diferente: necesitas un framework? La respuesta no es obvia, y confundirla te puede llevar a meter dependencias innecesarias o a reinventar la rueda con la librería estándar. Voy a comparar net/http y Gin con código real para que tengas criterio y decidas con datos, no con inercia de otros ecosistemas.
Go tiene una librería estándar potente. Desde Go 1.22, el ServeMux soporta patrones de rutas con métodos HTTP y path parameters. Eso cambió la conversación. Antes, usar net/http puro para una API REST era incómodo. Ahora, para muchos casos, es suficiente. Gin sigue aportando valor, pero en un rango de problemas más concreto que hace dos años.
net/http en Go 1.22+: el nuevo ServeMux
Antes de Go 1.22, el router estándar era básico. No distinguía métodos HTTP y no soportaba path parameters. Si querías GET /users/{id}, tenías que parsear la URL a mano o usar una librería externa. Eso empujaba a mucha gente hacia frameworks sin plantearse si realmente los necesitaban.
Go 1.22 cambió esto. El nuevo ServeMux soporta:
- Métodos HTTP en la ruta:
"GET /users/{id}"en vez de tener que comprobarr.Methoddentro del handler. - Path parameters:
r.PathValue("id")extrae el valor directamente. - Wildcards:
"GET /files/{path...}"captura el resto de la ruta. - Precedencia explícita: las rutas más específicas ganan sobre las más generales.
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: "Revisar PR", Done: false},
2: {ID: 2, Title: "Deploy a 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("Servidor en :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}Esto es un servidor HTTP funcional con routing por método y path parameters. Cero dependencias externas. Sin framework. El código es explícito, fácil de seguir y producción-ready.
Si vienes de construir una API REST con Go, este patrón te va a resultar familiar. La librería estándar post-1.22 cubre la mayoría de necesidades de routing sin necesidad de nada más.
Qué aporta Gin encima de net/http
Gin es el framework web más popular de Go. Tiene más de 80.000 estrellas en GitHub y un ecosistema amplio. Pero la pregunta relevante no es si es popular, sino qué problemas concretos resuelve que net/http no resuelve.
Router basado en radix tree
El router de Gin usa un radix tree (httprouter por debajo), que es más eficiente que el pattern matching del ServeMux estándar. En la práctica, la diferencia de rendimiento en routing es irrelevante para la mayoría de APIs. Donde sí importa es en la API del router:
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)
// Grupos de rutas
api := r.Group("/api/v1")
{
api.GET("/users", getUsers)
api.GET("/users/:id", getUser)
}Los grupos de rutas (r.Group) son útiles cuando tienes un prefijo compartido o quieres aplicar middleware a un conjunto de endpoints. En net/http, puedes hacer algo similar con http.StripPrefix o componiendo handlers manualmente, pero es más verboso.
Binding y validación de parámetros
Aquí es donde Gin empieza a aportar valor real. Gin puede bindear automáticamente query params, path params, headers y body JSON a structs con validación integrada:
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 está validado, puedes usarlo directamente
c.JSON(http.StatusCreated, gin.H{"title": req.Title, "priority": req.Priority})
}El tag binding usa go-playground/validator por debajo. Puedes validar required, min, max, email, url, expresiones regulares y mucho más. Todo declarativo en el struct.
En net/http puro, la validación es 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
}
// ...
}Funciona perfectamente. Pero cuando tienes veinte endpoints con structs de entrada diferentes, la validación manual se convierte en boilerplate repetitivo que además es fácil de dejar incompleto. El binding de Gin elimina esa clase de errores.
Cadena de middlewares
Gin tiene un sistema de middlewares con un orden de ejecución claro y la capacidad de abortar la cadena en cualquier punto:
r := gin.New()
// Middlewares globales
r.Use(gin.Logger())
r.Use(gin.Recovery())
r.Use(corsMiddleware())
// Middleware por grupo
admin := r.Group("/admin")
admin.Use(authRequired())
{
admin.GET("/stats", getStats)
admin.DELETE("/users/:id", deleteUser)
}El método c.Abort() detiene la cadena, c.Next() pasa al siguiente middleware y c.Set()/c.Get() permite compartir datos entre middlewares y handlers dentro del mismo request.
Respuestas JSON simplificadas
Gin proporciona helpers para las respuestas más comunes:
// 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 equivalente
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "ok"})Menos líneas, sí. Pero si solo usas JSON (que es el 95% de las APIs modernas), puedes escribir un helper de tres líneas para net/http y olvidarte:
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}Comparación lado a lado: el mismo endpoint
Vamos a implementar el mismo endpoint completo en ambos: un POST /tasks que recibe JSON, valida los campos, crea la tarea y devuelve la respuesta.
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
}
// Validación manual
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")
}La diferencia principal no es la cantidad de líneas. Es que en la versión Gin, la validación está declarada en el struct y se ejecuta automáticamente al hacer ShouldBindJSON. En net/http, la validación es código imperativo que tienes que escribir, mantener y asegurarte de que no te dejas ningún campo. Con un endpoint es manejable. Con treinta, la diferencia es real.
Middlewares: net/http vs Gin
Los middlewares son una pieza central de cualquier API. Logging, autenticación, CORS, rate limiting, recovery de panics. Ambas opciones soportan middlewares, pero con ergonomía diferente.
Middleware en net/http
En net/http, un middleware es una función que recibe un http.Handler y devuelve otro http.Handler. Es composición de funciones pura:
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 // No llamamos next: la cadena se detiene
}
// Validar token...
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", getTasks)
mux.HandleFunc("POST /tasks", createTask)
// Componer middlewares (se ejecutan de fuera a dentro)
handler := loggingMiddleware(authMiddleware(mux))
http.ListenAndServe(":8080", handler)
}El patrón es elegante y no necesita framework. Pero la composición anidada se complica cuando tienes cinco o seis middlewares. Y si quieres aplicar middleware a rutas específicas (no globales), tienes que componer manualmente.
Para pasar datos entre middlewares, usas 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 necesaria
// ...
}Funciona, pero el context.Value no tiene type safety. Necesitas type assertions y las claves son any, lo que abre la puerta a errores sutiles. La convención es usar tipos custom como claves para evitar colisiones, pero es responsabilidad del desarrollador.
Si quieres profundizar en patrones de middlewares en Go, tengo un artículo donde exploro esto en detalle.
Middleware en Gin
Gin tiene un modelo de middleware más estructurado:
func loggingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next() // Ejecutar el resto de la cadena
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")
// ...
}Las ventajas de Gin aquí son claras:
c.Next()yc.Abort(): control explícito del flujo de la cadena. Puedes ejecutar lógica antes y después del handler.c.Set()/c.Get(): compartir datos entre middlewares sin usarcontext.Value. Sigue sin tener type safety completo, pero la API es más directa.- Grupos con middleware:
r.Group("/api").Use(authMiddleware())aplica middleware solo a un subconjunto de rutas de forma declarativa. c.Writer.Status(): acceso al status code de la respuesta en el middleware de logging. Ennet/httpnecesitas unResponseWriterwrapper para capturar esto.
La clave: en net/http, un middleware que necesita leer el status code de la respuesta requiere wrappear el ResponseWriter con un struct custom. En Gin ya lo tienes disponible.
// net/http: ResponseWriter wrapper para capturar 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))
})
}No es difícil, pero es boilerplate que Gin te ahorra. Y si no lo implementas, tu middleware de logging no puede registrar el status code. En Gin es gratis.
JSON: serialización, deserialización y errores
El manejo de JSON es el pan de cada día de una API REST. Ambas opciones cubren el caso, pero con diferencias en ergonomía.
net/http
// Deserializar
func createTask(w http.ResponseWriter, r *http.Request) {
var req CreateTaskRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() // Rechazar campos no esperados
if err := decoder.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error())
return
}
// Validar manualmente...
}
// Serializar
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)
}
}El detalle de DisallowUnknownFields() es importante. Por defecto, encoding/json ignora campos desconocidos en el body. Eso puede ser un problema si un cliente manda un campo con un typo (priorty en vez de priority) y nadie se entera.
Gin
// Deserializar con 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 ya está validado por los tags binding
}
// Serializar
func getTask(c *gin.Context) {
// ...
c.JSON(http.StatusOK, task)
}Gin simplifica la serialización con c.JSON(). Una línea, content-type incluido. Para deserialización, ShouldBindJSON combina decode y validación.
Binding de diferentes fuentes
Donde Gin aporta valor real es cuando necesitas extraer datos de múltiples fuentes en un mismo 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 // Valor por defecto
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 ya están parseados y validados
}ShouldBindQuery parsea query parameters. ShouldBindUri parsea path parameters. ShouldBindHeader parsea headers. Todo con el mismo sistema de validación declarativa. En net/http tendrías que hacer r.URL.Query().Get("status") para cada campo, convertir tipos manualmente y validar cada uno.
// net/http equivalente para 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
}
}
// ... usar status, page, limit
}La diferencia es evidente. No es que no se pueda hacer con net/http. Es que con veinte endpoints con filtros diferentes, el binding declarativo de Gin te ahorra tiempo y errores.
Rendimiento: ambos son rápidos
Este punto es corto porque la conclusión es simple: no elijas entre net/http y Gin por rendimiento. Ambos son rápidos.
El router de Gin (basado en httprouter) es marginalmente más eficiente para routing puro porque usa un radix tree en lugar del pattern matching del ServeMux. En benchmarks sintéticos, la diferencia existe. En una API real donde el cuello de botella es la base de datos, una llamada a otro servicio o la serialización de la respuesta, la diferencia de routing es irrelevante.
Números reales (orientativos, dependen del hardware y la carga):
| Aspecto | net/http | Gin |
|---|---|---|
| Routing overhead | ~200 ns/op | ~150 ns/op |
| JSON encoding | Mismo (encoding/json) | Mismo (encoding/json) |
| Memoria por request | ~2-3 KB | ~3-5 KB (Context de Gin) |
| Throughput bajo carga | Excelente | Excelente |
Gin añade un poco de overhead por su Context y la cadena de middlewares, pero estamos hablando de microsegundos. Si el rendimiento de routing es tu cuello de botella, tienes problemas mayores que la elección de framework.
La decisión entre net/http y Gin debería ser de ergonomía y mantenibilidad, no de rendimiento. Ambos manejan miles de peticiones por segundo sin sudar.
Cuándo net/http es suficiente
La librería estándar es suficiente (y preferible) en estos escenarios:
Servicios internos con pocos endpoints
Si tu servicio tiene cinco endpoints, un health check y un par de middlewares, net/http es todo lo que necesitas. Añadir Gin sería meter una dependencia externa para ahorrarte unas pocas líneas de validación.
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)
}Cinco líneas de setup. Cero dependencias externas. Producción-ready.
Librerías y herramientas reutilizables
Si estás escribiendo una librería Go que expone un endpoint HTTP (un exporter de Prometheus, un webhook handler, un SDK), usar net/http es la decisión correcta. No quieres forzar a tus usuarios a depender de Gin. La interfaz http.Handler de la librería estándar es universal.
Proyectos donde el tamaño del binario importa
Gin trae consigo varias dependencias (go-playground/validator, bytedance/sonic o encoding/json, ugorji/go…). Para herramientas CLI o servicios embedded donde cada megabyte cuenta, la librería estándar es más ligera.
Aprender Go
Si estás aprendiendo Go, empieza con net/http. Entender cómo funciona el Handler, el ResponseWriter, el Request y la composición de middlewares te va a hacer mejor desarrollador Go. Gin abstrae cosas que conviene entender antes de dejar que un framework las haga por ti.
Cuándo Gin merece la pena
Gin aporta valor real en estos casos:
APIs con muchos endpoints y validación compleja
Si tu API tiene treinta endpoints, cada uno con su struct de entrada, filtros por query params, paginación y validación, el binding declarativo de Gin te ahorra cientos de líneas de validación manual y reduce la superficie de error.
Equipos grandes o con rotación
El sistema de grupos, middleware y binding de Gin establece una convención que es más fácil de seguir que la composición manual de net/http. Cuando alguien nuevo se incorpora al equipo, sabe dónde buscar las rutas, dónde están los middlewares y cómo se valida la entrada. Es estructura impuesta por el framework en vez de convención que cada persona puede implementar diferente.
APIs con middleware complejo
Si necesitas middlewares que lean el status code de la respuesta, que ejecuten lógica antes y después del handler, que aborten la cadena con una respuesta JSON estructurada, Gin te da eso out of the box. En net/http, puedes hacerlo, pero vas a acabar escribiendo abstracciones que se parecen sospechosamente a lo que Gin ya hace.
Cuando vienes de otros frameworks
Si tu equipo viene de Express, Flask o Spring Boot, la API de Gin les va a resultar familiar. Grupos de rutas, middleware chain, binding de parámetros. La curva de aprendizaje es menor que la composición funcional pura de net/http, que requiere entender las interfaces del paquete estándar.
Otros frameworks: Chi, Echo, Fiber
Gin no es la única opción. Hay otros frameworks Go que vale la pena conocer.
Chi
Chi es un router ligero compatible con net/http. Eso significa que tus handlers siguen siendo func(w http.ResponseWriter, r *http.Request), no funciones con un contexto custom. Si quieres mejor routing y middleware pero sin salirte de la interfaz estándar, Chi es la opción más pragmática.
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 es mi recomendación si quieres un paso intermedio entre net/http puro y un framework completo como Gin. Obtienes routing con grupos, un sistema de middleware maduro y compatibilidad total con el ecosistema estándar.
Echo
Echo es similar a Gin en filosofía: context custom, binding, validación, middlewares incluidos. El rendimiento es comparable. La elección entre Gin y Echo es principalmente de preferencia de API y ecosistema. Gin tiene más comunidad y más middleware de terceros. Echo tiene una documentación excelente.
Fiber
Fiber está basado en fasthttp, no en net/http. Es el más rápido en benchmarks sintéticos, pero esa velocidad viene con un coste: no es compatible con el ecosistema estándar de Go. Cualquier middleware escrito para net/http no funciona con Fiber sin adaptadores. Cualquier librería que espere un http.Request necesita conversión. Salvo que tu caso de uso requiera exprimir cada nanosegundo de routing, el tradeoff no merece la pena.
Mi recomendación: empieza con net/http, mueve a Gin cuando sientas el dolor
No es una respuesta cobarde. Es la aproximación que mejor funciona en Go.
Go no es Java, donde sin Spring Boot estás perdido. Go no es Python, donde sin FastAPI o Flask no tienes estructura. La librería estándar de Go es un servidor HTTP de producción. Empieza con ella. Escribe tus primeros handlers, tus primeros middlewares, tu validación manual. Entiende cómo funciona http.Handler, cómo se componen funciones, cómo se usa el context.
Cuando empieces a sentir el dolor —validación repetitiva, routing limitado, middleware boilerplate—, entonces evalúa Gin o Chi. En ese punto sabrás exactamente qué problema te resuelve el framework y cuánto de la magia estás dispuesto a aceptar.
La secuencia que recomiendo:
- Empieza con
net/httppuro. Monta una API con cuatro o cinco endpoints, un par de middlewares y manejo de errores. - Identifica el boilerplate. Si escribes la misma validación en cada handler, si necesitas wrappear el
ResponseWriterpara logging, si la composición de middleware se vuelve incómoda, toma nota. - Prueba Chi. Si lo que te molesta es solo el routing y la composición de middlewares, Chi te resuelve eso sin salirte del estándar.
- Prueba Gin. Si necesitas binding declarativo, validación integrada y un sistema de middleware más completo, Gin es la opción más madura.
- No mires atrás sin motivo. Una vez que eliges, quédate hasta que tengas un motivo real para cambiar.
Para proyectos nuevos con un equipo que ya conoce Go, mi criterio es:
| Escenario | Recomendación |
|---|---|
| Servicio interno, <10 endpoints | net/http |
| API pública, 20+ endpoints | Gin o Chi |
| Librería que expone HTTP | net/http (siempre) |
| Equipo nuevo en Go | net/http para aprender, luego evaluar |
| Equipo grande, rotación frecuente | Gin (convenciones impuestas > convenciones implícitas) |
| Microservicio cloud-native ligero | net/http o Chi |
| API con validación compleja de entrada | Gin |
Si quieres ver esto en práctica con un proyecto completo, empieza por construir una API REST con Go usando net/http y después aplica lo que hemos visto aquí para decidir si Gin aporta valor en tu caso. Y si te interesa cómo estructurar el testing, en testing en Go cubro los patrones que funcionan tanto con net/http como con Gin.
Elige con criterio, no con dogma
En Go, la pregunta “necesito un framework?” tiene una respuesta que en otros lenguajes no existe: probablemente no, al menos al principio. La librería estándar post-Go 1.22 es un servidor HTTP completo con routing por método, path parameters y una interfaz de composición que permite construir APIs de producción sin dependencias externas.
Gin aporta valor real en escenarios concretos: validación declarativa con binding de structs, sistema de middleware ergonómico con acceso al status code, grupos de rutas con middleware selectivo y una API familiar para equipos que vienen de frameworks en otros lenguajes. No es que Gin sea mejor o peor que net/http. Es que resuelve problemas diferentes.
La peor decisión es elegir Gin “porque en otros lenguajes siempre uso un framework”. La segunda peor es rechazar Gin “porque en Go no se usan frameworks” cuando tu API tiene cuarenta endpoints y estás escribiendo la misma validación manual en cada handler. Elige con criterio, no con dogma.


