Go’s Standard Library Gets Genuinely Better Routing
Historically, Go’s standard library routing has been pretty barebones, which is why so many third-party routers like gorilla/mux and chi exist. For years, the common approach was to write a big manual dispatch function:
// DELETE /records:
case r.Method == "DELETE" && n == 1 && p[0] == "records":
if !requireLogin(username, r.URL.Path, r, w) {
return
}
deleteAllRecords(ctx, username, rs, w, r)
// POST /records/<ID>
case r.Method == "POST" && n == 2 && p[0] == "records" && len(p[1]) > 0:
if !requireLogin(username, r.URL.Path, r, w) {
return
}
updateRecord(ctx, username, p[1], rs, w, r)
As of Go 1.22, though, the standard library’s net/http package supports pattern-based routing with method matching and wildcards. That same code can be rewritten more cleanly:
mux.HandleFunc("DELETE /records/", app.deleteAllRecords)
mux.HandleFunc("POST /records/{record_id}", app.updateRecord)
Adding authentication is just a matter of wrapping the handlers in middleware:
mux.Handle("DELETE /records/", requireLogin(http.HandlerFunc(app.deleteAllRecords)))
Watch Out for Trailing-Slash Redirects
There is one gotcha with the new ServeMux: if you register a route like /records/, a request for /records (without the trailing slash) will get a redirect to /records/. This can be a real problem for POST requests, because the redirect turns them into GET requests and drops the request body in the process.
The cleanest solution is to design your API routes without trailing slashes: use POST /records rather than POST /records/, which is more conventional anyway.
Generate Database Code with sqlc Instead of Reaching for an ORM
If you know the SQL you want but are tired of hand-writing the Go boilerplate around it, sqlc is a solid middle ground between raw SQL and a full ORM. Write your query:
-- name: GetVariant :one
SELECT *
FROM variants
WHERE id = ?;
And sqlc generates the calling code for you:
const getVariant = `-- name: GetVariant :one
SELECT id, created_at, updated_at, disabled, product_name, variant_name
FROM variants
WHERE id = ?
`
func (q *Queries) GetVariant(ctx context.Context, id int64) (Variant, error) {
row := q.db.QueryRowContext(ctx, getVariant, id)
var i Variant
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Disabled,
&i.ProductName,
&i.VariantName,
)
return i, err
}
The nice part is the flow: when you need a new query, you write the SQL. If you’re unsure of the calling convention, reading the generated function tells you exactly what to pass. It beats digging through ORM docs to figure out how to construct the query you already know you want.
SQLite: Separate Writer Connections and Consider GC Limits
For small SQLite-backed projects, a few practical tips stand out. First, use a dedicated database object for writes and set db.SetMaxOpenConns(1) on it. Without that, concurrent writes from multiple threads can trigger SQLITE_BUSY errors. If faster reads are a priority, you can keep two separate database objects – one for writing, one for reading.
Also worth noting: if you have two tables that never get joined, you can put them in separate database files, keeping connections independent.
On the runtime side, if you are running Go services in small VMs (256MB–512MB), default garbage collector behavior can get you killed. By default, Go lets the heap grow to roughly 2x its current size before collecting. In a tight memory environment, that doubling can easily cross the OOM threshold. Go 1.19 added GOMEMLIMIT, which lets you tell the runtime to trigger a GC when memory usage hits a given ceiling:
export GOMEMLIMIT=250MiB
Setting a limit like 250MB can prevent spurious OOM kills that look like memory leaks but are just the GC waiting too long.
Why Go Works for Small Websites
For small projects, there are several reasons Go remains a low-friction choice:
- Deployment is just copying a static binary; static assets can be embedded with the
embedpackage. - The built-in webserver is production-usable, so there’s no separate WSGI or CGI config layer to manage.
- Installing the toolchain on a server is often just
apt-get install golang-go, thengo build. - The
net/httpAPI is minimal – handlers are just functions that read the request and write a response, so there isn’t much framework magic to internalize. - Since
net/httpis in the standard library, you can build a working site with zero third-party dependencies.
This simplicity makes it easy to step away from a project for a year or two and pick it back up. There may be boilerplate, but it’s readable, plain Go code. That’s arguably a better deal than a magical framework that you have to relearn every time you return to a project.
Gaps That Remain
There are still areas where Go’s ecosystem and the standard library feel thin for web development. Template rendering is one: if your server is mostly an API, it can be easy to avoid html/template altogether, and its usage in Hugo may not convince you. Login systems and CSRF protection are also skills that are easy to skip if your projects don’t require users – which means they’re easy to avoid entirely until a real application demands them. That’s the kind of scenario where a full framework might actually pull its weight.
Recent Go Updates Are Worth Watching
Both GOMEMLIMIT and the new routing arrived in recent Go versions without much fanfare if you weren’t reading the release notes closely. It’s a good reminder to keep an eye on what each new Go release adds – standard library improvements can eliminate third-party dependencies and solve real production problems quietly.



