{}const=>[]async()letfn</>var
РазработкаBackend

Ошибки в Go: зачем нужен if err != nil и как правильно обрабатывать error

Разбираем обработку ошибок в Go: почему if err != nil встречается везде, как работают error, errors.Is, errors.As, fmt.Errorf, %w, panic и recover.

К

Кодик

Автор

7 мин чтения

Как вообще работают ошибки в Go?

В Java, JavaScript, Python или C# вы могли привыкнуть к конструкции примерно такого типа:

try:
    user=get_user()
except Exception as e:
    print(e)

Функция внутри может выбросить исключение, которое полетит вверх по стеку, пока где-нибудь не встретится обработчик.

В Go основной подход другой.

Ошибка — это обычное возвращаемое значение.

Например:

func getUser(id int)(User,error){
    // ...
}

Функция обещает вернуть две вещи:

  1. User;

  2. error.

Вызываем:

user,err:=getUser(42)

Если всё хорошо:

err==nil

Если что-то пошло не так:

err!=nil

Отсюда и главный мем Go-разработки:

if err!=nil{
    return err
}
🔥 100 000+ учеников уже с нами

Устал читать теорию?
Пора кодить!

Кодик — приложение, где ты учишься программировать через практику. AI-наставник, интерактивные уроки, реальные проекты.

🤖 AI 24/7
🎓 Сертификаты
💰 Бесплатно
🚀 Начать учиться
Присоединились сегодня

Почему именно nil?

В Go существует встроенный интерфейс:

type error interface{
    Error() string
}

Любой тип, реализующий метод Error() string, может использоваться как ошибка.

Например:

type ValidationError struct{
    Field string
}
func(e ValidationError)Error()string{
    return "invalid field: "+e.Field
}

А nil означает: ошибки нет, продолжаем жить.

Поэтому типичный код выглядит так:

result,err:=doSomething()
if err!=nil{
    return err
}

Именно эта конструкция встречается практически в каждом Go-проекте.

Но почему Go просто не добавит try/catch?

Это не случайность и не потому, что разработчики языка забыли.

Идея Go заключается в том, что обработка ошибок должна быть явной.

Посмотрим на условный код с исключениями:

const user=await getUser()
const orders=await getOrders(user.id)
const invoice=await createInvoice(orders)
sendEmail(invoice)

Выглядит красиво.

Но какие из этих функций могут упасть?

getUser? getOrders? createInvoice? sendEmail?

Теоретически — все.

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

В Go это часто выглядит так:

user,err:=getUser()
if err!=nil{return err}
orders,err:=getOrders(user.ID)
if err!=nil{return err}
invoice,err:=createInvoice(orders)
if err!=nil{return err}

Да, строк больше.

Зато буквально глазами видно: вот здесь операция может завершиться ошибкой.

Go в этом плане немного напоминает очень тревожного коллегу:

— А если файл не откроется?

— Проверим.

— А если база не ответит?

— Проверим.

— А если JSON сломан?

— Проверим.

— А если…

ДА, МЫ УЖЕ ПИШЕМ if err != nil.

Что реально означает if err != nil?

Начинающие часто воспринимают его просто как обязательный шаблон.

Но правильнее читать такой код:

result,err:=operation()
if err!=nil{
    return err
}

как: выполни операцию; если она не удалась — прекрати текущую функцию и сообщи об ошибке вызывающему коду.

Например:

func loadConfig()([]byte,error){
    data,err:=os.ReadFile("config.json")
    if err!=nil{
        return nil,err
    }
    return data,nil
}

Теперь вызывающий код решает, что делать дальше:

config,err:=loadConfig()
if err!=nil{
    log.Fatal(err)
}

Получается цепочка:

os.ReadFile()
↓
loadConfig()
↓
main()

Ошибка постепенно поднимается туда, где её действительно можно нормально обработать.

Главная ошибка новичка: просто возвращать err

Вот такой код технически нормальный:

data,err:=os.ReadFile("config.json")
if err!=nil{
    return err
}

Но представьте большой проект.

Через пять уровней прилетает:

open config.json: no such file or directory

И начинается мини-игра: «угадай, где именно всё развалилось».

Поэтому ошибки часто стоит дополнять контекстом.

fmt.Errorf: добавляем контекст

Например:

data,err:=os.ReadFile("config.json")
if err!=nil{
    return fmt.Errorf("read config: %w",err)
}

Теперь сообщение может выглядеть так:

read config: open config.json: no such file or directory

Уже значительно понятнее.

А если ошибка проходит ещё через один слой:

config,err:=loadConfig()
if err!=nil{
    return fmt.Errorf("start application: %w",err)
}

Получим:

start application: read config: open config.json: no such file or directory

Красота.

Точнее, приложение всё ещё умерло.

Но умерло информативно.

Что за %w?

Очень важный момент.

Можно написать:

fmt.Errorf("load user: %v",err)

А можно:

fmt.Errorf("load user: %w",err)

Разница огромная.

%w оборачивает исходную ошибку, сохраняя её внутри новой.

