Ошибки в Go: error wrapping, sentinel errors и %w

Разбираем обработку ошибок в Go с нуля: интерфейс error, sentinel errors, кастомные типы, fmt.Errorf с %w, errors.Is / errors.As / errors.Unwrap — и что спрашивают на собеседовании.

02 марта 2026 г.20 минLexicon Team

Ошибки в Go — не исключения. Это обычные значения, которые функция возвращает наравне с результатом. Это принципиальный выбор дизайна языка: никаких скрытых путей выполнения, никаких try/catch, которые можно случайно пропустить. Либо ты явно обработал ошибку, либо передал её выше — и это видно прямо в коде.

Такой подход кажется многословным до тех пор, пока не начинаешь работать в production. В Go невозможно случайно «потерять» ошибку в стеке вызовов — компилятор заставит её либо использовать, либо явно проигнорировать через _. Это делает код предсказуемым и читаемым: каждый if err != nil — явная точка принятия решения.

С Go 1.13 добавили механизм оборачивания ошибок: %w в fmt.Errorf, errors.Is, errors.As, errors.Unwrap. Именно эта часть регулярно всплывает на собеседованиях — и именно её нужно понимать глубоко, а не только знать синтаксис. В этой статье разберём всё: от устройства интерфейса error до практических паттернов добавления контекста на каждом уровне стека. Если хочешь сначала освежить общую картину языка — посмотри 50 вопросов junior–middle Go-разработчика, а после этой статьи имеет смысл перейти к context в Go.

Тренируй обработку ошибок с AI-интервьюером

Реальные вопросы по error handling, wrapping и паттернам Go — с разбором ответов в реальном времени.

Начать тренировку

1. Интерфейс error

В Go error — это обычный интерфейс, определённый в стандартной библиотеке:

type error interface {
    Error() string
}

Один метод, одна строка. Любой тип, реализующий Error() string, автоматически удовлетворяет интерфейсу error. Это важно: ошибка — это не специальный объект языка, а просто значение интерфейсного типа. Соответственно, её можно хранить в переменной, передавать как аргумент, возвращать из функции и класть в структуру — ровно как любое другое значение.

Самый простой способ создать ошибку — errors.New:

import "errors"

var err error = errors.New("что-то пошло не так")
fmt.Println(err) // что-то пошло не так

errors.New возвращает указатель на внутреннюю структуру errorString. Это значит, что каждый вызов errors.New создаёт новое значение, отличное от всех остальных — даже если текст совпадает:

err1 := errors.New("not found")
err2 := errors.New("not found")
fmt.Println(err1 == err2) // false — разные указатели

Это поведение критически важно для понимания sentinel errors — к нему мы вернёмся в следующем разделе.

Отсутствие ошибки — nil

Когда ошибки нет, функция возвращает nil. Это идиоматично для Go:

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("деление на ноль")
    }
    return a / b, nil
}

result, err := divide(10, 2)
if err != nil {
    log.Fatal(err)
}
fmt.Println(result) // 5

Проверка if err != nil — самый частый паттерн в Go-коде. Кажется, что это шум, но за этим стоит явность: ты точно знаешь, что функция может вернуть ошибку и что ты её обработал.

Собственный тип, реализующий error

Для передачи структурированного контекста достаточно реализовать один метод:

type NotFoundError struct {
    Resource string
    ID       int
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s с id=%d не найден", e.Resource, e.ID)
}

func getUser(id int) (*User, error) {
    // ...
    return nil, &NotFoundError{Resource: "пользователь", ID: id}
}

Кастомный тип позволяет хранить машиночитаемые данные: не просто строку, а конкретные поля. Вызывающий код сможет их извлечь — как именно, разберём в разделе об errors.As.

2. Sentinel errors

Sentinel error — это переменная типа error, объявленная на уровне пакета и предназначенная для сравнения:

package store

var ErrNotFound = errors.New("не найдено")
var ErrPermissionDenied = errors.New("нет доступа")

