JavaRush /Courses /Go SELF /ServeMux patterns with methods and wildcards in Go 1.22+

ServeMux patterns with methods and wildcards in Go 1.22+

Go SELF
Level 60, Lesson 0
Available

1. Why improve routing: fewer if statements — less pain

When you’re just starting to write an HTTP server, it feels natural to make one handler and build a “mini-dispatcher” inside it: check r.URL.Path, check r.Method, slice up the path string, do strings.Split, and then hope you didn’t forget a slash anywhere. It works… right up until the moment you have more than three routes, and the team (or you a week later) tries to understand why POST /tasks suddenly ends up in the “view task” handler.

In Go 1.22 the standard library made a very nice step forward: net/http.ServeMux got method-aware patterns and wildcards. That means you can describe routes as “GET /tasks” and “POST /tasks” right at the routing level, rather than manually inside handler code. Wildcard segments like {id} and {path...} (capturing part of the path) were added too. In the Go 1.22 release notes, this is explicitly called out as a standard library update: patterns used by net/http.ServeMux now accept methods and wildcard segments, with examples like GET /task/{id}/.

The main benefit for us as beginners: less string “wizardry,” more declarative code. The server is more readable, testing is easier, and errors like “oops, forgot to check the method” become noticeably less common.

2. http.ServeMux as a request dispatcher

Imagine your server is a delivery office, and a request is a courier with a slip of paper: “Method: GET, path: /api/v1/tasks/10”. ServeMux is the receptionist at the entrance who looks at the slip and decides which specialist to send the courier to. If the receptionist is slow, everyone lines up and complains.

ServeMux has a simple job: match the request to a pattern that you registered via mux.Handle(...) or mux.HandleFunc(...). Previously, patterns were mostly “about the path,” and developers often registered one pattern /tasks, and then inside the handler did a switch on r.Method. That worked, but it constantly produced copy-paste and small mistakes.

Now we can define a pattern so that the method becomes part of the route. That is, the “receptionist” looks not only at “where they’re going” (/tasks), but also at “why they’re going” (GET/POST). That’s exactly what we want for a proper API.

To cement the mental picture, here’s a diagram of “how a request flows through the mux”:

flowchart TD
    A[Client sends a request] --> B[http.Server]
    B --> C[ServeMux: find a matching pattern]
    C -->|found| D[Handler]
    C -->|not found| E[404 / 405 depending on the situation]
    D --> F[Response to the client]

Important: we’re still in the world of the standard net/http, with no external routers and no “framework magic.” It’s the same ServeMux; it just got smarter.

3. Method-aware patterns: GET /tasks without checking r.Method

When you first see a string like "GET /tasks", it can feel like some fancy syntax “like in frameworks.” But it’s actually a very down-to-earth idea: ServeMux takes a string pattern, and starting with Go 1.22 that pattern can include the HTTP method.

The most common practical win is that you stop writing the same thing in every handler:

if r.Method != http.MethodGet {
    w.WriteHeader(http.StatusMethodNotAllowed)
    return
}

and instead you simply register the correct route. Handlers become shorter and more straightforward: “I do one action, for one method.”

Minimal example: GET /health

package main

import (
	"fmt"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprintln(w, "ok") // ok
	})

	_ = http.ListenAndServe(":8080", mux)
}

Notice the form: there must be a space between the method and the path. This is not “style”; it’s part of the pattern syntax.

Two methods on the same path: GET /tasks and POST /tasks

In APIs this is a classic: GET means read the list, POST means create.

package main

import (
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("list tasks\n"))
	})

	mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusCreated)
	})

	_ = http.ListenAndServe(":8080", mux)
}

Here’s the key idea: one path — multiple routes. And that’s normal. The receptionist (mux) simply sees different “routing rules.”

Why this is considered “the Go way”

Go really likes infrastructure rules to be expressed in one place. When you smear method checks across handlers, you end up with lots of tiny inconsistencies. Then someone adds PUT, forgets to set Allow, returns 400 somewhere instead of 405 — and suddenly you’ve got a zoo of behaviors.

If you’ve already seen the idea of “wrapping handlers with an adapter,” this is the same philosophy. In older materials from the Go team there’s an example where they create their own type appHandler (a function type) and implement ServeHTTP on it to centralize error handling. It’s explicitly emphasized there: a receiver can be a function, and that helps keep infrastructure in one place.

With method-aware patterns you get a similar effect: part of the “infrastructure” moves from the handler body to the route-registration site.

4. Wildcards in ServeMux: {id} and {path...}

If method-aware patterns remove the pain around r.Method, wildcards remove the pain around string operations on the path. And string operations on paths are the kind of bugs that start out as “I’m being careful,” and then suddenly break on a double slash, an empty segment, or an unexpected tail.

{id}: a single path segment

A pattern like:

  • GET /tasks/{id}

means: after /tasks/ there must be exactly one segment, for example /tasks/12 or /tasks/abc. At this stage we only need to understand matching: the route exists, and the request “lands” in the handler.

The meaning of {id} is “a variable part of the path goes here.” Yes, we’re not discussing how to extract the value yet (that’s a separate topic), but the very fact of a “variable segment” already saves us from manual strings.Split.

{path...}: a multi-segment path tail

Sometimes you need to match not one segment, but “everything after this.” A typical example is serving static files (inside an API it’s less common, but as a teaching example it’s great).

A pattern like:

  • GET /assets/{path...}

means: after /assets/ there can be logo.png, css/app.css, or images/icons/edit.svg — all of that is the “tail,” matched as a whole. It’s convenient when you want a single handler to serve an entire URL “directory.”

Why you need this in practice

