The counting problem and the routing problem
You need to print numbers from zero to nine. In older Go versions, you wrote a three-part loop with an explicit counter. You needed to route HTTP requests to different handlers based on URL patterns, but the standard library only supported exact matches or directory prefixes with rigid rules. Go 1.22 changes both of those constraints. It gives you a cleaner way to count and a smarter way to route.
These two features look small on the surface. They solve real friction points that developers hit daily. The integer range syntax removes boilerplate from simple iteration. The enhanced ServeMux removes the need for third-party routers in most applications. Both features follow the same design principle: make the common case obvious without hiding the mechanics underneath.
Range over integers: syntax sugar with zero overhead
The range keyword used to work only with slices, maps, and channels. You had to manually declare the index variable, set the starting value, and write the increment step. The new syntax treats an integer literal as an implicit sequence. for i := range 10 generates the numbers zero through nine.
This is not a hidden slice allocation. It is a direct translation to the old three-part loop at compile time. The compiler sees the literal and emits the exact same machine code as for i := 0; i < 10; i++. The only difference is readability. You get the clarity of a declarative loop without paying for runtime overhead.
Here is the simplest form:
package main
import "fmt"
// main prints numbers zero through nine using the new range syntax.
func main() {
// range over a literal generates 0..n-1 at compile time
for i := range 10 {
// i is a fresh variable on each iteration
fmt.Println(i)
}
}
The compiler expands this before optimization. No heap allocation occurs. No temporary slice is created. The generated assembly matches the traditional loop exactly. You get cleaner source code with identical performance.
Go developers often debate syntax sugar. The language has historically resisted features that obscure control flow. This feature passes that test because the expansion is mechanical and predictable. You can always mentally translate it back to the three-part form. The language team added it after years of community requests because the old syntax forced developers to write boilerplate for a trivial operation.
Goroutines are cheap. Channels are not magic. Range over integers is just a cleaner way to write a counter.
How the compiler handles it
The compiler treats the integer range as a syntactic shortcut. During the parsing phase, it recognizes the range <int> pattern and rewrites the abstract syntax tree to a standard for loop. This happens before type checking and before optimization. The rest of the compilation pipeline sees a normal loop.
This design choice matters for two reasons. First, it avoids introducing a new runtime type for integer sequences. Second, it keeps the feature compatible with existing tooling. Linters, formatters, and debuggers all work without special cases.
The loop variable capture bug that plagued older Go versions is also resolved in 1.22. Before this release, closures inside a loop captured the same variable instance, leading to subtle bugs when launching goroutines. The compiler now creates a fresh variable for each iteration by default. You no longer need to write i := i inside the loop body to fix the capture. The language handles it automatically.
If you forget to capture a loop variable in older versions, the compiler used to allow it and the bug manifested at runtime. Go 1.22 makes the correct behavior the default. You can rely on the loop variable being fresh every time.
Trust the compiler. Write the loop that reads clearly.
ServeMux enhancements: pattern matching without the framework
HTTP routing used to be a game of string matching. You registered exact paths like /api/v1 or prefixes like /static/. If you wanted to support multiple API versions, you registered each one manually. The enhanced ServeMux adds pattern matching. You can use curly braces for path parameters like /api/v{version} and the mux automatically extracts the value.
Prefix matching now coexists with exact matching using a strict priority system. Exact matches win. Then path parameters. Then prefixes. This priority system prevents ambiguous routing and makes the behavior predictable.
Here is the basic setup:
package main
import "net/http"
// main registers routes with wildcard and prefix matching.
func main() {
// NewServeMux returns the enhanced router with pattern support
mux := http.NewServeMux()
// Curly braces define a path parameter that gets extracted at runtime
mux.HandleFunc("/api/v{version}", handleAPI)
// Trailing slash marks a prefix match for all subpaths
mux.HandleFunc("/static/", handleStatic)
// ListenAndServe starts the server with the enhanced mux
http.ListenAndServe(":8080", mux)
}
The router parses the registered patterns and builds a tree structure. When a request arrives, it walks the tree to find the best match. The matching algorithm checks exact paths first, then evaluates path parameters, and finally falls back to prefixes. This deterministic order means you never have to worry about route collision order.
The context.Context always goes as the first parameter in handler functions, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. The enhanced mux does not change this convention. It only changes how the URL gets parsed before your handler runs.
gofmt is mandatory. Don't argue about indentation or spacing in your route registrations. Let the tool decide. Most editors run it on save, and the standard library expects consistent formatting.
A realistic HTTP server setup
Real applications need to extract path parameters, handle static assets, and wrap errors properly. The enhanced mux makes this straightforward without pulling in external dependencies.
Here is a complete handler setup:
package main
import (
"fmt"
"net/http"
)
// handleAPI responds to versioned API requests.
func handleAPI(w http.ResponseWriter, r *http.Request) {
// PathValue extracts the named parameter from the matched pattern
version := r.PathValue("version")
// Return early if the parameter is missing or empty
if version == "" {
http.Error(w, "missing version", http.StatusBadRequest)
return
}
// Write a simple JSON response with the extracted version
fmt.Fprintf(w, `{"version": "%s"}`, version)
}
// handleStatic serves files from the /static/ prefix.
func handleStatic(w http.ResponseWriter, r *http.Request) {
// Strip the prefix to get the relative file path
path := r.URL.Path[len("/static/"):]
// Serve the file or return a not found error
if path == "" {
http.Error(w, "index not found", http.StatusNotFound)
return
}
fmt.Fprintf(w, "serving: %s", path)
}
The PathValue method returns an empty string if the key does not exist. You should always check for empty values before using them. The prefix handler strips the registered prefix manually because the mux does not do it automatically. This gives you full control over how subpaths are processed.
The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. HTTP handlers follow the same principle: check inputs early, return clear errors, and keep the happy path linear.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and runtime behavior
The enhanced mux is strict about route registration. If you register two patterns that conflict, the program panics at startup. The panic message reads http: RegisterPattern called with conflicting patterns. This is intentional. Ambiguous routing leads to unpredictable behavior in production. The runtime catches it early so you fix it before deployment.
Path parameters only match a single path segment. The pattern /api/v{version} matches /api/v1 but not /api/v1/users. If you need to capture multiple segments, you must register a separate route or use a prefix handler. The mux does not support greedy wildcards. This limitation keeps the matching algorithm fast and predictable.
The integer range syntax does not work with variables in older compiler versions, but Go 1.22+ accepts any expression that evaluates to an integer at compile time or runtime. If you pass a non-integer type, the compiler rejects the program with invalid operation: range over non-integer type. You cannot range over a float or a string. The feature is strictly for integer sequences.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. HTTP handlers run in their own goroutines, so long-running operations inside them must respect request cancellation. Use r.Context().Done() to detect client disconnects and stop work early.
The worst goroutine bug is the one that never logs.
When to use what
Use a traditional three-part loop when you need a custom increment step or a non-integer counter. Use range over integers when you need to iterate from zero to n minus one and want cleaner syntax. Use a standard ServeMux prefix when you need to catch all subpaths under a directory. Use a wildcard pattern when you need to extract a single path segment like an ID or version. Use a third-party router when you need regex matching, middleware chains, or advanced grouping. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.