Идея в том, что вызывающий код получает один и тот же указатель на одно и то же значение — и может надёжно сравнивать с ним ошибки. Название «sentinel» (страж) отражает смысл: это фиксированное значение, которое охраняет определённое семантическое значение.

Стандартная библиотека полна sentinel errors. Вот несколько важных:

io.EOF            // нормальное завершение потока чтения
os.ErrNotExist    // файл или директория не существует
sql.ErrNoRows     // запрос не вернул ни одной строки
context.Canceled  // контекст был явно отменён
context.DeadlineExceeded // истёк дедлайн контекста

Их объединяет одно: это сигнал, несущий конкретный смысл, который вызывающий код должен уметь распознать и обработать.

Прямое сравнение и почему его недостаточно

До Go 1.13 сравнение через == было стандартным:

_, err := io.ReadAll(r)
if err == io.EOF {
    // конец данных, всё нормально
}

Это работает, пока ошибка не оборачивается. Как только появляется обёртка, прямое сравнение ломается:

wrapped := fmt.Errorf("читаю файл: %w", io.EOF)
fmt.Println(wrapped == io.EOF) // false — это уже другой объект

Именно поэтому с Go 1.13 рекомендуется использовать errors.Is — он умеет раскрутить цепочку обёрток и проверить каждое звено:

fmt.Println(errors.Is(wrapped, io.EOF)) // true

Антипаттерн: сравнение по строке

Иногда в старом коде встречается такой подход:

if err.Error() == "not found" {
    // ...
}

Это антипаттерн по нескольким причинам: строка может измениться при рефакторинге, не работает с оборачиванием, не масштабируется на несколько пакетов. Всегда используй sentinel errors или кастомные типы.

3. Кастомные типы ошибок

Sentinel error хранит только сообщение. Когда нужен контекст — использую кастомный тип:

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("поле %q: %s", e.Field, e.Message)
}

Вызывающий код может не только проверить факт ошибки, но и получить конкретные поля:

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Message: "не может быть отрицательным"}
    }
    if age > 150 {
        return &ValidationError{Field: "age", Message: "не может быть больше 150"}
    }
    return nil
}

err := validateAge(-5)
var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Printf("Ошибка валидации поля: %s\n", valErr.Field)
}

errors.As умеет находить нужный тип даже если ошибка обёрнута — подробнее в разделе 6. Здесь важно другое: кастомный тип даёт машиночитаемый контекст, который строка никогда не даст.

Поддержка цепочки через Unwrap

Чтобы кастомная ошибка участвовала в цепочке unwrapping, добавь поле и метод:

type DBError struct {
    Op  string
    Err error // вложенная ошибка
}

func (e *DBError) Error() string {
    return fmt.Sprintf("db %s: %v", e.Op, e.Err)
}

func (e *DBError) Unwrap() error {
    return e.Err
}

Теперь errors.Is и errors.As смогут пройти сквозь DBError и добраться до вложенной ошибки:

err := &DBError{Op: "query", Err: sql.ErrNoRows}
fmt.Println(errors.Is(err, sql.ErrNoRows)) // true

Go-вопросы в Telegram

Ежедневные разборы горутин, каналов и паттернов.

Подписаться

4. Оборачивание ошибок: fmt.Errorf и %w

До Go 1.13 при добавлении контекста использовали %v:

return fmt.Errorf("открываю конфиг: %v", err)

Это добавляет строку с описанием, но теряет оригинальную ошибку как значение. Проверить через errors.Is или извлечь через errors.As уже нельзя.

Go 1.13 добавил %w:

return fmt.Errorf("открываю конфиг: %w", err)

Разница принципиальная: %w не просто вставляет строку — он создаёт новую ошибку типа *fmt.wrapError, которая содержит оригинальную ошибку и реализует Unwrap() error. Цепочка сохраняется, и errors.Is/errors.As могут её пройти.

Анатомия оборачивания

Что происходит внутри fmt.Errorf("открываю конфиг: %w", err):

