Updated Jun 23, 2026

Routing & Groups

In Phase 1 you stood up one route and watched Echo answer it. Real APIs have many routes, and the first thing that bites people is which route answered which request. So before any code, let's set the mental model straight.

A route is method + path → handler

📝 A route in Echo is three things glued together: an HTTP method (GET, POST, …), a path (/books, /books/:id), and a handler (func(c echo.Context) error). When a request arrives, Echo looks at the method and the path, finds the one handler registered for that pair, and calls it. Nothing more mysterious than that.

That "method and path" part matters. GET /books and POST /books are two different routes with two different handlers, even though the path text is identical. People coming from frameworks that only key on the path get tripped up here — Echo treats the verb as part of the address.

Under the hood Echo stores all your routes in a radix tree (a prefix tree). You never touch it, but it's why matching stays fast even with hundreds of routes, and why a literal path like /books/new can coexist with a parameter path like /books/:id without a linear scan. 💡 Mental model: think of the tree as a switchboard that routes by shared path prefixes, not a list it walks top to bottom.

A group is the second idea: a set of routes that share a common path prefix (and, later, shared middleware). /api/v1/books and /api/v1/authors clearly belong together; a group lets you say "/api/v1" once instead of typing it on every route.

We'll grow the books API from Phase 1 — the same Book{id, title, author} shape — into a small set of real routes.

Registering methods

Echo gives you one function per HTTP method, each with the same (path, handler) signature:

package main

import (
	"net/http"

	"github.com/labstack/echo/v4"
)

type Book struct {
	ID     int    `json:"id"`
	Title  string `json:"title"`
	Author string `json:"author"`
}

// Our "database" for now: a slice in memory.
var books = []Book{
	{ID: 1, Title: "The Go Programming Language", Author: "Donovan & Kernighan"},
	{ID: 2, Title: "Go in Action", Author: "Kennedy"},
}

func main() {
	e := echo.New()

	e.GET("/books", listBooks)    // read the collection
	e.POST("/books", createBook)  // add to the collection

	e.Logger.Fatal(e.Start(":1323"))
}

func listBooks(c echo.Context) error {
	return c.JSON(http.StatusOK, books)
}

func createBook(c echo.Context) error {
	return c.JSON(http.StatusCreated, Book{ID: 3, Title: "TODO", Author: "TODO"})
}

What just happened: e.GET and e.POST each registered a route on the same path /books but for different verbs, pointing at different handlers. A GET /books request runs listBooks; a POST /books runs createBook. (We're hard-coding createBook's response for now — reading the request body is Phase 3's job.)

The full set is e.GET, e.POST, e.PUT, e.PATCH, e.DELETE, e.HEAD, and e.OPTIONS — all (path, handler). There's also e.Any(path, handler), which registers the handler for every method at that path. Reach for e.Any rarely; being explicit about verbs is usually clearer and safer.

Path params: capturing pieces of the URL

You don't want a separate route per book ID. Instead you declare a path parameter with a :name segment, and read it back inside the handler with c.Param("name").

func main() {
	e := echo.New()

	e.GET("/books", listBooks)
	e.GET("/books/:id", getBook) // :id is a path parameter

	e.Logger.Fatal(e.Start(":1323"))
}

func getBook(c echo.Context) error {
	id := c.Param("id") // always a string, e.g. "2" from /books/2

	for _, b := range books {
		// strconv.Itoa converts the int ID to a string to compare.
		if id == strconv.Itoa(b.ID) {
			return c.JSON(http.StatusOK, b)
		}
	}

	return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}

What just happened: the route /books/:id matches any single segment after /books/ and stashes it under the name id. A request to /books/2 makes c.Param("id") return the string "2". ⚠️ Path params are always strings — Echo doesn't guess types. If you need a number, you convert it yourself (here with strconv.Itoa on the other side; you'll more often parse the param with strconv.Atoi). Remember to add "strconv" to your imports.

There's also a wildcard segment, *, for "match the rest of the path, slashes and all." It's mostly used for serving files:

e.GET("/files/*", func(c echo.Context) error {
	path := c.Param("*") // e.g. "docs/intro.pdf" for /files/docs/intro.pdf
	return c.String(http.StatusOK, "you asked for: "+path)
})

What just happened: unlike :id, which captures exactly one segment, * captures everything after /files/ including any /. You read it with the special name c.Param("*"). Use it sparingly — for static assets or catch-alls — not for normal API routes, where named params read better.

Query params: the bit after the ?

Path params identify which resource. Query params — the ?key=value pairs at the end of a URL — usually filter or modify a request. Think GET /books?author=Kennedy. They're optional by nature, so Echo reads them differently: c.QueryParam("name") returns the value, or an empty string "" if it wasn't supplied.

func listBooks(c echo.Context) error {
	author := c.QueryParam("author") // "" if ?author= was not in the URL

	if author == "" {
		return c.JSON(http.StatusOK, books) // no filter: return all
	}

	var filtered []Book
	for _, b := range books {
		if b.Author == author {
			filtered = append(filtered, b)
		}
	}
	return c.JSON(http.StatusOK, filtered)
}

What just happened: GET /books returns everything, while GET /books?author=Kennedy returns only the matching ones. The key thing: c.QueryParam("author") never errors on a missing param — it just hands back "". ⚠️ That's a footgun if you treat "" as "no books matched" instead of "no filter requested" — so we check for the empty string first and decide what it means.

Need everything at once? c.QueryParams() returns a url.Values (a map[string][]string) holding every query key and its value(s):

func searchBooks(c echo.Context) error {
	params := c.QueryParams() // url.Values, e.g. {"author": ["Kennedy"], "sort": ["title"]}
	return c.JSON(http.StatusOK, params)
}

What just happened: c.QueryParams() gives you the whole bag of query values, which is handy when a single key can repeat (?tag=go&tag=web) or when you want to loop over unknown filters. For one known key, stick with c.QueryParam — it's simpler.

Groups: say the prefix once

As the API grows you'll want a version prefix like /api/v1 so you can ship /api/v2 later without breaking existing clients. Typing /api/v1/... on every route is tedious and easy to get wrong. A group fixes that.

func main() {
	e := echo.New()

	v1 := e.Group("/api/v1") // every route below is prefixed with /api/v1

	v1.GET("/books", listBooks)        // -> GET /api/v1/books
	v1.GET("/books/:id", getBook)      // -> GET /api/v1/books/:id
	v1.POST("/books", createBook)      // -> POST /api/v1/books

	e.Logger.Fatal(e.Start(":1323"))
}

What just happened: e.Group("/api/v1") returns a group value (v1) that carries the prefix. Calling v1.GET("/books", ...) registers the route at the combined path /api/v1/books. The group has the same method functions as the instance — v1.GET, v1.POST, and so on — so the routes you write inside read cleanly, with the shared prefix factored out.

💡 The bigger payoff is middleware. A group can attach middleware that runs only for its routes — for example, requiring auth on everything under /admin:

// authMiddleware is defined in Phase 5 — shown here only to make the shape concrete.
admin := e.Group("/admin", authMiddleware)   // middleware as a second argument
admin.GET("/stats", adminStats)              // protected: auth runs first

// You can also attach it after creating the group:
admin.Use(authMiddleware)

What just happened: passing authMiddleware as the second argument to e.Group (or calling admin.Use(...)) means every route in that group runs the middleware before its handler — so /admin/* is protected without repeating the check in each handler. We're forward-referencing here: what middleware actually is, and how to write authMiddleware, is Phase 5. For now, just hold the shape: groups bundle a prefix and shared middleware.

Recap

  • A route is method + path → handler; GET /books and POST /books are distinct routes that share a path but not a handler.
  • Register routes with e.GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS(path, handler), or e.Any for every method at once.
  • Path params (/books/:id) are read with c.Param("id") and are always strings — convert them yourself. The wildcard * captures the rest of the path via c.Param("*").
  • Query params are read with c.QueryParam("author") (returns "" when absent) or c.QueryParams() for the whole url.Values bag.
  • Groups (e.Group("/api/v1")) factor out a shared prefix, and can carry shared middleware via a second argument or g.Use(...).

Quick check

[
  {
    "q": "You register e.GET(\"/books\", listBooks). A request comes in as POST /books. What happens?",
    "choices": ["listBooks runs anyway", "Echo returns 405 Method Not Allowed because no handler matches that method+path", "Echo runs the first route in the tree", "The server panics"],
    "answer": 1,
    "explain": "A route is method AND path. GET /books and POST /books are different routes; with no POST handler registered, Echo responds 405 Method Not Allowed."
  },
  {
    "q": "For the route /books/:id, what does c.Param(\"id\") return for a request to /books/42?",
    "choices": ["The integer 42", "The string \"42\"", "nil", "An error you must handle"],
    "answer": 1,
    "explain": "Path params are always strings. c.Param(\"id\") returns \"42\"; convert it yourself with strconv.Atoi if you need a number."
  },
  {
    "q": "A request to GET /api/v1/books has no query string. What does c.QueryParam(\"author\") return?",
    "choices": ["An error", "nil", "An empty string \"\"", "It panics on a missing key"],
    "answer": 2,
    "explain": "c.QueryParam never errors on a missing key — it returns \"\". Check for the empty string to decide whether a filter was actually requested."
  }
]

← Phase 1: What Echo Is & Your First Server · Guide overview · Phase 3: Binding & Validation →

Check your understanding

1. You register e.GET("/books", listBooks). A request comes in as POST /books. What happens?

2. For the route /books/:id, what does c.Param("id") return for a request to /books/42?

3. A request to GET /api/v1/books has no query string. What does c.QueryParam("author") return?

Was this page helpful?