Because as soon as you start building an API with resources, you almost inevitably end up with a pattern like:

  • list: GET /tasks
  • work with a specific entity: GET /tasks/{id}
  • delete: DELETE /tasks/{id}

And if you don’t use wildcards, you’ll almost certainly slide into “let’s parse r.URL.Path by hand.” That’s the same path into the woods where it’s very easy to step on a rake.

How ServeMux chooses the best route

When you have more routes, a natural question appears: if several patterns “match,” who wins?

Instead of a dry list of rules (which people usually read like a spell), let’s use everyday logic. If the receptionist has two rules:

  • “send all requests to /assets/… to the static assets department”
  • “send all requests to /assets/logo.png to the designer; they really love that file”

then the more specific rule should win.

In ServeMux it’s the same idea: the more specific pattern wins. And method-aware patterns add another layer of specificity: GET /tasks is more specific than just /tasks “for all methods.” So if you registered both, a GET /tasks request should logically go to the “GET branch.”

One more nuance that’s useful for beginners to remember: query parameters (after ?) do not participate in handler selection. That means /tasks?done=true still matches as /tasks. If you try to write a pattern like "GET /tasks?done=true", you simply won’t hit your handler and you’ll stare at a 404 thinking “but everything is correct!”

5. HTTP application skeleton: registering routes

Right now our goal is not to implement task storage and not to build CRUD (that’s a separate story), but to assemble a routing skeleton where you can see which routes exist and how method+path turns into a specific handler.

Let’s build a minimal server that:

  • returns ok on GET /health
  • can distinguish GET /api/v1/tasks from POST /api/v1/tasks
  • has routes to work with a task by id: GET and DELETE on /api/v1/tasks/{id}
  • shows an example of a “tail wildcard” on /assets/{path...}
package main

import (
	"fmt"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /health", health)

	mux.HandleFunc("GET /api/v1/tasks", listTasks)
	mux.HandleFunc("POST /api/v1/tasks", createTask)

	mux.HandleFunc("GET /api/v1/tasks/{id}", getTask)
	mux.HandleFunc("DELETE /api/v1/tasks/{id}", deleteTask)

	mux.HandleFunc("GET /assets/{path...}", assets)

	_ = http.ListenAndServe(":8080", mux)
}

func health(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "ok") // ok
}

func listTasks(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "list tasks") // list tasks
}

func createTask(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusCreated)
	fmt.Fprintln(w, "created") // created
}

func getTask(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "get task by id") // get task by id
}

func deleteTask(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusNoContent)
}

func assets(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "static asset") // static asset
}

What matters in this code:

  • We do not check r.Method inside handlers, because ServeMux already selected the handler by method.
  • We do not parse r.URL.Path. We simply registered routes. Yes, the getTask handler still doesn’t know which specific {id} came in — but at least it’s guaranteed to be called only for /api/v1/tasks/{id}, not for something else. That’s already a big win.
  • We explicitly create mux := http.NewServeMux() and pass it into ListenAndServe. This reduces the chance of “losing” routes by accidentally registering them on DefaultServeMux and starting the server with a different mux (a classic bug: “why do I get 404 for everything?”).

How the code “feels” different

Without big words: method-aware patterns and wildcards make the server less “stringy” and more “structural.”

When you look at the route-registration block, you see almost an API specification. It’s like a little table of “what we support.” And if in the future you want, for example, to properly distinguish between “route does not exist” and “route exists but the method doesn’t match,” having the method in the pattern makes that behavior much more natural.

Plus, purely psychologically: fewer manual if statements means fewer places to make mistakes. That’s especially important for beginners, because your brain is already busy not mixing up := and = and not forgetting an import.

6. Common mistakes when working with ServeMux patterns

Mistake #1: forgot the space between the method and the path.
A pattern string is not “it’ll parse somehow,” but a concrete syntax. If you write "GET/api/v1/tasks" instead of "GET /api/v1/tasks", you’ll get a route that doesn’t match at all, and you’ll stare at 404 until you start suspecting your internet is broken.

Mistake #2: trying to shove query parameters into the pattern.
"/tasks?done=true" in a pattern doesn’t work conceptually: the query is not part of the path. The correct pattern is "GET /tasks", and filtering is separate logic that reads r.URL.Query() (and it must not affect handler selection).

Mistake #3: mixing two styles for no reason: method-aware patterns and switch on r.Method inside.
Sometimes people do this “just in case,” and then forget to update both places. As a result, ServeMux already guarantees the method, but the handler can still return 405 due to an outdated check. You end up fighting yourself. Pick one style for a given part of the code: if the method is in the pattern, you usually don’t re-check it inside the handler.

Mistake #4: parsing r.URL.Path by hand even when {id} exists.
This is a habit from other languages or early experiments. The problem is that manual path parsing quickly turns into a ball of conditions that’s hard to cover with tests and easy to break. If ServeMux supports wildcards, it’s better to let it do its job than compete with it in “guess the slash.”

Mistake #5: registering routes on DefaultServeMux but starting the server with a different mux.
When you write http.HandleFunc(...), you work with DefaultServeMux. When you write mux := http.NewServeMux() and mux.HandleFunc(...), you work with a specific mux. If you mix up these two worlds, your routes will “disappear.” So for learning projects it’s better to keep a simple rule: always create an explicit mux and always pass it into ListenAndServe.

1
Task
Go SELF, level 60, lesson 0
Locked
Service heartbeat
Service heartbeat
1
Task
Go SELF, level 60, lesson 0
Locked
Task board
Task board
1
Task
Go SELF, level 60, lesson 0
Locked
Route map
Route map
1
Task
Go SELF, level 60, lesson 0
Locked
Priority rules
Priority rules
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION