Самое интересное в новой версии Go — 1.27
- пятница, 21 августа 2026 г. в 00:00:23
Всем привет! Меня зовут Паша Агалецкий, я техлид команды платформы разработки Авито. В этой статье я расскажу о новой версии Go — 1.27. Я выбрал для вас самые интересные фичи — те, что влияют на код или дают новые по-настоящему полезные инструменты.
Кстати, у этого материала есть видео-версия. Вы можете ознакомиться с обновлением Go, посмотрев мой обзор на YouTube, VK Видео или RuTube

Дженерики появились в Go 1.18, но с асимметрией: функция могла объявлять собственные параметры типов, а метод — нет. Из-за этого часть API приходилось оформлять отдельными функциями уровня пакета, даже когда по смыслу операция относилась к конкретному типу.
Разберём на обобщённом типе Result, который хранит значение или ошибку:
type Result[T any] struct { Value T Err error }
Допустим, нам нужна операция Map: она должна взять значение из Result[T] и преобразовать его в другой тип. До Go 1.27 такую операцию приходилось объявлять обычной функцией уровня пакета:
func MapResult[T, U any](r Result[T], f func(T) U) Result[U] { if r.Err != nil { return Result[U]{Err: r.Err} } return Result[U]{Value: f(r.Value)} } func main() { r := Result[int]{Value: 42} formatted := MapResult(r, strconv.Itoa) fmt.Println(formatted.Value) }
MapResult занимает имя в пространстве имён всего пакета. Если подобных типов и операций много, API быстро превращается в набор функций MapResult, MapSlice, MapOptional и так далее.
В Go 1.27 это ограничение сняли: теперь метод может объявлять собственные параметры типов.
func (r Result[T]) Map[U any](f func(T) U) Result[U] { if r.Err != nil { return Result[U]{Err: r.Err} } return Result[U]{Value: f(r.Value)} } func main() { r := Result[int]{Value: 42} formatted := r.Map(strconv.Itoa) fmt.Println(formatted.Value) }
Здесь T задан на типе Result, а U объявляет уже сам метод Map. При вызове r.Map(strconv.Itoa) компилятор сам понимает, что U — это string.
Польза не только в более аккуратных именах. Методы читаются слева направо и позволяют собирать цепочки операций вокруг одного типа. Это пригодится авторам библиотек, обобщённых коллекций, контейнеров и fluent API.
Но граница у новой возможности есть: методы интерфейсов по-прежнему не могут объявлять параметры типов.
Так написать нельзя:
type Mapper interface { Map[T any](func(string) T) T }
Generic-метод конкретного типа также не реализует обычный метод интерфейса. Поэтому новая возможность помогает организовывать API конкретных типов, но не добавляет новый механизм динамического полиморфизма.
Причина — в неявной реализации интерфейсов в Go. Тип не объявляет заранее, какие интерфейсы он реализует. Поэтому компилятор не может заранее знать, какие реализации generic-метода понадобятся при вызовах через интерфейс, а потенциально их бесконечно много. С методами конкретных типов проще: тип receiver известен статически, и вызов разрешается ещё при компиляции.
Это главное изменение языка в Go 1.27 и одно из самых заметных расширений дженериков с момента их появления.