Это позволяет позже проверить причину через errors.Is() или достать конкретный тип ошибки через errors.As().

Поэтому для передачи ошибки выше обычно используется именно:

return fmt.Errorf("load user: %w",err)

errors.Is: сравниваем ошибки правильно

Допустим, файл не найден.

Можно попробовать:

if err==os.ErrNotExist{
    // ...
}

Но если ошибка была обёрнута через fmt.Errorf, обычное сравнение может не сработать.

Используется:

if errors.Is(err,os.ErrNotExist){
    // файл не найден
}

Например:

data,err:=os.ReadFile("config.json")
if err!=nil{
    if errors.Is(err,os.ErrNotExist){
        fmt.Println("Config file not found")
    }
    return err
}

errors.Is умеет идти по цепочке обёрнутых ошибок.

application failed
└── cannot load settings
    └── cannot read file
        └── file does not exist

И находить исходную причину.

errors.As: когда нужен конкретный тип ошибки

Иногда недостаточно понять, что произошла определённая ошибка. Нужно достать данные из неё.

type ValidationError struct{
    Field string
}
func(e *ValidationError)Error()string{
    return "invalid field: "+e.Field
}

Функция возвращает её:

return &ValidationError{
    Field:"email",
}

Выше можем написать:

var validationErr *ValidationError
if errors.As(err,&validationErr){
    fmt.Println(validationErr.Field)
}

И получить:

email

Полезно, когда ошибки несут дополнительную информацию.

А можно создать ошибку вручную?

Да. Для простых случаев есть errors.New().

var ErrUserNotFound=errors.New("user not found")

И использовать:

func getUser(id int)(User,error){
    if id<=0{
        return User{},ErrUserNotFound
    }
    // ...
}

А выше:

user,err:=getUser(id)
if errors.Is(err,ErrUserNotFound){
    // показать 404
}

Такие ошибки часто объявляют как package-level переменные:

var ErrNotFound=errors.New("not found")
var ErrUnauthorized=errors.New("unauthorized")

Их называют sentinel errors.

По сути это специальные маркеры: произошла ошибка конкретной категории.

Ошибки — это часть API функции

Вот здесь начинает раскрываться философия Go.

func GetUser(id int)User

и:

func GetUser(id int)(User,error)

Это два разных контракта.

Вторая функция прямо говорит: я могу не вернуть пользователя.

В языках с исключениями такая информация часто скрыта.

Функция выглядит совершенно невинно:

user=get_user(id)

Но внутри потенциально могут прилететь DatabaseError, TimeoutError, NotFoundError или ConnectionError.

В Go возможность ошибки буквально присутствует в сигнатуре:

(User,error)

И это одна из причин, почему Go-код обычно довольно легко читать сверху вниз.

Можно ли игнорировать ошибку?

Технически да.

data,_:=os.ReadFile("config.json")

Подчёркивание _ говорит: второе значение мне не нужно.

Но с ошибками так делать стоит только тогда, когда вы реально понимаете последствия.

result,_:=strconv.Atoi("hello")
fmt.Println(result)

Можно получить:

0

И где-то через 500 строк вы будете искать: «а какого чёрта у нас тут ноль?»

Хотя ошибка произошла гораздо раньше.

Игнорирование ошибок — один из лучших способов сделать баг, который потом будет смотреть на вас из production.

Нужно ли логировать каждую ошибку?

Нет.

Вот распространённый анти-паттерн:

if err!=nil{
    log.Println(err)
    return err
}

А выше:

if err!=nil{
    log.Println(err)
    return err
}

И ещё выше:

if err!=nil{
    log.Println(err)
}

В итоге одна ошибка превращается в логах в:

database timeout
database timeout
database timeout
database timeout

И человек на дежурстве думает, что база умерла четыре раза. Хотя умерла один.

Часто хороший принцип такой: нижние уровни возвращают ошибку с контекстом, верхний уровень решает, когда её логировать.

А где panic?

Вот тут начинающие часто говорят: «Подождите. В Go же есть panic. Почему бы вместо всех этих err просто не использовать его?»

Потому что panic — это не обычный механизм обработки ошибок.

panic("something went terribly wrong")

После panic нормальное выполнение функции прекращается и начинается раскрутка стека.

Есть ещё recover(), который может перехватить panic.

Звучит подозрительно похоже на исключения, но философия применения другая.

error или panic?

Обычная ожидаемая проблема → error

  • файл не найден;

  • пользователь ввёл неверные данные;

  • API недоступен;

  • запрос в БД завершился ошибкой;

  • сервер вернул 500;

  • JSON не распарсился.

Это нормальные ситуации, которые программа должна уметь переживать.

Программа попала в состояние, которого вообще не должно существовать → возможно panic

func MustConfig(path string)Config{
    config,err:=loadConfig(path)
    if err!=nil{
        panic(err)
    }
    return config
}

Иногда приложение действительно не имеет смысла запускать без обязательной конфигурации.

Но превращать каждую ошибку в:

panic(err)

— практически гарантированный способ превратить приложение в минное поле.

