error— встроенный интерфейс с единственным методомError() string- создание:
errors.New("text")— простая ошибка;fmt.Errorf("... %v", arg)— с форматированием - ошибку возвращают последней из функции — соглашение Go
- именование переменных: начинаются с
Err/err(ErrNotFound,errInternal) - именование типов: заканчиваются на
Error(PathError,SyntaxError) - текст ошибки — с маленькой буквы (соглашение гошного коммьюнити)
Интерфейс
// builtin
type error interface {
Error() string
}
Любой тип с методом Error() string реализует этот интерфейс.
Создание ошибок
// Простая ошибка — достаточно в большинстве случаев
err := errors.New("connection refused")
// С форматированием — когда нужны параметры в тексте
err := fmt.Errorf("user %d not found", userID)
На практике чаще всего используют именно эти два способа — отдельные типы нужны редко.
Соглашения именования
// Переменные — начинаются с Err/err
var ErrNotFound = errors.New("not found") // публичная
var errInternal = errors.New("internal error") // приватная
// Типы — заканчиваются на Error
type PathError struct { Op, Path string; Err error }
type SyntaxError struct { Msg string; Offset int64 }
// Текст — с маленькой буквы
errors.New("connection refused") // ✅
errors.New("Connection refused") // ❌
Ошибка — последнее возвращаемое значение
// ✅ принято в Go
func ReadFile(path string) ([]byte, error) { ... }
// ❌ непривычно, ломает ожидания
func ReadFile(path string) (error, []byte) { ... }
Антипаттерн: лишнее проксирование
// ❌ бессмысленный код — ничего не меняет
func DoSomething() error {
err := doWork()
if err != nil {
return err
}
return nil
}
// ✅ эквивалентно и проще
func DoSomething() error {
return doWork()
}
Если сигнатуры совпадают и контекст не нужен — просто проксируй.
- sentinel (дозорная) ошибка — глобальная переменная для маркировки конкретной ситуации
- нужна, чтобы понять: произошла ли именно эта ошибка (sql.ErrNoRows, io.EOF)
- проблема: глобальную переменную можно изменить из другого пакета
- решение: константные ошибки — type definition от string + метод Error()
- сравнение через
errors.Is, не через ==
Sentinel ошибка
// Зарезервированная глобальная ошибка
var ErrDatabaseProblem = errors.New("database problem")
// Использование
func QueryUser(id int) (*User, error) {
row := db.QueryRow("SELECT ...", id)
err := row.Scan(&user)
if err == sql.ErrNoRows {
return nil, ErrDatabaseProblem
}
return &user, err
}
sql.ErrNoRows — классический пример sentinel: нет строк, запрос пуст.
Проблема: мутабельность
// Кто угодно может поменять глобальную ошибку!
prevEOF := io.EOF
io.EOF = errors.New("hacked!")
fmt.Println(io.EOF == prevEOF) // false — сломали!
Никто так не делает, но технически возможно.
Решение: константные ошибки
type ConstError string
func (e ConstError) Error() string {
return string(e) // type definition → явное приведение
}
const ErrDatabase ConstError = "database problem"
// Нельзя изменить:
// ErrDatabase = "other" // ❌ ошибка компиляции — константу менять нельзя
Хак: type definition от string → можно сделать константой. Метод Error() делает совместимым с интерфейсом error.
Почему нужно явное приведение string(e) в методе Error(): type definition создаёт новый тип, не псевдоним, поэтому прямое использование e в строковом контексте запрещено.
Оборачивание работает
wrapped := fmt.Errorf("query failed: %w", ErrDatabase)
fmt.Println(errors.Is(wrapped, ErrDatabase)) // true
ConstError — обычная ошибка, errors.Is/As/Unwrap работают.
Почему не сравнивать через ==
Если ошибка была обёрнута через fmt.Errorf("%w", sentinel), прямое сравнение err == sentinel вернёт false. errors.Is раскручивает цепочку обёрток.
- оборачивание = упаковка ошибки в контейнер-обёртку, сохраняя доступ к исходной
%v— просто вставляет текст, Unwrap не работает (нет метода Unwrap)%w(Go 1.13+) — создаёт тип с методомUnwrap(), цепочка разворачивания работаетerrors.Unwrap()— вызывает метод Unwrap, возвращает внутреннюю ошибку (или nil)- зачем: добавить контекст при пробросе или пометить ошибку sentinel'ом
%v vs %w
original := errors.New("connection refused")
// %v — просто текст, unwrap НЕ работает
// (такая же структра создается и для errors.New())
//type errorString struct {
// s string
//}
wrapped1 := fmt.Errorf("db error: %v", original)
fmt.Println(errors.Unwrap(wrapped1)) // <nil> — нечего разворачивать
// %w — создаёт обёртку с Unwrap(), цепочка работает
//type wrapError struct {
// msg string
// err error // Здесь хранится ссылка на оригинальную ошибку
//}
wrapped2 := fmt.Errorf("db error: %w", original)
fmt.Println(errors.Unwrap(wrapped2)) // "connection refused"
%v создаёт строку без метода Unwrap — errors.Unwrap возвращает nil. %w создаёт специальный тип с методом Unwrap(), который возвращает оригинальную ошибку.
Цепочка оборачивания
err1 := errors.New("disk full")
err2 := fmt.Errorf("write failed: %w", err1)
err3 := fmt.Errorf("save config: %w", err2)
// Разворачиваем по одному:
fmt.Println(err3) // "save config: write failed: disk full"
fmt.Println(errors.Unwrap(err3)) // "write failed: disk full"
fmt.Println(errors.Unwrap(errors.Unwrap(err3))) // "disk full"
fmt.Println(errors.Unwrap(errors.Unwrap(errors.Unwrap(err3)))) // <nil>
Каждый %w добавляет слой. Unwrap снимает по одному. Финальный Unwrap на самой глубокой ошибке возвращает nil.
Два сценария использования
// 1. Добавить контекст при пробросе
func ReadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading config %s: %w", path, err)
}
// ...
}
// 2. Пометить sentinel'ом
var ErrValidation = errors.New("validation error")
func Validate(input string) error {
if input == "" {
return fmt.Errorf("%w: empty input", ErrValidation)
}
// ...
}
Сценарий 1: %w добавляет контекст — теперь ошибка несёт информацию о пути и операции.
Сценарий 2: оборачивание sentinel'а — errors.Is(err, ErrValidation) будет работать, потому что цепочка разворачивается рекурсивно.
Ловушки
// Строка вместо ошибки — unwrap не сработает как ожидалось
wrapped := fmt.Errorf("error: %w", "not an error") // строка, не error!
// Два %w — поведение может быть неочевидным
wrapped := fmt.Errorf("%w and %w", err1, err2)
// Go 1.20+ поддерживает multiple wrapping, но осторожно
Go 1.20+ поддерживает несколько %w в одном fmt.Errorf — создаётся ошибка с несколькими обёрнутыми ошибками (multiple wrapping). До 1.20 поведение с двумя %w было undefined.
errors.Is— рекурсивный Unwrap + сравнение по значению (для sentinel)errors.As— рекурсивный Unwrap + сравнение по типу (для кастомных типов)- всегда использовать Is/As вместо == и type switch — код меняется, обёртки добавляются
- type switch по обёрнутой ошибке не найдёт исходный тип (за интерфейсом будет тип обёртки)
- специальный приём: оборачивание sentinel без контекста — чтобы запретить ==
errors.Is — сравнение по значению
var ErrDB = errors.New("database problem")
wrapped := fmt.Errorf("query: %w", ErrDB)
// ❌ не сработает — wrapped != ErrDB (разные объекты)
fmt.Println(wrapped == ErrDB) // false
// ✅ рекурсивно unwrap'ит и находит ErrDB внутри
fmt.Println(errors.Is(wrapped, ErrDB)) // true
errors.Is рекурсивно вызывает Unwrap() на каждом уровне цепочки, пока не найдёт совпадение по значению или не исчерпает цепочку.
errors.As — сравнение по типу
type DatabaseError struct {
Query string
Err error
}
func (e *DatabaseError) Error() string { return e.Err.Error() }
original := &DatabaseError{Query: "SELECT", Err: errors.New("timeout")}
wrapped := fmt.Errorf("handler: %w", original)
// ❌ type switch не найдёт — за интерфейсом тип обёртки, не DatabaseError
switch wrapped.(type) {
case *DatabaseError: // сюда НЕ зайдёт
}
// ✅ errors.As рекурсивно ищет нужный тип
var dbErr *DatabaseError
if errors.As(wrapped, &dbErr) {
fmt.Println(dbErr.Query) // "SELECT"
}
errors.As рекурсивно разворачивает цепочку, ища ошибку, assignable к типу целевого указателя, и присваивает её при нахождении.
Почему всегда Is/As
// Сейчас работает:
if err == sql.ErrNoRows { ... }
// Завтра разработчик добавил контекст:
return fmt.Errorf("users table: %w", sql.ErrNoRows)
// И ваш == сломался! А errors.Is продолжит работать:
if errors.Is(err, sql.ErrNoRows) { ... } // ✅ всегда
Код меняется — обёртки добавляются. Is/As защищают от этого.
Запрет сравнения через ==
// Специально оборачивают без контекста:
var errBase = errors.New("access denied")
var ErrAccessDenied = fmt.Errorf("%w", errBase)
// Теперь == не сработает НИКОГДА — только errors.Is
fmt.Println(err == ErrAccessDenied) // false
fmt.Println(errors.Is(err, ErrAccessDenied)) // true
Приём для библиотек: заставить пользователей сразу использовать errors.Is.
Никогда не сравнивай текст ошибки
// ❌ хрупко, антипаттерн
if err.Error() == "connection refused" { ... }
Текст ошибки — для людей (логи, экран), не для кода.
- ошибка должна рассказывать историю — 4 правила текста
- обрабатывать ошибку ровно один раз (логирование = обработка)
- не хватает контекста/ответственности → пробрасывай наверх (с контекстом или без)
- есть контекст → обрабатывай прямо здесь (лог, retry, переподключение и т.д.)
- не добавляй бессмысленный контекст — если нечего сказать, просто проксируй
4 правила текста ошибки
- Максимально подробно — не просто
"error", а что именно случилось - Весь контекст для расследования — получая текст, можно понять причину
- Однозначно характеризует место — одна и та же ошибка не из двух мест кода
- Не слишком большой — логи стоят дорого, не злоупотреблять длиной
Обрабатывать один раз
// ❌ двойная обработка — залогировали И пробросили
func GetRoute(lat, lon float64) error {
err := validateCoords(lat, lon)
if err != nil {
log.Printf("validation failed: %v", err) // обработка 1
return fmt.Errorf("invalid coords: %w", err) // обработка 2
}
return nil
}
// ✅ либо логируем здесь (если хватает контекста)
func GetRoute(lat, lon float64) error {
err := validateCoords(lat, lon)
if err != nil {
log.Printf("GetRoute: validation failed: %v", err)
return err // не добавляем контекст, уже залогировали
}
return nil
}
// ✅ либо пробрасываем с контекстом (если не хватает)
func GetRoute(lat, lon float64) error {
err := validateCoords(lat, lon)
if err != nil {
return fmt.Errorf("validate coords in GetRoute: %w", err)
}
return nil
}
Проброс — не обработка
Проброс ошибки наверх (с контекстом или без) — это не обработка. Обработка — это когда вы что-то делаете: логируете, делаете retry, отвечаете клиенту, меняете поведение.
Не добавляй пустой контекст
// ❌ контекст бессмысленный
return fmt.Errorf("error: %w", err)
// ✅ просто проксируй
return err
// ✅ или добавь полезный контекст
return fmt.Errorf("reading config file %s: %w", path, err)
- Go не поддерживает исключения (exceptions) — вместо них явная обработка ошибок
panic— механизм для исключительных ситуаций, когда продолжение невозможно- panic ≠ throw: throw делает проблему вызывающей стороны, panic = «не знаю что делать, сдаюсь»
recoverработает только в defer — вне defer возвращает nil- panic принимает
any— можно передать строку, ошибку, что угодно, даже nil
Panic + recover
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)\n }
}()
fmt.Println("start")\n panic("something terrible")
fmt.Println("unreachable") // никогда не выполнится
}
// start
// recovered: something terrible
Stack unwinding (раскрутка стека)
1. panic() → остановка текущей функции
2. вызов defer'ов текущей функции (LIFO)
3. выход → вызов defer'ов вызывающей функции
4. ... и так вверх по стеку
5. Если нашли recover в defer → восстановление
6. Если не нашли → завершение программы с трейсом
Recover только в defer
func process() {
recover() // ❌ бесполезно — вернёт nil
defer recover() // ❌ тоже не сработает (не вызов в defer-функции)
defer func() { recover() }() // ✅ работает
panic("error")
}
defer recover() не работает потому что recover вызывается напрямую как аргумент defer, а не внутри анонимной функции — он исполняется сразу, не во время паники. Правильно: defer func() { recover() }().
Panic принимает any
panic("string error") // строка
panic(errors.New("real error")) // error
panic(42) // int
panic(nil) // nil → Go 1.21+ создаёт runtime.PanicNilError
Когда использовать panic
Ошибка программиста (пришло значение, которое никогда не должно было прийти). Зависимость не инициализировалась (база не пингуется, без неё приложение бессмысленно). Исключительная ситуация, когда нечего обрабатывать.
Почему Go без исключений
Исключения = сложность (гарантии безопасности, утечки ресурсов в C++, неявность потока управления). Go позиционируется как простой язык — явная обработка ошибок проще для понимания.
- sentinel — когда нужна маркировка без дополнительного контекста (io.EOF, sql.ErrNoRows)
- кастомный тип — когда нужен контекст: поля с параметрами (os.PathError: Op, Path, Err)
- оба создают зависимость между пакетами (нужен импорт для сравнения)
- проверка по поведению — type assertion к анонимному интерфейсу, разрывает зависимость
- ошибки = часть публичного API пакета, относиться бережно
Sentinel — маркировка
var ErrNotFound = errors.New("not found")
var ErrTimeout = errors.New("timeout")
// Проверка:
if errors.Is(err, ErrNotFound) { ... }
Просто, без полей. Достаточно, когда не нужен контекст.
Кастомный тип — контекст
// Пример из stdlib: os.PathError
type PathError struct {
Op string // "open", "read"
Path string // "/etc/config"
Err error // underlying error
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
// Проверка + доступ к полям:
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println(pathErr.Path) // "/etc/config"
}
Неудобно складывать Op, Path в текст ошибки и потом парсить обратно. Проще — отдельный тип.
Оба создают зависимость
import "myapp/storage"
// Для проверки sentinel — нужен импорт storage
if errors.Is(err, storage.ErrNotFound) { ... }
// Для проверки типа — тоже нужен импорт storage
var dbErr *storage.DatabaseError
if errors.As(err, &dbErr) { ... }
Проверка по поведению — разрыв зависимости
// Пакет storage — ошибка с поведением
type FSError struct{ path string }
func (e *FSError) Error() string { return "fs: " + e.path }
func (e *FSError) Path() string { return e.path }
// Другой пакет — НЕ импортирует storage!
func isPathError(err error) bool {
_, ok := err.(interface{ Path() string }) // анонимный интерфейс
return ok
}
Программист должен знать поведение (какой метод проверять), но коду не нужен импорт. Для исключительных случаев, когда важно разорвать зависимость.
Ошибки = публичное API
Публичные sentinel'ы и типы ошибок — такая же часть API, как функции и структуры. Менять их — breaking change. Относиться бережно.
- multierror — пакет для списка ошибок, когда одной недостаточно
- сценарий: несколько независимых ветвей могут давать ошибки (обработка среза, параллельные запросы)
Appendдля накопления, результат — обычнаяerrorс методомError()- полностью совместим с
errors.Is,errors.As,Unwrap - компактнее, чем возвращать
(error, error, error)и проверять каждую
Зачем
// Обрабатываем срез — на каждом элементе может быть сбой
func ProcessAll(items []Item) error {
var result error
for _, item := range items {
if err := process(item); err != nil {
result = multierror.Append(result, err)
}
}
return result // nil если ошибок не было, иначе список
}
Не хотим останавливаться на первой ошибке — обрабатываем весь срез, собираем все ошибки.
multierror.Append(nil, ...) возвращает nil если все переданные ошибки nil. Если хотя бы одна не nil — возвращает multierror.
Совместимость с Is/As/Unwrap
err1 := errors.New("db timeout")
err2 := errors.New("cache miss")
var ErrFatal = errors.New("fatal")
multi := multierror.Append(nil, err1, err2, ErrFatal)
// Оборачивание тоже работает
wrapped := fmt.Errorf("batch failed: %w", multi)
// Is/As проходят по всему списку
fmt.Println(errors.Is(wrapped, ErrFatal)) // true
errors.Is при поиске проходит по всему списку ошибок внутри multierror.
Почему не (error, error, error)
// ❌ загромождает — на каждом уровне проброса проверяй все три
func DoWork() (error, error, error) { ... }
// ✅ одна ошибка — внутри список
func DoWork() error {
var errs error
errs = multierror.Append(errs, step1())
errs = multierror.Append(errs, step2())
return errs
}
- стандартный пакет
errorsне даёт стектрейс - пакет
github.com/pkg/errors— сохраняет стек при создании ошибки - снятие стектрейса — не бесплатно, бенчмарк показывает существенный оверхед
- не злоупотреблять в горячих местах и логах — логи стоят дорого
Стандартный пакет — без стектрейса
err := errors.New("something failed")
// Нет способа получить стек вызова — только текст
Стандартный errors.New создаёт ошибку только с текстом — никакого стека вызовов.
github.com/pkg/errors — со стектрейсом
import pkgerrors "github.com/pkg/errors"
err := pkgerrors.New("something failed")
// Внутри сохраняется стек вызова на момент создания
fmt.Printf("%+v\n", err)\n// something failed
// main.process
// /app/main.go:15
// main.main
// /app/main.go:8
%+v — специальный формат для pkg/errors, выводит текст ошибки + полный стектрейс с именами функций и номерами строк.
Оверхед
BenchmarkStandardError ~50 ns/op 0 allocs
BenchmarkPkgErrors ~2000 ns/op 3 allocs
Снять стектрейс ≈ в 40 раз дороже стандартного errors.New. Причина: runtime.Callers — проход по стеку, аллокации.
Когда использовать
Стектрейс полезен для отладки, но не нужен всегда. Если в горячем цикле создаёте ошибки со стектрейсом — это оверхед. Если логируете стектрейсы в каждом безобидном логе — логи разрастаются и стоят дорого.