В Go ошибки — это значения. Не исключения, не коды возврата в стиле C, не паника. Обычные значения, которые передаются, проверяются и трансформируются по тем же правилам, что и любые другие данные. Это сознательное решение авторов языка, которое меняет подход к написанию кода: вместо try-catch блоков, где обработка ошибок живёт отдельно от основного потока, в Go ошибка обрабатывается в том же месте, где возникает.
На первый взгляд это выглядит многословно. Причём настолько что if err != nil стало мемом не только в Go сообществе, но и за его пределами. Однако за всем стоит система, которая при правильном использовании даёт понятные сообщения об ошибках, удобную диагностику и предсказуемое поведение. В этой статье разберём, как устроена обработка ошибок в Go и какие паттерны помогают писать надёжный код.
Интерфейс error
Всё начинается с интерфейса:
type error interface {
Error() string
}
Любой тип, у которого есть метод Error() string, является ошибкой. Стандартная библиотека предоставляет два основных способа создать ошибку:
// Через errors.New для статических сообщений
err := errors.New("файл не найден")
// Через fmt.Errorf для сообщений с параметрами
err := fmt.Errorf("файл %s не найден", filename)
errors.New возвращает указатель на простую структуру с единственным строковым полем . fmt.Errorf делает то же самое, но с форматированием. Эти два способа покрывают большинство случаев.
Возврат и проверка ошибок
В Go принято возвращать ошибку как последнее значение из функции:
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("чтение конфига: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("парсинг конфига: %w", err)
}
return &cfg, nil
}
Вызывающий код проверяет ошибку:
cfg, err := ReadConfig("/etc/app/config.json")
if err != nil {
log.Fatalf("не удалось загрузить конфигурацию: %v", err)
}
Частые if err != nil многословны. Зато каждая ошибка обрабатывается явно, и при чтении кода сразу видно, что происходит при сбое. В языках с исключениями при чтении функции из 20 строк непонятно, какая из них может бросить исключение и куда управление уйдёт. В Go это всегда явно.
Оборачивание ошибок: fmt.Errorf и %w
Начиная с Go 1.13, fmt.Errorf поддерживает глагол %w, который оборачивает ошибку. Оборачивание сохраняет оригинальную ошибку внутри новой, добавляя больше описания ошибке:
func (s *Storage) GetUser(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx, "SELECT name, email FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.Name, &u.Email); err != nil {
return nil, fmt.Errorf("получение пользователя id=%d: %w", id, err)
}
return &u, nil
}
Если row.Scan вернёт sql.ErrNoRows, то GetUser вернёт ошибку вида:
получение пользователя id=42: sql: no rows in result set
При этом оригинальная sql.ErrNoRows сохранена внутри и доступна через errors.Is. Без %w (если использовать %v) контекст добавится, но связь с оригинальной ошибкой потеряется.
Т.к. произойдет форматирование значения (глагол “%v” значит value, т.е. значение), а не оборачивание (глагол “%w” значит warp, т.е. оборачивать).
Каждый уровень оборачивания ошибки добавляет свой контекст. В итоге ошибка, дошедшая до верхнего уровня, содержит полный путь:
обработка заказа: получение пользователя id=42: sql: no rows in result set
По этому сообщению сразу понятно, что произошло, где и почему.
А так же мы не теряем изначальную ошибку и сохраняем возможность проверки на неё через errors.Is.
Sentinel-ошибки и errors.Is
Sentinel-ошибки это заранее определённые значения ошибок на уровне пакета. В стандартной библиотеке таких примеров много:
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("conflict")
)
Для проверки sentinel-ошибок используется errors.Is. Эта функция проходит по всей цепочке оборачиваний и проверяет совпадение:
user, err := storage.GetUser(ctx, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("получение пользователя: %w", err)
}
errors.Is работает рекурсивно, если ошибка обёрнута через %w в несколько слоёв, она всё равно найдёт совпадение. Поэтому прямое сравнение err == sql.ErrNoRows является ошибкой. Сравнение сработает только если ошибка не обёрнута, а это хрупкое допущение: любой промежуточный слой может добавить обёртку, и сравнение сломается.
// Плохо: не найдёт ошибку, если она обёрнута
if err == sql.ErrNoRows {
// ...
}
// Хорошо: проходит по всей цепочке
if errors.Is(err, sql.ErrNoRows) {
// ...
}
Типизированные ошибки и errors.As
Когда недостаточно просто знать, что ошибка произошла и нужны детали. этого создают типизированные ошибки Иногда кроме факта ошибки нужно больше подробностей, и тогда можно воспользоваться типизированными ошибками.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("валидация поля %s: %s", e.Field, e.Message)
}
errors.As извлекает типизированную ошибку из цепочки:
var ve *ValidationError
if errors.As(err, &ve) {
// Доступны поля ve.Field и ve.Message
log.Printf("ошибка валидации: поле %s — %s", ve.Field, ve.Message)
}
Как и errors.Is, функция errors.As рекурсивно проходит по всей цепочке обёрток. Типичное встречается в HTTP обработчиках, где тип ошибки определяет код ответа:
func handleError(w http.ResponseWriter, err error) {
var ve *ValidationError
if errors.As(err, &ve) {
http.Error(w, ve.Error(), http.StatusBadRequest)
return
}
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if errors.Is(err, ErrUnauthorized) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Неизвестная ошибка, код 500
log.Printf("внутренняя ошибка: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
Когда использовать sentinel, а когда типы
Sentinel-ошибки подходят, когда важен сам факт ошибки: «не найдено», «нет доступа», «конфликт версий». Типизированные ошибки важны, когда вместе с фактом ошибки нужны данные: какое поле не прошло валидацию, какой лимит превышен, какой ресурс заблокирован.
errors.Join: несколько ошибок сразу
Начиная с Go 1.20 появилась функция errors.Join, которая объединяет несколько ошибок в одну:
func validateOrder(o Order) error {
var errs []error
if o.CustomerID == 0 {
errs = append(errs, fmt.Errorf("не указан customer_id"))
}
if len(o.Items) == 0 {
errs = append(errs, fmt.Errorf("заказ без товаров"))
}
if o.Total <= 0 {
errs = append(errs, fmt.Errorf("некорректная сумма: %d", o.Total))
}
return errors.Join(errs...)
}
errors.Join вернёт nil, если все ошибки в слайсе nil. Это удобно: не нужно отдельно проверять, были ли ошибки.
errors.Is и errors.As так же работают и с объединёнными ошибками через проверку каждой ошибки в группе:
err := errors.Join(ErrNotFound, ErrConflict)
errors.Is(err, ErrNotFound) // true
errors.Is(err, ErrConflict) // true
Типично используется при валидации, где нужно собрать все ошибки, а не останавливаться на первой. Или закрытие нескольких ресурсов, где каждый может вернуть ошибку:
func (app *App) Close() error {
return errors.Join(
app.db.Close(),
app.cache.Close(),
app.queue.Close(),
)
}
Практические паттерны
Добавляйте контекст при оборачивании
Голая ошибка без контекста бесполезна в логах:
// Очень Плохо: потеряна ошибка, контекст не добавляет понимания
return errors.New("ошибка")
// Плохо: непонятно, откуда ошибка
return err
// Плохо: контекст не добавляет информации
return fmt.Errorf("ошибка: %w", err)
// Хорошо: понятно, что делали и с какими параметрами
return fmt.Errorf("отправка уведомления пользователю %d: %w", userID, err)
Контекст должен отвечать на вопросы «что делали» и «с какими данными». Не дублируйте информацию, которая уже есть в оригинальной ошибке.
Обрабатывайте ошибку один раз
Ошибку нужно либо обработать (залогировать, вернуть пользователю, повторить операцию), либо передать выше. Не делайте и то и другое:
// Плохо: ошибка логируется здесь и ещё раз выше
if err != nil {
log.Printf("ошибка: %v", err)
return fmt.Errorf("операция не удалась: %w", err)
}
// Хорошо: просто передаём выше с контекстом
if err != nil {
return fmt.Errorf("операция не удалась: %w", err)
}
Если залогировали и вернули, то одна и та же ошибка появится в логах дважды (или ещё больше, если вызывающие тоже логируют). На проде с высоким RPS это превращает логи в поток лишней информации.
Не оборачивайте, если контекст не нужен
Если функция делает одно действие и из контекста и так понятно, что произошло, дополнительная обёртка добавляет шум:
// Лишняя обёртка: и так понятно, что ping не прошёл
func (s *Storage) Ping(ctx context.Context) error {
if err := s.db.PingContext(ctx); err != nil {
return fmt.Errorf("ping базы данных: %w", err) // избыточно
}
return nil
}
// Проще:
func (s *Storage) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
}
Не используйте panic для обработки ошибок
panic используется только для аварийного завершения, а не механизм обработки ошибок. Он предназначен для ситуаций, когда программа не может продолжать работу: нарушение инварианта, баг в логике, невозможность инициализации критического компонента.
// Допустимо: если конфиг не загрузился, приложение не может работать
func mustLoadConfig(path string) *Config {
cfg, err := LoadConfig(path)
if err != nil {
panic(fmt.Sprintf("загрузка конфигурации: %v", err))
}
return cfg
}
// Недопустимо: паника в бизнес-логике
func GetUser(id int64) *User {
user, err := db.FindUser(id)
if err != nil {
panic(err) // Убьёт весь сервер из-за одного запроса
}
return user
}
Функции с префиксом Must (такие как regexp.MustCompile или template.Must в стандартной библиотеке) конвенция Go для обозначения функций, которые паникуют при ошибке. Используйте их при инициализации, не в рантайме.
Типичные ошибки
Проверка через == вместо errors.Is. Прямое сравнение ломается, как только кто-то добавляет обёртку через %w. Это может быть вы сами через полгода или коллега, который не знает, что где-то выше по стеку стоит ==.
Игнорирование ошибок. Компилятор Go не заставляет проверять возвращённые ошибки. Линтер errcheck (входит в golangci-lint) ловит непроверенные ошибки, и его стоит включить в CI.
// Тихое игнорирование. Баг, который сложно найти
json.Unmarshal(data, &result)
// Если намеренно игнорируете, то покажите это явно
_ = conn.Close()
Возврат err и ненулевого значения одновременно. Вызывающий код обычно проверяет if err != nil и не использует второе значение. Если вы возвращаете частично заполненную структуру с ошибкой, это приводит к непредсказуемому поведению:
// Плохо: вызывающий может использовать частичный результат
func Parse(data []byte) (*Result, error) {
r := &Result{Partial: true}
if err := json.Unmarshal(data, r); err != nil {
return r, err // Частичный Result с ошибкой
}
return r, nil
}
// Хорошо: при ошибке в результате явный nil
func Parse(data []byte) (*Result, error) {
var r Result
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("парсинг результата: %w", err)
}
return &r, nil
}
Строковые проверки ошибок. Подход вида strings.Contains(err.Error(), "not found") ненадёжен. Текст ошибки может измениться в любой момент. Используйте errors.Is и errors.As.
Заключение
Обработка ошибок в Go построена на трёх принципах: ошибки — это значения, каждая ошибка обрабатывается явно, контекст добавляется при передаче вверх по стеку.
На практике основные инструменты — это fmt.Errorf с %w для оборачивания, errors.Is и errors.As для проверки, и errors.Join для объединения нескольких ошибок. Sentinel-ошибки определяют «что случилось», типизированные ошибки несут дополнительные данные.
Частые проблемы, которые я встречаю на код-ревью: ошибки без контекста (голый return err), двойное логирование (залогировали и вернули), и прямое сравнение вместо errors.Is. Все три решаются простыми правилами, которые быстро входят в привычку.
FAQ
Чем %w отличается от %v в fmt.Errorf?
%w оборачивает ошибку, сохраняя связь с оригиналом. errors.Is и errors.As смогут найти оригинальную ошибку в цепочке. %v просто вставляет текст ошибки в строку, чем теряет связь с ошибкой.
original := sql.ErrNoRows
wrapped := fmt.Errorf("запрос: %w", original)
errors.Is(wrapped, sql.ErrNoRows) // true
formatted := fmt.Errorf("запрос: %v", original)
errors.Is(formatted, sql.ErrNoRows) // false
Используйте %w, когда вызывающему коду может понадобиться проверить тип ошибки. Используйте %v, когда хотите скрыть детали реализации (например, чтобы не привязывать пользователей пакета к конкретной ошибке зависимости).
Когда использовать panic, а когда возвращать error?
Возвращайте error практически всегда. panic для исключительных ситуаций, когда продолжение работы невозможно или бессмысленно:
- Инициализация (если без компонента приложение не может стартовать)
- Нарушение инварианта, которое указывает на баг (например,
defaultв switch, который «не должен» сработать) - Функции вида
Must*, вызываемые при старте
В обработчиках HTTP-запросов, в бизнес-логике, в работе с БД используйте только error. Паника в горутине без recover убьёт всю программу.
Стоит ли использовать пакет pkg/errors?
Пакет github.com/pkg/errors был создан до Go 1.13, когда в стандартной библиотеке не было %w, errors.Is и errors.As. Он добавлял оборачивание и stack traces.
С Go 1.13+ стандартная библиотека покрывает основные сценарии. pkg/errors переведён в режим поддержки (maintenance mode), а сам автор рекомендует переходить на стандартные средства. Для новых проектов используйте fmt.Errorf с %w и пакет errors.
Если нужны stack traces, то посмотрите на сторонние решения, но в большинстве случаев контекст, добавленный через %w на каждом уровне, даёт достаточно информации для диагностики.
Как тестировать ошибки?
Через errors.Is проверяем sentinel-ошибки:
func TestGetUser_NotFound(t *testing.T) {
_, err := storage.GetUser(ctx, 999)
if !errors.Is(err, ErrNotFound) {
t.Errorf("ожидали ErrNotFound, получили: %v", err)
}
}
А через errors.As типизированные:
func TestValidate_InvalidEmail(t *testing.T) {
err := Validate(User{Email: "invalid"})
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("ожидали ValidationError, получили: %v", err)
}
if ve.Field != "email" {
t.Errorf("ожидали поле email, получили: %s", ve.Field)
}
}
Не проверяйте текст ошибки через err.Error(), т.к. он подвержен изменениям. Проверяйте тип и семантику.
Теги: