Go Gotcha #4: Frameworks and ORMs Work Fine Until They Don't
Meta: "Gin, Echo, and GORM are productivity tools that become performance liabilities. Here's what Go frameworks and ORMs actually cost, and when that bill comes due."
E
veryone coming to Go from Node.js or Python reaches for a framework first. Gin because it's popular. Echo because it looks clean. GORM because raw SQL feels unnecessary when the ORM is right there. The setup takes 20 minutes and you're shipping endpoints.The problem shows up later. Not always at launch. Often at 10x load, sometimes never until someone profiles the service and finds the framework's middleware chain allocating on every request, the ORM firing N+1 queries nobody intended, and error handling scattered across 40 handlers with no consistency. This is the compounding cost. It doesn't announce itself. It accumulates.
What Gin and Echo Actually Cost You
Gin and Echo work by replacing context.Context with their own context types gin.Context and echo.Context. These aren't the standard context. They're framework-specific structs bundling the request, response writer, path params, and request-scoped values into one object.
This immediately couples your handlers to the framework. You can't pass a Gin handler to net/http without a wrapper. You can't use standard middleware libraries without a translation layer. Your handlers become untestable at the unit level without standing up the full framework.
The handler signature is the most telling problem:
Untitled1// Gin handler no error return2func GetProduct(c *gin.Context) {3 product, err := db.FindProduct(c.Param("id"))4 if err != nil {5 c.JSON(500, gin.H{"error": err.Error()})6 return // miss this return and execution continues7 }8 c.JSON(200, product)9}
No error return. In idiomatic Go, functions return errors. Callers handle them. A middleware at the top of the stack catches everything, logs once, writes the appropriate response. That's the pattern. Gin breaks it. Developers compensate by calling c.JSON() and returning manually forgetting the return half the time or by using panic() and relying on recovery middleware. Neither is idiomatic. Both create inconsistent behavior under error conditions.
Echo does better here: handlers return error, and you can inspect echo.HTTPError in a centralized handler. But your handlers still take echo.Context, not context.Context, so the coupling remains.
The coupling is permanent. When your codebase has 100 handlers taking *gin.Context, migrating is a rewrite, not a refactor. The dependency grows into your business logic, your test helpers, your error types. By the time you want out, you can't get out cheaply.
What GORM Costs You
GORM hides SQL behind an elegant API that works well for the first 50 routes. Then it becomes what your team debugs whenever a query is slow and nobody knows why.
The fundamental problem: GORM generates SQL, but you can't always predict what SQL it generates. The Preload feature exists to avoid N+1 queries but it requires you to remember to use it. When you forget:
Untitled1// This looks reasonable. It runs N+1 queries.2var products []Product3db.Find(&products)4for _, p := range products {5 db.First(&p.Category, p.CategoryID) // one query per product6}
A service at baseline traffic might handle this without visible impact. Under Harbolnas traffic 15x normal load those N+1 queries turn a 50ms endpoint into a 3-second one. The fix isn't hard once you find it. Finding it takes time you don't have during a traffic spike.
GORM also creates friction with schema migrations. AutoMigrate adds columns but won't remove them, rename them, or handle data migrations. The moment you have real data in production, you're writing raw SQL for the migrations GORM can't do. Two schema management systems now: GORM model tags and your migration files. They drift. Someone adds a field to the struct without a corresponding migration. Or writes a migration without updating the struct. The mismatch surfaces at runtime.
The honest case for GORM: it's fine when your queries are simple CRUD. When you need a JOIN with a WHERE clause and aggregation, you're writing db.Raw() at which point you've paid GORM's abstraction cost without getting its benefit.
database/sql with pgx and raw queries gives you exactly what you ask for, every time. The queries take longer to write. They're also exactly what runs. And sqlc which generates type-safe Go from your .sql files gets you GORM's productivity without the hidden behavior.
The Honest Case for Frameworks
A prototype that will be thrown away. A service that needs to ship in two weeks and will be rewritten if it gains traction. An internal tool where performance doesn't matter and the team knows Gin already.
These are real cases. Ship with Gin, prove the idea, rewrite if it sticks. The mistake isn't using a framework for prototyping it's keeping the framework as the system outgrows the constraints the prototype was designed for.
The signal: you're fighting the framework's abstractions to get behavior you want. You're writing c.Set("user", claims) and c.Get("user").(Claims) instead of typed context keys. You've written a custom recovery middleware because the default one doesn't log the way your team needs. You're wrapping Gin handlers to make them work with your internal tracing library.
When the framework is creating work, not eliminating it, you've held it too long.
What Chi Offers and Whether It's Worth It
Chi occupies a middle ground worth understanding. It implements http.Handler, so it composes cleanly with the stdlib. Handlers are plain func(w http.ResponseWriter, r *http.Request). Middleware is func(http.Handler) http.Handler. Nothing couples to chi switching away is mechanical, not a rewrite.
The one genuine advantage over Go 1.22's standard ServeMux: group-scoped middleware.
Untitled1r := chi.NewRouter()23r.Group(func(r chi.Router) {4 r.Use(AuthMiddleware) // scoped to this group only5 r.Get("/admin/users", listUsers)6 r.Post("/admin/users", createUser)7})89r.Get("/health", healthCheck) // no auth required
In the stdlib, you'd create a separate http.NewServeMux() for the admin routes and mount it which works, but is slightly more ceremony per group. If you have multiple route groups with different middleware stacks, chi's group API is cleaner.
That's the entire case for chi over Go 1.22 vanilla routing. Not spectacular. Not mandatory. Worth it if group-scoped middleware matters to your routing structure. Not worth it if you're applying a single middleware chain to everything use the stdlib.
The Part That's Hardest to Measure: Error Handling Inconsistency
The most expensive framework cost isn't performance overhead. It's error handling inconsistency, and it compounds across the codebase.
In a vanilla Go service, every handler returns an error. An error middleware at the top of the chain catches it, logs once with the full context chain, and writes the appropriate HTTP response. One log line per request error, at the boundary. Errors from the storage layer bubble up wrapped with context: fmt.Errorf("finding product %s: %w", id, err). By the time the error reaches the middleware, it has a complete story.
In a framework-based service, errors get handled wherever the developer remembered to handle them. Some get c.JSON(500, ...). Some get logged at the storage layer AND the handler AND the middleware. Some get swallowed because someone forgot to check. The result is inconsistent HTTP responses and duplicate or missing log entries. Production debugging becomes archaeological.
This is fixable in frameworks Echo's centralized HTTPErrorHandler gets you partway there but the framework's design doesn't enforce it, so it requires discipline on every handler, every time. The vanilla Go pattern enforces it structurally.
In the next post, we build a service scaffold that makes this correct by default.
FAQ
Q: Is Gin faster than net/http? A: No, not meaningfully on real workloads. Gin's router (httprouter underneath) is faster than the old stdlib ServeMux for route matching, but Go 1.22's enhanced ServeMux closed most of that gap. Your handlers, business logic, and database calls will always dominate your latency budget. The router is never your bottleneck in production.
Q: What about Fiber? It benchmarks faster than everything.
A: Fiber is built on fasthttp, which bypasses net/http entirely. It's faster on microbenchmarks because it avoids net/http's allocations. But it's incompatible with the net/http ecosystem any middleware or library expecting http.Handler won't work. For most real services, the raw throughput advantage doesn't translate to production wins unless you're handling thousands of tiny, compute-free requests per second with nothing else happening.
Q: We're already on Gin. Should we migrate? A: Not unless you're actively fighting it. Rewrites have a cost, and the cost of migrating 100 Gin handlers isn't repaid by ideological purity. Migrate when the framework is creating more work than it's saving when you're wrapping, adapting, and working around it on a weekly basis.
Q: Is GORM ever the right call?
A: Yes. For CRUD-heavy internal tools, admin panels, or services where every query maps cleanly to a single model, GORM's productivity benefit is real. The rule of thumb: if you can implement every query in your service using GORM's ORM API without db.Raw(), GORM is probably fine. When more than 20% of your queries need db.Raw(), the ORM is overhead without payoff.
Q: What's the stack you'd actually recommend for raw DB access?
A: database/sql with pgx/v5 as the Postgres driver, plus sqlc to generate type-safe Go from your SQL files. You write real SQL no surprises, no hidden queries and sqlc generates the Go boilerplate. Migrations go through goose or migrate. No reflection, no schema drift, no N+1 hidden behind an elegant API.
Go's standard library is unusually capable. Frameworks solve real problems they're just problems that often don't exist at the start of a project, and by the time they do, the coupling has already grown in.
At SpectreDev, new services start with vanilla Go and acquire dependencies only when the stdlib genuinely can't do what's needed. We reach that point less often than you'd expect.
Internal links used:
- API design for high-throughput systems error handling patterns at scale
- Production Go scaffold post ``
External links used:
- None
Word count: ~1,580