---
title: "URLParam: параметры пути и query-строка"
description: "chi urlparam: достаём id из маршрута /notes/{id}, строки и strconv.Atoi, query через r.URL.Query. Разбор ошибки с опечаткой в имени параметра и 500 в проде."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/04-urlparam/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

# URLParam: параметры пути и query-строка

*Курс «Chi: роутер для Go HTTP-сервисов», урок 4 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/04-urlparam/*

В хендлер пришёл адрес /notes/42, а работать нужно с самой заметкой — значит, из пути достаётся 42. chi.URLParam читает значения из фигурных скобок маршрута: параметры пути в go всегда строки, и на их границах живут битые числа и молчаливые опечатки. Разберём URLParam, query-строку и разницу между /notes/42 и /notes?page=2.

REST-каркас из [прошлого урока](/courses/chi/lessons/03-marshruty-metody/) обещал карточку заметки по GET /notes/{id} — теперь нужна механика: как хендлер узнаёт, какая именно заметка запрошена. Адрес приходит строкой /notes/42, и где-то в нём спрятан id. Роутер chi разбирает его ещё до вашего хендлера: сегмент {id} в шаблоне маршрута говорит «здесь будет произвольное значение», роутер сопоставляет фактический путь с шаблоном и складывает совпадения в контекст запроса. Достать значение оттуда — одна функция, chi.URLParam. Звучит просто, и это правда просто; сложности начинаются на границах. Параметр — всегда строка, значит рядом нужен strconv.Atoi и обработка ошибки. Имя в URLParam должно совпасть с именем в шаблоне байт в байт — иначе вернётся пустая строка: не exception, не ошибка компиляции, а молчаливая пустота, которая взорвётся дальше по цепочке. А ещё есть query-строка, которая выглядит похожей, но живёт по другим правилам и достаётся другими средствами. Этим границам и посвящён урок.

### Параметры пути в chi: /notes/{id}

Шаблон маршрута пишется фигурными скобками: r.Get("/notes/{id}", getNote). Имя внутри скобок — произвольный идентификатор: {id}, {noteID}, {slug} — это метка сегмента, а не переменная Go. Когда приходит запрос GET /notes/42, chi сопоставляет путь с шаблоном сегмент за сегментом, находит несовпадающую позицию и запоминает: id равно 42. Хендлер достаёт значение функцией chi.URLParam(r, "id") из контекста запроса, куда роутер положил разбор. Два жёстких правила. Первое: {id} ловит ровно один сегмент — до следующего слэша; /notes/42/comments в этот маршрут не влезет, для хвостов произвольной длины есть маршруты со звёздочкой, о них дальше по курсу. Второе: имя в chi.URLParam обязано совпасть с именем в шаблоне — не по смыслу, а по буквам: "ID" и "id" — разные метки. Chi не валидирует имена заранее и не ругается на незнакомое: вернёт пустую строку, о последствиях — отдельным блоком ниже. В одном маршруте параметров может быть несколько: /users/{userID}/notes/{noteID} — легальный шаблон с двумя метками.

### Достаём id из пути

```go
r.Get("/notes/{id}", getNote)

func getNote(w http.ResponseWriter, r *http.Request) {
        id := chi.URLParam(r, "id")

        w.Write([]byte("запрошена заметка " + id))
}
```

Запрос GET /notes/42 напечатает «запрошена заметка 42», GET /notes/7 — про семёрку. Хендлер один на все заметки, различие лежит в параметре — в этом сила шаблонов: пятьдесят заметок не превращаются в пятьдесят регистраций. Значение достаётся ровно по имени метки из шаблона: URLParam(r, "id") соответствует {id}. Пока id используется как строка, код безупречен; проблемы начинаются, когда из строки делают число, — об этом следующий блок.

### Параметры — всегда строки: strconv.Atoi

URLParam возвращает string, всегда, без исключений. В пути нет типов: 42 — это символы «4» и «2», и пока вы только вставляете их в ответ или лог, строка устраивает. Но база ждёт int64, арифметика сравнения версий ждёт int — значит, нужен разбор: strconv.Atoi переводит строку в int и честно возвращает ошибку. Ошибка здесь не украшение: пользователь вольен вписать в адрес что угодно — /notes/hello, /notes/99999999999999999999, /notes/-5 — и все три случая Atoi отвергнет: первый не число, второй не влезает в int, третий синтаксически валиден, но пусть бизнес-логика решает сама. Правило каркаса: разбор параметра — первая строка хендлера, и на ошибке хендлер отвечает 400 Bad Request с внятным текстом, а не падает и не лезет в базу с мусором. Код ответа важен: 400 говорит «ошибка на стороне клиента», и пейджер мониторинга не будет дёргать дежурных из-за опечатки пользователя в адресе.

### Число с проверкой

```go
func getNote(w http.ResponseWriter, r *http.Request) {
        id := chi.URLParam(r, "id")

        n, err := strconv.Atoi(id)
        if err != nil {
                http.Error(w, "id должен быть числом", http.StatusBadRequest)
                return
        }

        // n — int, дальше работаем с числом
        fmt.Fprintf(w, "заметка %d", n)
}
```

Atoi возвращает пару: число и ошибку. Ошибка проверяется до любого использования n — мусор не проходит дальше в сервисный слой. http.Error ставит статус и печатает текст одной строкой: для прототипа достаточно; когда появится единый JSON-формат ошибок, это место заменит его, а проверка останется на том же месте. strconv и fmt — импорты стандартной библиотеки, chi тут уже ни при чём.

> **URLParam молчит на опечатке.** Маршрут объявлен как /notes/{id}, а в хендлере написано chi.URLParam(r, "ID") — с большой буквы. Chi не вернёт ошибку, не запаникует и не упадёт: он вернёт пустую строку, потому что «метки с таким именем нет» и «метка есть, но пуста» для него неразличимы. Дальше по цепочке strconv.Atoi("") вернёт ошибку, а если её проверку забыли, — в базу уходит id 0, и прод отвечает 500. Тесты при этом зелёные: расхождение двух имён живёт в коде и не проявляется, пока путь не встретится с настоящим запросом. Лекарство дешёвое: имя параметра выносится в общую константу для шаблона и для URLParam — тогда расхождение станет невозможным.

### Путь или query: /notes/42 против /notes?page=2

Параметры пути и query-строка выглядят похоже — и то, и другое «данные в адресе», — но назначения у них разные, и путаница между ними портит API быстрее, чем любые опечатки. Параметр пути определяет, какой ресурс запрошен: /notes/42 — конкретная заметка, её идентичность, и другой заметки по этому адресу быть не может. Query-строка фильтрует или оформляет представление коллекции: /notes?page=2&tag=go — всё та же страница «списка заметок», просто в другом разрезе. Практический тест для дизайна API: если убрать параметр и адрес перестаёт указывать на конкретный ресурс — это путь; если адрес всё равно ведёт на тот же список — это query. Закладки, кэши и журналы читаются легче, когда ресурсы живут в пути, а настройки вида — после знака вопроса. Разборы в chi тоже разные: URLParam достаёт метки шаблона, а query читается средствами net/http из r.URL — роутер тут не нужен, об этом ниже.

### Параметр пути или query-строка

| Критерий | Параметр пути: /notes/42 | Query: /notes?page=2 |
| --- | --- | --- |
| Роль | идентификатор ресурса | фильтр или оформление представления |
| Обязательность | без него адрес не указывает на ресурс | можно убрать — смысл адреса сохранится |
| Достаётся в chi | chi.URLParam(r, "id") | r.URL.Query().Get("page") |
| Типичные примеры | {id}, {slug}, {userID} | page, limit, sort, q |
| Кэши и закладки | адрес ресурса стабилен | варианты одного и того же ресурса |

### Проверка: где место параметра

**Вопрос:** Нужно отсортировать список заметок по дате: какой дизайн адреса корректен?

- GET /notes/sort/date — сортировка в пути
- ✅ GET /notes?sort=date — сортировка в query-строке
- GET /notes/{sort} — параметр пути
- GET /date/notes — отдельный маршрут

Сортировка не меняет, какой ресурс запрошен, — это всё тот же список заметок в другом представлении, и место ей в query-строке. Параметр пути резервируют для идентичности: какой именно объект читается. /notes/sort/date ещё и конфликтует с /notes/{id}: chi не отличит заметку с id «sort» от запроса сортировки.

### Query-строка: r.URL.Query()

Для query-строки в chi нет собственной функции — и правильно: query разбирает стандартная библиотека, адрес запроса и так принадлежит net/http. r.URL.Query() возвращает map[string][]string — ключи и значения, разложенные по знакам & и =; значения — срез, потому что один ключ может повторяться. На практике почти всегда берут метод Get: r.URL.Query().Get("page") возвращает первое значение или пустую строку, если ключа нет, — то же молчаливое поведение, что у URLParam, и те же проверки нужны следом. Разбор query нужен ради дефолтов: пустая page — значит 1, пустой limit — значит 20. Из строки снова делают число той же strconv.Atoi с той же обработкой ошибки. Отдельно запомните: URLParam на query-ключи не работает никогда — метки живут только в шаблоне пути, и chi.URLParam(r, "page") вернёт пустоту, даже если адрес содержит ?page=2. Промах не репортится — это самая коварная разновидность опечатки имени.

### Пагинация через query

```go
r.Get("/notes", listNotes)

func listNotes(w http.ResponseWriter, r *http.Request) {
        page, err := strconv.Atoi(r.URL.Query().Get("page"))
        if err != nil || page < 1 {
                page = 1
        }

        fmt.Fprintf(w, "список заметок, страница %d", page)
}
```

Отсутствующий или битый page здесь не ошибка, а повод для дефолта: список без параметров показывает первую страницу, и 400 здесь только мешал бы. Поэтому err не отдаётся наружу, а сворачивается в page 1. Отрицательные значения ловит вторая половина условия — query-параметры заслуживают проверки так же, как параметры пути.

### Проверка: вложенные параметры

**Вопрос:** Зарегистрирован r.Get("/users/{userID}/notes/{noteID}", h); пришёл запрос GET /users/7/notes/3. Что вернёт chi.URLParam(r, "userID")?

- ✅ "7": значение первого сегмента-метки
- "3": последнее значение параметра
- Пустую строку: вложенность не разбирается
- "7" и "3" через запятую

Каждая метка шаблона независима и достаётся по своему имени: userID — "7", noteID — "3". Вложенность на разбор не влияет: chi сопоставил сегменты по позициям и сложил обе пары в контекст запроса, откуда URLParam читает по строковому имени.

### Вложенные параметры и вложенные ресурсы

Шаблон /users/{userID}/notes/{noteID} описывает классическую вложенность: заметки конкретного пользователя. Имена обязаны различаться — два {id} в одном маршруте превратили бы разбор в лотерею, — поэтому метки зовут по смыслу: userID и noteID. В хендлере обе метки достаются парой вызовов chi.URLParam, а логика «заметка 3 принадлежит пользователю 7» проверяется в сервисном слое: если заметка у другого владельца, это 404, а не 200 с чужими данными. Вложенность — удобный приём, но не бесплатный: цепочка /users/7/notes/3/comments/9 растёт в глубину быстрее, чем в полезность, и на четвёртом уровне шаблон проще развернуть в отдельный ресурс. Для сравнения: в Gin те же метки пишутся двоеточием — /users/:userID — и достаются функцией c.Param; механика совпадает вплоть до опечаток, различается синтаксис. Тот же разбор на другом фреймворке — в [уроке Gin про параметры](/courses/gin/lessons/03-parametry/).

### Что напечатает хендлер?

```go
r.Get("/users/{userID}/notes/{noteID}", func(w http.ResponseWriter, r *http.Request) {
        u := chi.URLParam(r, "userID")
        n := chi.URLParam(r, "noteID")

        fmt.Fprintf(w, "user=%s note=%s", u, n)
})

// запрос: GET /users/7/notes/3
```

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

- ✅ user=7 note=3
- user=3 note=7
- user= note=3: первый параметр потерялся
- user=7 note=3 note=3: значения задублировались

Chi сопоставил сегменты по позициям: {userID} поймал 7, {noteID} поймал 3, и обе метки достаются независимо по именам. Порядок в печати — вопрос только порядка аргументов fmt.Fprintf; порядок сегментов пути на него не влияет.

### Соберите разбор id

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

```go
r.Get("/notes/___", getNote)

func getNote(w http.ResponseWriter, r *http.Request) {
        id := chi.___(r, "id")

        n, err := strconv.___(id)
        if err != nil {
                http.Error(w, "битый id", http.___)
                return
        }

        fmt.Fprintf(w, "заметка %d", n)
}
```

Подсказка: Метка в шаблоне — {id}; значение достаёт chi.URLParam; число — strconv.Atoi; ошибка клиента — 400 StatusBadRequest.

Правильные ответы в пропусках: `{id}`, `URLParam`, `Atoi`, `StatusBadRequest`

### Правила работы с параметрами

- Имя в шаблоне и в chi.URLParam совпадает байт в байт: "id" и "ID" — разные метки; имя выносится в константу.
- Параметр — всегда string: перед арифметикой нужен strconv.Atoi с проверкой ошибки.
- Ошибка разбора id — 400 Bad Request, а не 500: в кривом адресе виноват клиент.
- Query-параметры достаются r.URL.Query().Get и через URLParam не видны никогда.
- В одном маршруте — несколько меток с уникальными именами: /users/{userID}/notes/{noteID}.

### Соберите хендлер по строкам

Расставьте строки хендлера по порядку: от получения параметра до ответа.

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

1. `func getNote(w http.ResponseWriter, r *http.Request) {`
2. `id := chi.URLParam(r, "id")`
3. `n, err := strconv.Atoi(id)`
4. `if err != nil { http.Error(w, "битый id", http.StatusBadRequest); return }`
5. `fmt.Fprintf(w, "заметка %d", n)`
6. `}`

> **Сверьте имена grep-ом до деплоя.** Быстрая самопроверка: пройдитесь по файлу маршрутов и по хендлерам — каждая метка из фигурных скобок должна встретиться в chi.URLParam с тем же именем. Запуск и ручные клики этого не показывают: URLParam молчит и на правильном коде. Сверка занимает минуту и убирает целый класс 500, которых нет в тестах. Прогонку вручную делайте локальным Go: песочница сайта сеть не видит.

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

- **chi.URLParam(r, "id")** — Достаёт значение сегмента {id} из шаблона маршрута по имени метки. Возвращает string; при несовпадении имени — пустую строку молча.
- **{id} в шаблоне** — Метка одного сегмента пути, до следующего слэша. Имя — произвольный идентификатор; в маршруте может быть несколько меток с разными именами.
- **strconv.Atoi** — Переводит строку параметра в int и возвращает ошибку на нечисловом входе. Разбор — первая строка хендлера, ошибка — ответ 400.
- **r.URL.Query().Get("page")** — Читает query-строку средствами net/http: первое значение ключа или пустую строку. URLParam query-ключи не видит никогда.
- **Путь против query** — Путь идентифицирует ресурс: /notes/42. Query оформляет представление: /notes?page=2&sort=date. Отсюда разные функции разбора и разная семантика кэшей и закладок.
- **Вложенные параметры** — /users/{userID}/notes/{noteID}: каждая метка достаётся по имени, имена в маршруте уникальны. Владение проверяется в сервисном слое — чужая заметка это 404.

Параметры пути разобраны, query читается с дефолтами — каркас /notes научился работать с конкретной заметкой, а не только с коллекцией. Следующая ступень — [middleware](/courses/chi/lessons/05-middleware/): лог, восстановление после паники и счётчики навешиваются на цепочку одним r.Use, и хендлеры перестают заниматься служебной работой. А если однажды {id} станет тесным — понадобится забрать весь хвост пути, например имя файла с вложенными папками, — это тема урока про [wildcards](/courses/chi/lessons/09-wildcards/): звёздочка ловит остаток пути целиком. Для практики соберите каркас из прошлого урока и хендлеры из этого в один файл: локальный Go запустит его как есть, и curl-ом видно всё — и карточку по /notes/42, и 400 на /notes/hello.

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

- Шаблон {id} ловит один сегмент пути; значение достаётся chi.URLParam(r, "id") ровно по имени метки.
- Параметры — всегда строки: числа разбираются strconv.Atoi с обработкой ошибки и ответом 400 на мусор.
- Несовпадение имени в шаблоне и в URLParam возвращает пустую строку молча — имя параметра выносится в общую константу.
- Query-строка не видна через URLParam: r.URL.Query().Get("page") из net/http, с дефолтами и проверкой.
- Путь идентифицирует ресурс (/notes/42), query оформляет представление (/notes?page=2) — от этого зависят кэши, закладки и логи.
- Вложенный маршрут /users/{userID}/notes/{noteID} разбирается по уникальным именам меток.
- Владение вложенным ресурсом проверяется в сервисном слое: чужая заметка — 404, а не утечка данных.

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

Доработайте каркас заметок: в getNote разберите id через strconv.Atoi и отвечайте 400 на нечисловой путь, а в listNotes добавьте query-параметры page и limit с дефолтами 1 и 20. Проверьте curl-ом адреса /notes/abc, /notes/-3 и /notes?page=2&limit=5 и убедитесь, что ответы совпадают с планом.

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

### Что вернёт chi.URLParam, если имени нет в маршруте?

Пустую строку — без ошибки и без паники: «метки нет» и «метка есть, но пуста» для chi неразличимы. Поэтому имя берут из общей константы для шаблона и вызова, а пустоту после URLParam проверяют так же серьёзно, как ошибку strconv.Atoi.

### Почему URLParam не читает ?page=2?

Метки шаблона живут только в пути: chi сопоставляет сегменты до слэшей, и query-строка в маршрутизацию не входит. Query разбирает стандартная библиотека — r.URL.Query().Get("page"). Перепутать их проще всего в спешке: промах выглядит как пустая строка, а не как ошибка.

### Как достать несколько параметров из одного маршрута?

Дать меткам уникальные имена: /users/{userID}/notes/{noteID}, затем два вызова chi.URLParam — по одному на метку. Имена должны различаться, потому что разбор идёт по позициям сегментов, а достаётся каждая пара по строковому имени из контекста запроса.
