---
title: "Маршрутизация в Echo: параметры пути, query и приоритеты"
description: "Маршруты в Echo: path-параметры :id через c.Param, query-параметры c.QueryParam, wildcard, приоритет статических маршрутов и честная ошибка 400 — с примерами."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/03-marshrutizaciya/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Маршрутизация в Echo: параметры пути, query и приоритеты

*Курс «Echo: минималистичный быстрый фреймворк», урок 3 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/03-marshrutizaciya/*

Маршрутизация в Echo — это умение описать /users/42, /users/42/posts/7 и /search?q=echo&page=2, достать значения через c.Param и c.QueryParam и предсказать, какой маршрут победит, если под запрос подходят два. Разбираем path-параметры, wildcard и query на коде, который можно скопировать в проект из прошлого урока.

Любой API живёт на двух видах данных в URL. Первое — путь: /users/42 означает «пользователь номер 42», и 42 здесь — часть маршрута. Второе — строка запроса после знака вопроса: /search?q=echo&page=2 — уточнения к тому же пути, например номер страницы. В этом уроке научимся регистрировать такие маршруты в Echo и доставать из них значения, а заодно разберём правило, которое в первый день обязательно путает новичков: что вернуть, когда один запрос подходит под два маршрута сразу. Всё проверяемое здесь выполняется локально: дописывайте маршруты в main.go из прошлого урока и смотрите ответы через curl.

### Как работает роутер Echo

Маршруты Echo живут в radix-дереве — дереве из префиксов путей, где поиск совпадения выглядит как спуск от корня по общим частям строк. Заявление из readme — поиск маршрута без динамических аллокаций памяти — на практике означает, что сопоставление запроса с маршрутом не создаёт мусора для сборщика мусора: на больших нагрузках это часть экономии. Важнее для повседневной работы другое — правила приоритета. Если под запрос подходят несколько маршрутов, Echo выбирает самый конкретный: статический сегмент (/users/new) важнее параметра (:id), параметр важнее wildcard (*). Дубликатов в дереве не бывает: повторная регистрация одного и того же метода и пути вызывает панику при старте — сразу видно, что маршруты в проекте конфликтуют. И обратите внимание: порядок объявления маршрутов, в отличие от многих фреймворков, на результат не влияет — важна только специфичность правила.

### Path-параметры: :id и c.Param

```go
e.GET("/users/:id", func(c echo.Context) error {
        id := c.Param("id")
        return c.JSON(http.StatusOK, map[string]string{"id": id})
})
```

Двоеточие в пути объявляет параметр: :id означает «в этом месте один сегмент, любое значение». Значение достаётся c.Param("id") по имени — всегда строка. Запросы /users/42 и /users/anna попадут в один обработчик с id = "42" и id = "anna". А вот /users/42/posts/7 сюда уже не попадёт: :id матчит ровно один сегмент.

### Числа из параметров и ошибка 400

Параметр — всегда строка, и «42» из URL — это не число. Если id по смыслу числовой, конвертируйте вручную через strconv.Atoi и честно обработайте неудачу: /users/abc — это не ошибка сервера, а запрос клиента с мусором, и отвечать надо 400, а не 500. Возвращать ошибку будем через echo.NewHTTPError, знакомую с прошлого урока: центральный обработчик превратит её в ответ со статусом и сообщением. Шаблон ниже — типовая обёртка, которая потом разрастётся в настоящий обработчик с базой данных, — сохраните его в проект: он переезжает из сервиса в сервис почти без изменений.

### getUser: параметр, конвертация, ответ

```go
func getUser(c echo.Context) error {
        id, err := strconv.Atoi(c.Param("id"))
        if err != nil {
                return echo.NewHTTPError(http.StatusBadRequest, "id должен быть числом")
        }

        // здесь был бы поиск в базе: users.Find(id)

        return c.JSON(http.StatusOK, map[string]any{
                "id":   id,
                "name": fmt.Sprintf("user-%d", id),
        })
}

// в main(): e.GET("/users/:id", getUser)
```

strconv.Atoi парсит строку в int и возвращает ошибку, если в строке не только цифры. После успешной конвертации id — настоящее число: его можно передавать в базу или сервисный слой. fmt.Sprintf здесь имитирует имя — в реальном проекте на этом месте стоит запрос к хранилищу. Нужны импорты strconv и fmt.

### Два параметра в одном маршруте

Параметров в пути может быть сколько угодно, если между ними есть статические разделители: /users/:uid/posts/:pid — корректный маршрут, где uid — автор, pid — конкретный пост. Так выражают вложенность ресурсов: все посты пользователя и один пост в отдельности. Правило вкуса, которое я советую: не ставьте два «голых» параметра подряд без статики — /users/42/7 не говорит клиенту, что 42 — пользователь, а 7 — пост, и читать такой код через полгода тяжело. Ещё одна ловушка — опечатка в имени: если в c.Param("uid") вы напишете uids, ошибки компиляции не будет, паники не будет — вернётся пустая строка. Поэтому параметры называют одинаково в маршруте и в коде, а пустое значение из c.Param — первый подозреваемый, когда ответ приходит без ожидаемых данных.

### Query-параметры: c.QueryParam

Всё после знака вопроса — строка запроса: /search?q=echo&page=2&tag=go&tag=web. Доступ в Echo двоякий: c.QueryParam("q") возвращает значение одного ключа, c.QueryParams() — url.Values со всеми парами (url.Values — это map[string][]string: один ключ может нести несколько значений, как tag выше; первое значение достаётся через .Get("tag")). Поведение при отсутствии параметра простое и заодно коварное: c.QueryParam вернёт пустую строку — без ошибки, без nil. В Gin есть DefaultQuery, подставляющий значение по умолчанию; в Echo такого аналога нет — дефолты подставляете сами, обычно через fallback после неудачной конвертации в число. Это не «недоработка», а последовательность минимализма, но помнить о ней надо: запрос без ?page= — нормальная ситуация, а не исключение.

### Пагинация: page и limit с проверкой

```go
func search(c echo.Context) error {
        q := c.QueryParam("q")

        page, err := strconv.Atoi(c.QueryParam("page"))
        if err != nil || page < 1 {
                page = 1
        }

        limit, err := strconv.Atoi(c.QueryParam("limit"))
        if err != nil || limit < 1 || limit > 100 {
                limit = 20
        }

        return c.JSON(http.StatusOK, map[string]any{
                "query": q,
                "page":  page,
                "limit": limit,
        })
}

// в main(): e.GET("/search", search)
```

Пустая строка вместо page — Atoi вернёт ошибку, и сработает fallback: page = 1. Верхняя граница limit — не паранойя: ?limit=1000000 от случайного клиента не должен превращаться в запрос, который уронит базу. Числовые query-параметры без дефолтов и границ — типовая дыра в новых API.

### Что вернёт search без параметров

```go
// GET /search  (запрос без ?page= и ?limit=)

q := c.QueryParam("q")
page, err := strconv.Atoi(c.QueryParam("page"))
if err != nil || page < 1 {
        page = 1
}
```

**Что выведет программа?**

- 500 Internal Server Error: пустая строка не конвертируется в число
- {"query":"","page":0,"limit":0} — нулевые значения по умолчанию
- ✅ {"query":"","page":1,"limit":20} — сработал fallback
- 400 Bad Request: параметры обязательны

c.QueryParam вернёт "", Atoi выдаст ошибку — и сработает ветка fallback: page = 1, limit = 20. Отсутствие параметров у поиска — норма, а не ошибка клиента.

### Wildcard и приоритет: кто победит

Звёздочка в конце пути — wildcard: /files/* матчит хвост любой длины, включая вложенные подпути, а значение достаётся c.Param("*"). Классическое применение — раздача файлов: /files/docs/report.pdf вернёт вам docs/report.pdf одним куском. Теперь тот самый запутывающий момент: пусть объявлены /users/new и /users/:id. Запрос GET /users/new подходит под оба маршрута — и Echo выберет статический /users/new, без исключений и без учёта порядка регистрации. Логика простая: параметр — это «любое значение», статика — «именно это», поэтому конкретное правило всегда сильнее. А если настоящих /users/new не существует, а запрос пришёл? Тогда id будет строкой "new", и уже ваш код решит, что с ней делать: после неудачного Atoi — честная ошибка 400. Отдельно запомните: /users и /users/ — разные маршруты; склейкой trailing slash занимается middleware.RemoveTrailingSlash() через e.Pre — вернёмся к нему в уроке про [middleware](/courses/echo/lessons/06-middleware/).

### Виды маршрутов: шпаргалка

| Маршрут | Матчится | Как достать значение |
| --- | --- | --- |
| GET /health | точный путь /health | параметров нет |
| GET /users/:id | один сегмент после /users/ | c.Param("id") |
| GET /users/:uid/posts/:pid | два сегмента на своих позициях | c.Param("uid"), c.Param("pid") |
| GET /files/* | хвост любой длины, включая вложенные | c.Param("*") |
| GET /search | путь /search, остальное — после знака ? | c.QueryParam("q"), c.QueryParams() |

### Проверка: приоритет маршрутов

**Вопрос:** Объявлены e.GET("/users/:id", a) и e.GET("/users/new", b). Что ответит Echo на GET /users/new?

- Обработчик a с id = "new": параметры сильнее статики
- ✅ Обработчик b: статический маршрут всегда конкретнее параметра
- 404: маршруты конфликтуют
- 405 Method Not Allowed

Приоритет Echo: статика > параметр > wildcard. /users/new — конкретное совпадение, оно и срабатывает. Конфликта здесь нет: это разные маршруты, паника была бы при повторной регистрации точно одинаковых.

### Заполните пропуски в getUser

Восстановите обработчик: метод регистрации, конвертация, чтение параметра и статус для мусорного запроса.

```go
e.___("/users/:id", getUser)

func getUser(c echo.Context) error {
        id, err := strconv.___(c.___("id"))
        if err != nil {
                return echo.NewHTTPError(http.Status___, "id должен быть числом")
        }

        return c.JSON(http.StatusOK, map[string]any{"id": id})
}
```

Подсказка: Маршрут читается методом GET; строку в число конвертирует strconv.Atoi; значение path-параметра — c.Param; статус для мусорного запроса — 400, то есть http.StatusBadRequest.

Правильные ответы в пропусках: `GET`, `Atoi`, `Param`, `BadRequest`

### Соберите обработчик search из строк

Расставьте строки обработчика в правильном порядке — от чтения query до возврата JSON.

Правильный порядок строк:

1. `func search(c echo.Context) error {`
2. `  q := c.QueryParam("q")`
3. `  page, err := strconv.Atoi(c.QueryParam("page"))`
4. `  if err != nil || page < 1 {`
5. `    page = 1`
6. `  }`
7. `  limit, err := strconv.Atoi(c.QueryParam("limit"))`
8. `  if err != nil || limit < 1 || limit > 100 {`
9. `    limit = 20`
10. `  }`
11. `  return c.JSON(http.StatusOK, map[string]any{"query": q, "page": page, "limit": limit})`
12. `}`

### Карточки: маршруты и параметры

- **:id в маршруте** — Объявление path-параметра: матчит один сегмент пути. Значение достаётся по имени через c.Param("id").
- **c.Param("id")** — Значение path-параметра из URL. Всегда строка; при опечатке в имени — пустая строка, без ошибки и паники.
- **c.QueryParam("page")** — Значение query-параметра после знака ?. Строка; если параметра нет — пустая строка. Дефолтов в Echo нет, подставляйте сами.
- **c.QueryParams()** — Все query-параметры как url.Values (map[string][]string): поддержка повторяющихся ключей вроде ?tag=go&tag=web.
- **c.Param("*")** — Хвост пути под wildcard-маршрутом вида /files/*: весь остаток пути одним куском, включая вложенные сегменты.
- **Приоритет маршрутов** — Статический сегмент > параметр :id > wildcard *. Порядок объявления маршрутов в Echo не важен.

### Что дальше по курсу

Маршруты описаны, параметры достаются — следующий логичный шаг: работа с телом запроса и заголовками. Это [урок про запросы и ответы](/courses/echo/lessons/04-request-response/): c.Bind, c.JSON, NoContent, Redirect. После — [привязка и валидация](/courses/echo/lessons/05-bind-validaciya/) со struct-тегами и кастомным validator, где ручные проверки вроде Atoi переезжают в декларативные правила. Для статус-кодов и пагинации как практик загляните в [урок о RESTful-практиках](/courses/echo/lessons/17-rest-praktiki/): половина материала уже знакома вам по этому уроку. А префиксы /api/v1, общие middleware на поддеревьях и версионирование — в [уроке про группы](/courses/echo/lessons/11-gruppy/).

## Коротко: что запомнить

- Path-параметры объявляются двоеточием (/users/:id) и достаются c.Param("id"): значение всегда строка.
- Числовой параметр — strconv.Atoi плюс обработка ошибки: мусор в URL — это 400, а не 500.
- Query-параметры — c.QueryParam (один) и c.QueryParams (url.Values для повторяющихся ключей).
- Дефолтов для query в Echo нет: fallback после Atoi — рабочий шаблон (page = 1, limit = 20).
- Wildcard /files/* матчит хвост пути, значение достаётся c.Param("*").
- Приоритет: статика > параметр > wildcard; порядок объявления маршрутов не важен.
- /users и /users/ — разные маршруты; склейка — middleware.RemoveTrailingSlash через e.Pre.

## Домашнее задание

Добавьте в проект из прошлого урока два маршрута: GET /books/:id (с Atoi и 400 на мусор) и GET /books/year/:year (тоже число). Убедитесь, что запрос /books/year/2001 попадает в year-маршрут, а не в :id, и письменно объясните себе почему. Затем допишите верхнюю границу 50 для limit в обработчике /search и проверьте curl-ом: запрос ?limit=1000 должен вернуть limit = 50.

## Вопросы и ответы по уроку

### Как достать path-параметр в Echo?

c.Param("id"): имя в скобках — то, что вы объявили в маршруте после двоеточия. Значение всегда строка; для числа — strconv.Atoi с обработкой ошибки и ответом 400.

### Что вернёт c.QueryParam, если параметра нет?

Пустую строку, без ошибки. Дефолтного метода вроде gin-овского DefaultQuery в Echo нет — подставляйте значение сами после неудачной конвертации.

### Что важнее: статический маршрут или параметр?

Статика. Если под запрос подходят /users/new и /users/:id, Echo выберет /users/new. Это правило radix-дерева, а не порядок регистрации.