Следующее изменение упрощает работу со встроенными структурами. До Go 1.27 в struct literals можно было напрямую указывать только поля верхнего уровня — даже если к полю встроенной структуры обычно обращались как к обычному полю внешнего типа.
Например, встроим адрес в пользователя:
type Address struct { City string ZipCode string } type User struct { Name string Address }
Поле City здесь продвигается из Address, поэтому после создания пользователя к нему можно обратиться через user.City. До Go 1.27 это не работало при инициализации: приходилось явно указывать Address.
user := User{ Name: "Ivan", Address: Address{ City: "Moscow", }, }
В Go 1.27 ключом может быть любое поле, для которого у структуры есть корректный field selector. Поэтому promoted field можно указать напрямую:
user := User{ Name: "Ivan", City: "Moscow", }
Важно, что речь идёт именно о promoted fields, а не о произвольном пути к вложенному полю. Если объявить именованное поле Address Address, то City не продвигается и сокращённая запись не сработает. Синтаксиса Address.City: "Moscow" в struct literals тоже не появилось.
Есть ещё два ограничения. Нельзя одновременно инициализировать встроенную структуру целиком и одно из её promoted fields, например указать и Address: ..., и City: .... Кроме того, новый синтаксис не работает, если путь к полю проходит через встроенный указатель.
Изменение небольшое, но struct literals с несколькими уровнями embedding станут заметно проще — особенно в конфигурациях и тестовых данных.
Ещё одна небольшая правка делает дженерики последовательнее. Go уже умел выводить параметры типов generic-функции при обычном присваивании. Например, такой код работал и раньше:
type Handler func(string) func Handle[T any](value T) { fmt.Println(value) } var handler Handler handler = Handle
Компилятор видит тип переменной handler и понимает, что здесь нужна версия Handle[string].
Но в почти такой же ситуации внутри composite literal вывод типов не срабатывал. Тип приходилось указывать вручную:
handlers := []Handler{ Handle[string], }
В Go 1.27 вывод типов работает во всех контекстах присваивания, поэтому код упрощается:
handlers := []Handler{ Handle, }
То же правило действует для полей struct literals, элементов литералов массивов, слайсов и map, а также при отправке generic-функции в канал с известным типом элемента.
Нового механизма здесь нет. Просто теперь правило работает предсказуемо: если тип назначения известен и однозначно определяет параметры типов, компилятор выводит их сам.
Самое практичное изменение стандартной библиотеки — новый JSON API. encoding/json/v2 появился раньше, но оставался экспериментальным и требовал флага GOEXPERIMENT=jsonv2.
В Go 1.27 новая реализация стала доступна из коробки. API разделили на два уровня:
encoding/json/v2 преобразует Go-значения в JSON и обратно;
encoding/json/jsontext работает с JSON на более низком уровне — как с последовательностью токенов и значений.
Базовые вызовы при этом выглядят знакомо:
import json "encoding/json/v2" type User struct { Name string `json:"name"` Admin bool `json:"admin"` } func main() { data, err := json.Marshal(User{ Name: "Ivan", Admin: true, }) if err != nil { log.Fatal(err) } fmt.Println(string(data)) }
Главное изменение API — функции принимают набор опций. Поведение можно настраивать для конкретного вызова, не создавая отдельный Encoder или Decoder.
Например, по умолчанию неизвестные поля при декодировании по-прежнему игнорируются:
input := []byte(`{ "name": "Ivan", "admin": true, "unexpected": 42 }`) var user User err := json.Unmarshal(input, &user)
Если API-контракт не должен принимать лишние поля, включаем строгую проверку:
var user User err := json.Unmarshal( input, &user, json.RejectUnknownMembers(true), ) if err != nil { log.Fatal(err) }
Другие опции управляют детерминированной сериализацией, представлением nil-map и nil-slice, сопоставлением имён и пользовательскими marshalers и unmarshalers.
У v2 более строгие настройки по умолчанию. Пакет отклоняет некорректный UTF-8 в строках и повторяющиеся имена внутри JSON-объекта. Такое поведение лучше согласуется с другими реализациями JSON. С дублирующимися именами разные JSON-парсеры могут разобрать один и тот же объект по-разному. На этом строят атаки: проверку проходят на одном сервисе, а второй из того же JSON получает другое значение. Но именно здесь при обновлении могут проявиться данные, которые старый пакет молча принимал, поэтому JSON-сценарии стоит отдельно прогнать на тестах.
Старый пакет encoding/json никуда не исчез. В Go 1.27 он работает поверх новой реализации, но сохраняет прежнюю семантику API. При этом точный текст сообщений об ошибках может отличаться. Поэтому переписывать весь проект на encoding/json/v2 сразу не нужно: миграцию можно делать постепенно там, где нужны новые опции.
По производительности Marshal в среднем остался примерно на прежнем уровне, зато Unmarshal заметно ускорился.
Если после обновления обнаружилась проблема совместимости, новую реализацию временно можно отключить при сборке:
GOEXPERIMENT=nojsonv2 go build ./...
Но это аварийный выход на время диагностики, а не постоянный режим работы.