Знаменитая короткая форма

Поскольку if err != nil встречается постоянно, Go-разработчики часто совмещают вызов и проверку:

if err:=doSomething();err!=nil{
    return err
}

Вместо:

err:=doSomething()
if err!=nil{
    return err
}

Особенно удобно, если результат функции кроме ошибки не нужен.

if err:=db.Ping();err!=nil{
    return fmt.Errorf("database unavailable: %w",err)
}

Переменная err при этом существует только внутри if.

Почему Go-разработчики защищают эту систему

Когда впервые сталкиваешься с Go, выглядит это примерно так:

if err!=nil{return err}
if err!=nil{return err}
if err!=nil{return err}
if err!=nil{return err}

МОЖЕТ ХВАТИТ УЖЕ?

Но через некоторое время становится понятно, зачем это сделали.

1. Ошибки видно

Вы смотрите на код и сразу понимаете, где функция может завершиться.

2. Контроль потока очевидный

Не нужно гадать, где внезапно вылетит исключение.

3. Обработку сложнее случайно забыть

Возвращаемый error находится прямо рядом с результатом функции.

4. Обработка находится рядом с проблемой

file,err:=os.Open(path)
if err!=nil{
    // решение прямо здесь
}

5. Код становится предсказуемым

А предсказуемый код особенно ценен в больших backend-системах.

Иногда скучный код — лучший код.

Это, возможно, самый Go-шный тезис всей статьи.

Но давайте признаем: boilerplate реально есть

Да. Go часто критикуют именно за количество повторяющейся обработки ошибок.

user,err:=loadUser()
if err!=nil{return err}
orders,err:=loadOrders(user.ID)
if err!=nil{return err}
invoice,err:=createInvoice(orders)
if err!=nil{return err}
err=sendInvoice(invoice)
if err!=nil{return err}

Очень компактным такой код не назовёшь.

Но язык делает выбор в пользу: явности > магии.

И это одна из фундаментальных особенностей Go.

Как НЕ надо обрабатывать ошибки в Go

1. Игнорировать ошибку

result,_:=dangerousOperation()

Без веской причины — плохо.

2. Сравнивать текст ошибки

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

Лучше:

if errors.Is(err,ErrUserNotFound){
    // ...
}

Текст ошибки может поменяться. Тип или sentinel error — значительно надёжнее.

3. Терять исходную ошибку

return fmt.Errorf("cannot load user: %v",err)

Часто хуже, чем:

return fmt.Errorf("cannot load user: %w",err)

Потому что %w сохраняет цепочку.

4. Логировать ошибку на каждом уровне

Получаете лог-матрёшку.

Лучше добавлять контекст:

return fmt.Errorf("checkout: %w",err)

и логировать там, где запрос окончательно обрабатывается.

5. Использовать panic вместо error

if err!=nil{
    panic(err)
}

Не превращайте сервер в приложение, которое при первом неправильном JSON говорит: «ну всё, я увольняюсь».

Как выглядит хороший Go-код с ошибками

func LoadUser(id int)(*User,error){
    user,err:=repository.FindByID(id)
    if err!=nil{
        if errors.Is(err,sql.ErrNoRows){
            return nil,ErrUserNotFound
        }
        return nil,fmt.Errorf("find user %d: %w",id,err)
    }
    return user,nil
}

А выше:

user,err:=LoadUser(id)
if err!=nil{
    if errors.Is(err,ErrUserNotFound){
        http.Error(w,"user not found",http.StatusNotFound)
        return
    }
    log.Printf("load user failed: %v",err)
    http.Error(w,"internal server error",http.StatusInternalServerError)
    return
}

Здесь уже видно нормальную архитектуру:

База данных
↓
Repository
↓
Service
↓
HTTP handler
↓
Пользователь

Каждый уровень решает свою задачу.

Как быстрее привыкнуть к Go?

Самый бесполезный способ учить обработку ошибок: прочитать 40 страниц документации и решить, что вроде всё понятно.

А потом открыть редактор:

package main
func main(){
}

И десять минут смотреть на мигающий курсор.

Go хорошо учится именно через практику: написать функцию, получить error, обработать его, специально сломать входные данные и посмотреть, что произойдёт.

В Кодике как раз можно учить программирование таким способом: небольшая теория → код → задания → практика прямо во время обучения.

Так гораздо быстрее становится понятно, зачем нужны error, if err != nil, fmt.Errorf() и другие конструкции, которые в статье выглядят очевидными, а в собственном проекте внезапно превращаются в: «стоп, а сюда что возвращать?»

А если хочется регулярно повторять программирование небольшими порциями, заглядывайте и в наше Telegram-сообщество Кодика. Там выходят полезные разборы, задачи, фишки языков и материалы, которые удобно прочитать между делом и сохранить себе.

🎯Хватит откладывать

Понравилась статья?
Пора применять на практике!

В Кодик ты не просто читаешь — ты сразу пишешь код. Теория + практика = реальный скилл.

Мгновенная практика
🧠AI объяснит код
🏆Сертификат

Без регистрации • Без карты