// Примерно так реализован *fmt.wrapError
type wrapError struct {
    msg string
    err error
}

func (e *wrapError) Error() string { return e.msg }
func (e *wrapError) Unwrap() error { return e.err }

Когда errors.Is получает обёрнутую ошибку, он вызывает Unwrap() до тех пор, пока не найдёт совпадение или не дойдёт до nil.

Несколько уровней оборачивания

Типичный сценарий — ошибка поднимается через несколько слоёв:

// слой хранилища
func (r *UserRepo) Find(id int) (*User, error) {
    row := r.db.QueryRow("SELECT * FROM users WHERE id = $1", id)
    if err := row.Scan(&u); err == sql.ErrNoRows {
        return nil, fmt.Errorf("find user %d: %w", id, store.ErrNotFound)
    }
    // ...
}

// слой сервиса
func (s *UserService) GetProfile(id int) (*Profile, error) {
    user, err := s.repo.Find(id)
    if err != nil {
        return nil, fmt.Errorf("get profile: %w", err)
    }
    // ...
}

// слой хендлера
profile, err := svc.GetProfile(42)
if err != nil {
    if errors.Is(err, store.ErrNotFound) {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    http.Error(w, "internal error", http.StatusInternalServerError)
}

Хендлер получает ошибку вида "get profile: find user 42: не найдено" — строку можно логировать. При этом errors.Is(err, store.ErrNotFound) вернёт true, потому что цепочка сохранена.

%v vs %w: правило выбора

%w — когда вызывающий код должен иметь возможность проверить вложенную ошибку через errors.Is/As. Это правило для внутренних вызовов внутри одного сервиса.

%v — когда ошибка является деталью реализации и не должна «протекать» наружу. Типичный случай — граница пакета или API: не хочешь, чтобы вызывающий код завязывался на внутренние типы ошибок.

// Граница пакета: скрываем детали реализации
func (s *AuthService) Login(login, pass string) (*Token, error) {
    token, err := s.db.FindToken(login, pass)
    if err != nil {
        // %v — вызывающий код не должен знать о деталях БД
        return nil, fmt.Errorf("аутентификация: %v", err)
    }
    return token, nil
}

Антипаттерн: двойное оборачивание без контекста

// Плохо: добавляем обёртку, но никакого нового смысла нет
return fmt.Errorf("ошибка: %w", err)

Оборачивание имеет смысл только тогда, когда добавляет информацию: какая операция выполнялась, с какими параметрами, на каком уровне. Если добавить нечего — просто возвращай err напрямую.

5. errors.Is — проверка идентичности

errors.Is(err, target) проверяет, есть ли в цепочке обёрток ошибка, равная target:

func Is(err, target error) bool

Алгоритм простой: сравниваем err == target. Если не совпало — вызываем err.Unwrap() и повторяем. Если Unwrap() вернул nil — возвращаем false.

err := fmt.Errorf("слой A: %w",
    fmt.Errorf("слой B: %w",
        fmt.Errorf("слой C: %w", io.EOF)))

fmt.Println(errors.Is(err, io.EOF)) // true — найдено на 3-м уровне

Кастомный Is

Иногда стандартного сравнения через == недостаточно. Например, если хочешь сравнивать ошибки по коду, а не по указателю:

type AppError struct {
    Code    int
    Message string
}

func (e *AppError) Error() string {
    return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

func (e *AppError) Is(target error) bool {
    t, ok := target.(*AppError)
    if !ok {
        return false
    }
    return e.Code == t.Code
}

var ErrForbidden = &AppError{Code: 403, Message: "нет доступа"}

err := fmt.Errorf("обработка запроса: %w", &AppError{Code: 403, Message: "токен истёк"})
fmt.Println(errors.Is(err, ErrForbidden)) // true — коды совпадают

errors.Is проверяет, реализует ли ошибка метод Is(error) bool, и если да — делегирует сравнение ему.

Важный нюанс: только %w сохраняет цепочку

withW := fmt.Errorf("обёртка: %w", io.EOF)
withV := fmt.Errorf("обёртка: %v", io.EOF)

fmt.Println(errors.Is(withW, io.EOF)) // true
fmt.Println(errors.Is(withV, io.EOF)) // false — цепочка потеряна

После %v оригинальная ошибка становится просто частью строки. errors.Is не может её найти — потому что технически её уже нет как значения.

6. errors.As — извлечение типа

Там где errors.Is проверяет конкретное значение, errors.As проверяет тип. Сигнатура:

func As(err error, target any) bool

target должен быть указателем на тип ошибки (или на интерфейс). Если в цепочке найдётся ошибка нужного типа — она будет записана в target и функция вернёт true:

_, err := os.Open("/несуществующий/файл")
wrappedErr := fmt.Errorf("загружаю конфиг: %w", err)

var pathErr *os.PathError
if errors.As(wrappedErr, &pathErr) {
    fmt.Println("операция:", pathErr.Op)   // open
    fmt.Println("путь:", pathErr.Path)     // /несуществующий/файл
    fmt.Println("ошибка ОС:", pathErr.Err) // no such file or directory
}

Без errors.As пришлось бы делать type assertion напрямую на оборачивающей ошибке — что не сработало бы, ведь внешний тип — *fmt.wrapError.

errors.As vs type assertion

// Type assertion — работает только если err именно *os.PathError
pathErr, ok := err.(*os.PathError)

// errors.As — работает и через любое количество обёрток
var pathErr *os.PathError
ok := errors.As(err, &pathErr)

Type assertion можно использовать, только если точно знаешь, что обёрток нет. В остальных случаях — errors.As.

Кастомный As

Аналогично Is, можно реализовать метод As(target any) bool для нестандартного поведения:

type MultiError struct {
    Errors []error
}

func (m *MultiError) Error() string {
    msgs := make([]string, len(m.Errors))
    for i, e := range m.Errors {
        msgs[i] = e.Error()
    }
    return strings.Join(msgs, "; ")
}

func (m *MultiError) As(target any) bool {
    // Пробуем привести к *MultiError
    if t, ok := target.(**MultiError); ok {
        *t = m
        return true
    }
    // Пробуем найти нужный тип в каждой из вложенных ошибок
    for _, err := range m.Errors {
        if errors.As(err, target) {
            return true
        }
    }
    return false
}

Go для собеседований: 50 реальных вопросов

Горутины, каналы, ошибки, интерфейсы — с разборами и примерами кода.

Начать тренировку

7. errors.Unwrap и поддержка деревьев ошибок

errors.Unwrap(err) — низкоуровневая функция, которую errors.Is и errors.As используют внутри:

wrapped := fmt.Errorf("обёртка: %w", io.EOF)
inner := errors.Unwrap(wrapped)
fmt.Println(inner == io.EOF) // true
fmt.Println(errors.Unwrap(inner)) // <nil> — io.EOF не оборачивает ничего

Напрямую errors.Unwrap редко нужен — обычно достаточно Is и As. Но понимать, что происходит под капотом, важно.

Go 1.20: errors.Join и деревья ошибок

До Go 1.20 одна ошибка могла содержать только одну вложенную. В Go 1.20 добавили errors.Join и поддержку Unwrap() []error:

err1 := errors.New("ошибка сети")
err2 := errors.New("ошибка диска")

combined := errors.Join(err1, err2)
fmt.Println(combined) // ошибка сети\nошибка диска

Под капотом errors.Join создаёт ошибку с методом Unwrap() []error, который возвращает срез. errors.Is и errors.As умеют обходить такие деревья рекурсивно:

fmt.Println(errors.Is(combined, err1)) // true
fmt.Println(errors.Is(combined, err2)) // true

Это полезно, когда несколько горутин возвращают ошибки и нужно их объединить в одну:

var mu sync.Mutex
var errs []error

var wg sync.WaitGroup
for _, task := range tasks {
    wg.Add(1)
    go func(t Task) {
        defer wg.Done()
        if err := t.Run(); err != nil {
            mu.Lock()
            errs = append(errs, err)
            mu.Unlock()
        }
    }(task)
}
wg.Wait()

return errors.Join(errs...)

Собственная реализация Unwrap() []error

type MultiValidationError struct {
    Errors []error
}

func (e *MultiValidationError) Error() string {
    msgs := make([]string, len(e.Errors))
    for i, err := range e.Errors {
        msgs[i] = err.Error()
    }
    return "ошибки валидации: " + strings.Join(msgs, ", ")
}

func (e *MultiValidationError) Unwrap() []error {
    return e.Errors
}

8. Паттерны обработки ошибок

Механизмы разобрали. Теперь — практика: как использовать их правильно в реальном коде.

Добавляй контекст при каждом подъёме

Ошибка должна нести в себе путь к причине. Каждый слой добавляет что-то своё:

// Хранилище
return fmt.Errorf("userRepo.Find(id=%d): %w", id, err)

// Сервис
return fmt.Errorf("UserService.GetProfile: %w", err)

// Хендлер
return fmt.Errorf("GET /users/%d: %w", id, err)

В итоге лог будет выглядеть так:

GET /users/42: UserService.GetProfile: userRepo.Find(id=42): не найдено

Это читаемый стектрейс без единого стека вызовов. Дебажить такой лог гораздо проще, чем безымянную "не найдено" без контекста.

Граница пакета: скрывай детали

Когда ошибка пересекает границу публичного API — используй %v вместо %w:

// Публичный метод пакета
func (c *Client) Fetch(url string) ([]byte, error) {
    body, err := c.doRequest(url)
    if err != nil {
        // Не раскрываем внутренний тип *requestError наружу
        return nil, fmt.Errorf("fetch %s: %v", url, err)
    }
    return body, nil
}

Это называется «капсуляция ошибок». Вызывающий код видит сообщение, но не может через errors.As добраться до внутреннего типа. Если нужно дать доступ к структурированным данным — делай это через явный sentinel или кастомный публичный тип.

Единственная проверка: не проверяй на каждом уровне

Частая ошибка — логировать ошибку на каждом уровне стека:

// Плохо: ошибка логируется трижды
func (r *Repo) Find(id int) (*User, error) {
    u, err := r.db.Get(id)
    if err != nil {
        log.Println(err) // первый лог
        return nil, fmt.Errorf("repo: %w", err)
    }
    return u, nil
}

func (s *Service) Get(id int) (*User, error) {
    u, err := s.repo.Find(id)
    if err != nil {
        log.Println(err) // второй лог
        return nil, fmt.Errorf("service: %w", err)
    }
    return u, nil
}

func handler(w http.ResponseWriter, r *http.Request) {
    u, err := svc.Get(42)
    if err != nil {
        log.Println(err) // третий лог
        http.Error(w, "internal error", 500)
    }
}

В логах появятся три одинаковые записи. Правильная стратегия: либо логируй, либо возвращай наверх — не делай оба. Логировать стоит один раз, на том уровне, где ошибку окончательно обработали (обычно это самый верхний слой — хендлер или main):

// Хорошо: repo и service только оборачивают и возвращают
// хендлер логирует один раз и завершает обработку
func handler(w http.ResponseWriter, r *http.Request) {
    u, err := svc.Get(42)
    if err != nil {
        log.Printf("GET /users/42: %v", err) // единственный лог
        http.Error(w, "internal error", 500)
        return
    }
    // ...
}

Сравнение с pkg/errors

До Go 1.13 популярной альтернативой был пакет github.com/pkg/errors. Он давал errors.Wrap(err, "msg") и стектрейс через %+v. С появлением встроенного wrapping через %w необходимость в pkg/errors существенно снизилась — стандартная библиотека теперь покрывает большинство сценариев. В новых проектах предпочтительнее стандартный подход.

9. Типичные вопросы на собеседовании

«Что такое sentinel error и когда его применять?»

Sentinel error — переменная типа error на уровне пакета, которая идентифицирует конкретный вид ошибки. Применять стоит тогда, когда вызывающий код должен уметь различить конкретные исходы и среагировать по-разному: например, вернуть 404 на ErrNotFound и 409 на ErrConflict. Не стоит делать sentinel из каждой ошибки — только из тех, на которые планируется реагировать.

«Зачем нужен %w, чем отличается от %v

%w оборачивает оригинальную ошибку: создаёт новое значение, содержащее исходную через Unwrap(). Это позволяет errors.Is/As добраться до оригинала. %v просто включает строковое представление ошибки в текст нового сообщения — оригинальная ошибка как значение теряется.

«Как работает errors.Is под капотом?»

Сравнивает err == target. Если нет — вызывает err.Unwrap() и повторяет. Если ошибка реализует метод Is(error) bool — делегирует сравнение ему. Это рекурсивный обход цепочки до совпадения или nil.

«Как errors.As отличается от type assertion?»

Type assertion работает только на самом верхнем типе: если ошибка обёрнута, assertion на внутренний тип упадёт с ok = false. errors.As проходит всю цепочку через Unwrap() и находит нужный тип на любой глубине.

«Как реализовать кастомный тип ошибки с поддержкой unwrapping?»

Добавь поле Err error и метод Unwrap() error { return e.Err }. Тогда errors.Is/As смогут пройти сквозь твой тип и добраться до вложенного. Без Unwrap() цепочка обрывается на твоём типе.

«Когда НЕ нужно оборачивать ошибку?»

Когда оборачивание не добавляет контекста: fmt.Errorf("ошибка: %w", err) бессмысленно. Также на границе пакета, когда не хочешь раскрывать внутренние типы ошибок вызывающему коду. И когда ошибка уже несёт достаточно информации — лишние обёртки загрязняют сообщение.

«Что произойдёт с errors.Is, если использовали %v вместо %w

errors.Is вернёт false. После %v оригинальная ошибка становится частью строки, а не частью цепочки. Новая ошибка не реализует Unwrap(), поэтому раскручивать нечего.

«Что такое errors.Join и когда он нужен?»

errors.Join объединяет несколько ошибок в одну, создавая ошибку с методом Unwrap() []error. Нужен когда несколько независимых операций (например, несколько горутин) могут завершиться с ошибками, и хочется вернуть их все разом. errors.Is/As умеют обходить такое дерево.

«Как логировать ошибки без дублирования?»

Правило одного лога: либо логируй и обрабатывай, либо оборачивай и возвращай наверх. Логировать стоит один раз на самом верхнем уровне — там, где ошибка окончательно обрабатывается (хендлер, main). Все нижние слои только добавляют контекст через fmt.Errorf("%w").

10. Итоги

Обработка ошибок в Go выстроена вокруг нескольких простых, но мощных примитивов. Интерфейс error даёт единую точку входа, sentinel errors — именованные константы для ветвления, кастомные типы — структурированный контекст. Механизм wrapping через %w, появившийся в Go 1.13, склеивает всё воедино: можно добавлять контекст на каждом уровне и при этом не терять доступ к исходной ошибке.

Ключевые выводы:

errors.Is проверяет идентичность значения в цепочке, errors.As — тип. Оба умеют рекурсивно обходить цепочку Unwrap(). %w сохраняет цепочку, %v её обрывает. На границах пакета — %v, внутри слоёв — %w. Логируй ошибку один раз на верхнем уровне, не дублируй по стеку.

Для более глубокого погружения в Go — смотри другие материалы кластера: 50 вопросов junior–middle Go-разработчика, context в Go: отмена, дедлайны и best practices, горутины в Go: concurrency на собеседовании.

Тренируй Go-интервью в реальном времени

Lexicon проводит голосовые mock-интервью по Go: ошибки, горутины, контексты, каналы. Получи развёрнутый фидбэк после каждой сессии.

Попробовать бесплатно

Автор

Lexicon Team

Читайте также