UUID часто встречаются в Go-проектах, но до Go 1.27 для работы с ними приходилось подключать стороннюю библиотеку.
Теперь базовый API есть в стандартном пакете uuid:
import "uuid" func main() { id := uuid.New() fmt.Println(id) }
Тип UUID — это массив из 16 байт:
type UUID [16]byte
Благодаря этому UUID можно сравнивать через == и использовать как ключ map:
users := map[uuid.UUID]string{} id := uuid.New() users[id] = "Ivan" if id == uuid.Nil() { fmt.Println("empty uuid") }
Для большинства сценариев достаточно uuid.New(). В Go 1.27 он эквивалентен генерации UUID версии 4.
Если нужна конкретная версия, доступны отдельные функции:
randomID := uuid.NewV4() orderedID := uuid.NewV7()
UUID v4 содержит 122 случайных бита. UUID v7 хранит timestamp в старших 48 битах и как минимум 62 случайных бита. Поэтому такие идентификаторы сортируются по времени создания — если системные часы не переводятся назад.
Для разбора строк есть uuid.Parse и uuid.MustParse:
id, err := uuid.Parse("f81d4fae-7dec-11d0-a765-00a0c91e6bf6") if err != nil { log.Fatal(err) } fmt.Println(id.String())
Случайная часть новых UUID создаётся криптографически стойким генератором. Специализированные библиотеки по-прежнему могут предлагать больше возможностей, но базовый сценарий теперь закрывает стандартная библиотека — одной внешней зависимостью меньше.
Следующее изменение особенно полезно для диагностики. В Go легко запустить горутину — и так же легко случайно оставить её заблокированной навсегда.
Возьмём упрощённую параллельную обработку задач:
type result struct { value string err error } func process(items []string) ([]string, error) { ch := make(chan result) for _, item := range items { go func() { value, err := processItem(item) ch <- result{value: value, err: err} }() } var values []string for range len(items) { r := <-ch if r.err != nil { return nil, r.err } values = append(values, r.value) } return values, nil }
Канал ch небуферизованный. Если одна операция вернёт ошибку, process завершится раньше времени. Остальные горутины могут навсегда зависнуть, пытаясь отправить результат в канал, который больше никто не читает.
Обычный профиль горутин покажет, где они заблокированы, но не отделит временное ожидание от настоящей утечки — ситуации, когда продолжить работу уже невозможно.
В Go 1.27 для этого стал общедоступен профиль goroutineleak. Его можно получить программно:
profile := pprof.Lookup("goroutineleak") if profile == nil { log.Fatal("goroutine leak profile is unavailable") } if err := profile.WriteTo(os.Stdout, 1); err != nil { log.Fatal(err) }
Или через стандартный HTTP endpoint:
/debug/pprof/goroutineleak
Runtime опирается на информацию сборщика мусора о достижимости объектов. Если горутина заблокирована на канале, mutex, condition variable или другом примитиве синхронизации, а разблокировать его уже некому, runtime может определить, что горутина больше не проснётся.
Найти все возможные утечки таким способом нельзя. Например, если примитив синхронизации доступен через глобальную переменную или локальную переменную работающей горутины, runtime не всегда может доказать, что разблокировки не будет.
Зато профиль обнаруживает большой класс реальных ошибок, которые раньше приходилось искать вручную по stack dump.
В Go 1.26 эта возможность была экспериментальной и требовала GOEXPERIMENT=goroutineleakprofile. В Go 1.27 флаг больше не нужен, поэтому профиль уже можно включать в обычную диагностику и CI.
Компилятор Go 1.27 генерирует вызовы специализированных функций выделения памяти для объектов определённого размера. В результате некоторые небольшие аллокации — меньше 80 байт — ускоряются вплоть до 30%.
Но 30% здесь относятся к стоимости конкретных аллокаций, а не ко всему приложению. В реальных программах, которые активно выделяют память, команда Go ожидает общий выигрыш примерно в 1%. Конкретный результат зависит от профиля нагрузки.
Компромисс — бинарный файл увеличивается примерно на 60 КБ независимо от размера приложения.
Если оптимизация вызвала регрессию, в Go 1.27 её можно отключить:
GOEXPERIMENT=nosizespecializedmalloc go build ./...
Флаг планируют удалить уже в Go 1.28. Поэтому при обнаружении проблемы стоит не только выключить оптимизацию, но и подготовить воспроизводимый пример для команды Go.
В Go 1.26 появился экспериментальный пакет simd/archsimd, который давал прямой доступ к SIMD-инструкциям на AMD64. Такой API привязан к архитектуре и размеру вектора: код для AVX2 или AVX-512 нельзя просто взять и собрать под ARM64.
Go 1.27 добавляет второй уровень — экспериментальный пакет simd с переносимым API. Он предоставляет типы вроде Int8s и Float32s, размер которых намеренно не зафиксирован в публичном интерфейсе.
Один и тот же код может использовать аппаратные SIMD-инструкции там, где они доступны, и подходящую реализацию на других архитектурах. Такой API полезен для сжатия, криптографии, обработки изображений, численных алгоритмов и машинного обучения.
Низкоуровневый simd/archsimd тоже продолжает развиваться:
обновлён API для AMD64;
добавлены 128-битные Neon-векторы для ARM64;
добавлены 128-битные SIMD-векторы для WebAssembly;
на AMD64 остаются доступны 128-, 256- и 512-битные векторы в зависимости от процессора.
Оба пакета пока экспериментальные и включаются одним флагом:
GOEXPERIMENT=simd go build ./...
Переписывать обычные циклы прямо сейчас не стоит. API ещё может измениться, а выигрыш нужно подтверждать бенчмарками на тех процессорах, где будет работать код.
А вот авторам библиотек, где производительность критична, за этим направлением уже стоит следить. Теперь у них есть и прямой доступ к инструкциям конкретной архитектуры, и переносимый уровень поверх него.

Начнём с проверки, которая может сразу проявиться после обновления. go test теперь по умолчанию запускает vet-анализатор stdversion.
Он проверяет, что код не использует символы стандартной библиотеки, появившиеся позже версии Go из go.mod или build tags файла.
Например, если в go.mod всё ещё указано go 1.26, а код уже использует новый пакет или API Go 1.27, проверка сообщит о несовместимости. Это ловит неочевидную ситуацию: библиотека успешно собирается новым toolchain, хотя версия в go.mod обещает пользователям поддержку старого Go.
Для модулей с директивой go 1.27 команда go mod tidy объединяет дублирующиеся блоки require. Такие блоки часто остаются после ручных правок и разрешения конфликтов.
После выполнения команды в go.mod остаётся не больше двух таких блоков:
прямые зависимости;
косвенные зависимости с // indirect.
Комментарии, которые связаны с зависимостями, при объединении сохраняются. Если комментарий относился сразу к прямым и косвенным зависимостям, он переедет к блоку прямых. Небольшая правка, зато go.mod реже будет накапливать мусор.
Go 1.27 получился большим не из-за количества мелких функций, а из-за нескольких изменений, которые заметны в реальной разработке.
Generic methods закрывают одно из главных ограничений дженериков и позволяют строить API вокруг конкретных типов, а не разносить связанные операции по всему пакету.
encoding/json/v2 даёт настраиваемое поведение, более строгие настройки по умолчанию и заметно более быстрый Unmarshal. При этом старый API продолжает работать, поэтому миграцию можно проводить постепенно.
Пакет uuid убирает частую внешнюю зависимость, а goroutineleak помогает находить ошибки, которые раньше приходилось вычислять вручную по профилям и stack dump.
Остальные изменения хорошо отражают привычный подход Go: после обновления toolchain приложение может стать немного быстрее, инструменты начинают ловить больше ошибок, а стандартная библиотека закрывает ещё несколько распространённых сценариев.
SIMD пока остаётся экспериментом, но появление переносимого пакета показывает направление: на Go хотят упростить разработку высокопроизводительных библиотек без жёсткой привязки всего кода к одной архитектуре.
Перед обновлением production я бы сделал три вещи: прогнал тесты на JSON, сравнил ключевые бенчмарки и проверил, что версия в go.mod соответствует реально используемому API. Если всё зелёное, Go 1.27 можно постепенно нести в production.
А что думаете вы? Делитесь мнениями в комментариях!