Введение

Python — язык обманчиво простой. Мы любим его за то, что он позволяет набросать прототип за вечер: никакой тебе возни с компиляторами, строгой типизацией или управлением памятью вручную. Пишешь как думаешь, и оно работает. Кайф? Кайф.

Но у этой медали есть обратная сторона. Python прощает слишком многое. Он позволяет писать код, который выглядит рабочим, проходит базовые тесты и даже Code Review (если ревьюер устал или поторопился). Вы катите это в продакшен... а через неделю сервер ложится от переполнения памяти, или данные пользователей начинают загадочно перемешиваться.

Ошибка №1: Капкан с изменяемыми аргументами (Mutable Default Arguments)

Начнем с классики, которую любят спрашивать на собеседованиях. Казалось бы, каждый Python-разработчик знает: «Не пиши пустой список в аргументах по умолчанию!». Но знать правило и понимать физику процесса — разные вещи.

Давайте честно, у многих в проектах до сих пор проскакивает вот такой код:

def add_item_to_cart(item, cart=[]):
    cart.append(item)
    return cart

Выглядит безобидно. Логика автора проста: «Если корзину не передали, создай новую пустую и положи туда товар».

Что происходит под капотом?

Проблема в том, как Python воспринимает инструкцию def. Для интерпретатора def — это исполняемая команда. Она выполняется ровно один раз — в момент, когда Python впервые читает этот файл (момент определения функции).

Именно в этот момент Python создает объект функции и вычисляет значения аргументов по умолчанию. Он создает этот пустой список [] и «приклеивает» его к функции навсегда. Вы можете даже увидеть его своими глазами, заглянув в атрибут __defaults__:

print(add_item_to_cart.__defaults__)
# ([],) — вот он, наш список, живет внутри функции!

Каждый раз, когда вы вызываете функцию без второго аргумента, вы обращаетесь не к новому списку, а к тому самому, созданному при старте программы.

Почему это непростительно?

Локально это может не выстрелить. Вы запустили скрипт, проверили один раз — работает. Перезапустили — снова работает.

А теперь представьте, что этот код попадает в веб-сервис (Django, FastAPI — неважно). Приложение запускается и висит в памяти неделями.

  1. Приходит Юзер А, добавляет товар. Список cart внутри функции становится ['iPhone'].

  2. Приходит Юзер Б, добавляет свой товар. Он ожидает пустую корзину, но Python подсовывает ему тот же самый список-объект. Теперь в корзине ['iPhone', 'Samsung'].

Поздравляю, у вас утечка данных. Пользователи начинают видеть чужие заказы. В масштабах биллинга или банковских транзакций — это катастрофа, за которую увольняют.

Как делать правильно

Паттерн всего один — используйте None как маркер пустоты.

def add_item_to_cart(item, cart=None):
    if cart is None:
        cart = []  # Новый список создается ВНУТРИ функции при каждом вызове
    cart.append(item)
    return cart

Здесь список создается в теле функции (runtime), а не в заголовке (definition time). Каждый вызов гарантированно получает свежий, чистый объект.

Декораторы

Кстати, эта ловушка поджидает вас не только в функциях. Если вы пишете свои декораторы и инициализируете какую-то переменную в теле декоратора (а не внутри обертки wrapper), она тоже выполнится один раз при импорте модуля.

Это частая причина странных багов, когда состояние (например, кеш или счетчик вызовов) становится общим для всех задекорированных функций, хотя вы планировали их изолировать. Механика та же: код исполняется один раз при чтении файла, а «память» остается навсегда.

Ошибка №2: «Слепой» перехват, или почему ваш код бессмертен

Есть у разработчиков соблазн — сделать так, чтобы скрипт никогда не падал. Ну, знаете, это ложное чувство безопасности: «Оберну-ка я всё в try-except, и если что-то пойдет не так, программа просто пойдет дальше».

Выглядит это обычно так:

try:
    complex_data_processing()
    save_to_db()
except:  # Или except Exception:
    pass

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

1. Вы создаете зомби, которого нельзя убить

Конструкция except: (без указания типа исключения) перехватывает всё. Вообще всё. Включая системные сигналы.

Когда вы нажимаете Ctrl+C в терминале, Python генерирует KeyboardInterrupt. Когда система просит процесс завершиться, летит SystemExit. Ваш «слепой» except радостно ловит эти сигналы и говорит: «Не, мы работаем дальше». В итоге скрипт превращается в неуправляемый процесс, который приходится убивать через kill -9.

2. Вы прячете реальные баги

Представьте, что внутри блока try вы допустили опечатку. Написали usre_id вместо user_id. Python честно кидает NameError.
Но ваш except ловит его и... просто молчит. Программа работает дальше, логика ломается, данные не сохраняются, а вы смотрите в консоль и не понимаете, что происходит. Ошибок нет, но ничего не работает. Дебажить такое — сущий ад.

Реальный кейс из жизни

Однажды нам пришлось разбирать инцидент. Сервис обработки платежей неделю (!) работал «вхолостую».
Пользователи нажимали «Оплатить», сайт говорил «Спасибо», но деньги не списывались, а заказы не создавались. Техподдержка молчала, потому что мониторинг был зеленый. Ошибок в логах — ноль.

Оказалось, джуниор обернул коннект к базе данных в такой вот try-except pass. В какой-то момент пароль от базы сменили. Скрипт просто перестал подключаться к БД, молча проглатывал ошибку OperationalError и шел дальше, делая вид, что всё хорошо. Неделя потерянной выручки из-за четырех строк кода.

Как делать правильно

Правило написано: перехватывайте только то, что ожидаете.

Если вы боитесь ошибки сети — ловите ConnectionError. Боитесь кривого JSON — ловите JSONDecodeError.

import logging

try:
    save_to_db(data)
except (ConnectionError, TimeoutError) as e:
    # Мы знаем, что это может случиться, и знаем, как реагировать
    logging.error(f"База недоступна, пробуем повторить: {e}")
    retry_later(data)
except Exception as e:
    # На самый крайний случай — ловим всё остальное, НО обязательно логируем
    logging.exception("Произошла непредвиденная ошибка!")
    raise  # И пробрасываем дальше, чтобы приложение всё-таки упало (или уведомило Sentry)

Запомните: упавшее приложение лучше, чем приложение, которое молча врет, что работает.

Ошибка №3: Синхронный код в асинхронном (Sync in Async)

Сейчас все бегут на FastAPI и aiohttp. Это модно, молодежно и производительно. Выучить синтаксис несложно: приписал async перед def, await перед вызовом — и ты великолепен.

Но именно здесь кроется самая коварная ловушка для тех, кто переезжает с Django или Flask. Старые привычки умирают тяжело, и в коде появляется вот это:

import requests
import time

@app.get("/process")
async def process_data():
    # "Ну мне же надо просто дернуть стороннюю API..."
    response = requests.get("https://slow-service.com/api")
    
    # "...или подождать пару секунд"
    time.sleep(5)
    
    return {"status": "ok"}

Механика: Почему всё встало?

Чтобы понять, почему этот код — преступление, нужно вспомнить, как работает Event Loop (цикл событий).

Представьте, что ваш сервер — это один-единственный официант в ресторане (поток). В асинхронном режиме он работает гениально: принял заказ у столика №1, отдал на кухню, и пока там готовят, побежал принимать заказ у столика №2. Он не стоит и не ждет. Благодаря этому Python на одном ядре может держать тысячи соединений.

Но когда вы пишете requests.get(...) или time.sleep(...) внутри async def, вы заставляете этого официанта встать и ждать, пока повара пожарят котлету. Он не принимает новые заказы, он не отдает готовые блюда. Он просто стоит.

В этот момент ваш сервер «умирает» для всех остальных пользователей. Если запрос длится 5 секунд, то в течение 5 секунд сервер не ответит никому. Даже тем, кто просто запросил главную страницу. Health-check'и от Kubernetes начнут падать, балансировщик решит, что инстанс мертв, и начнет его перезагружать.

Почему это непростительно?

Потому что это убивает саму суть асинхронности. Один «тяжелый» синхронный запрос способен положить высоконагруженный сервис. Вы хотели производительности, а получили однопоточный скрипт, работающий хуже, чем старый добрый Flask на тредах.

Как делать правильно

  1. Сетевые запросы (I/O):
    Забудьте про requests. Вообще. Если вы в асинхронном мире, ваши друзья — httpx или aiohttp. Для работы с базой — asyncpg или Motor.

    import httpx
    
    async with httpx.AsyncClient() as client:
        resp = await client.get("https://...") # Официант отдал заказ и ушел работать дальше
    
  2. Тяжелые вычисления (CPU-bound):
    Если вам нужно обработать картинку, посчитать хэш или распарсить гигантский JSON, await вам не поможет (это нагрузка на процессор, а не ожидание сети).
    Такие задачи нужно выбрасывать из Event Loop'а:

    • Вариант "Быстро и грязно": Запустить в отдельном потоке через run_in_executor:

      loop = asyncio.get_running_loop()
      # Выполняем синхронную функцию отдельно
      await loop.run_in_executor(None, heavy_function, arg1) 
      
    • Вариант "По-взрослому": Отправить задачу в очередь (Celery, TaskIQ, Dramatiq) и пусть её жуют отдельные воркеры. Главный сервер должен только принимать запросы.

Ошибка №4: Магия замыканий, или почему все ваши функции делают одно и то же

А вот здесь сыпятся даже опытные ребята. Это тонкий момент работы с областями видимости (scope), который выглядит как настоящий полтергейст.

Допустим, вам нужно динамически сгенерировать список функций. Например, создать 5 множителей: первый умножает на 0, второй на 1, и так далее. Вы пишете элегантный list comprehension:

# Создаем список функций: lambda x: x * 0, lambda x: x * 1 ...
multipliers = [lambda x: x * i for i in range(5)]

# Проверяем. Ожидаем: 2 * 0 = 0, 2 * 1 = 2 ...
results = [m(2) for m in multipliers]

Вы ожидаете увидеть [0, 2, 4, 6, 8].
Запускаете код.
Получаете: [8, 8, 8, 8, 8].

В этот момент хочется разбить монитор. Почему они все умножают на 4 (последнее значение i)? Вы же создавали их в цикле, когда i была разной!

Механика: Позднее связывание (Late Binding)

Проблема в том, как лямбда (или любая вложенная функция) «видит» внешние переменные.

В Python замыкания работают по ссылке, а не по значению. Когда вы пишете lambda x: x * i, функция не запоминает: «Ага, сейчас i равна нулю, запомню ноль». Она запоминает: «Мне нужно умножить x на переменную по имени i из внешней области видимости».

Функция ленива. Она не ищет значение i в момент создания. Она лезет за ним только в момент вызова.
А когда вы наконец вызываете эти функции (строкой ниже), цикл for уже давно закончился. Переменная i в глобальной области осталась висеть со значением 4. И все 5 ваших лямбд радостно лезут в одну и ту же переменную и видят там четверку.

Почему это непростительно?

Это логическая ошибка. Код валиден, ошибок нет, но логика работает совершенно не так, как задумано.
Особенно больно это бьет при создании UI-интерфейсов (например, в Tkinter или PyQt) или асинхронных колбэков, когда вы в цикле вешаете обработчики событий на кнопки:
Button 1, Button 2, Button 3...
И какую бы кнопку ни нажал юзер, срабатывает обработчик для последней.

Как делать правильно

Нам нужно заставить Python «заморозить» текущее значение i прямо в момент создания функции. Помните Ошибку №1 про аргументы по умолчанию? Там это было злом, а здесь — наше спасение.

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

# Передаем i как аргумент по умолчанию
multipliers = [lambda x, i=i: x * i for i in range(5)]

Что здесь происходит:
При каждой итерации создается лямбда, у которой есть своя собственная локальная переменная i. Её значение берется из внешней i прямо сейчас (в момент создания) и «запекается» внутрь функции.
Теперь, даже когда внешняя i изменится, внутренняя локальная i останется той, какой была при рождении.

Ошибка №5: Игры со временем (Naive Datetime)

Если вы думаете, что время — это просто часы и минуты, вы ещё не дебажили биллинг в ночь перевода часов на зимнее время.

Новички (да и не только) обожают писать так:

from datetime import datetime

# "Просто дай мне текущее время"
created_at = datetime.now() 
# Результат: 2023-10-05 14:30:00 (и никакой информации о часовом поясе)

В документации такие объекты называют Naive (наивные). Они не знают, где они находятся: в Москве, Нью-Йорке или на Гринвиче. И это главная причина головной боли при масштабировании.

В чем проблема?

  1. Географический винегрет
    Представьте цепочку:

    • Ваш ноутбук в Москве (UTC+3).

    • Сервер приложения в Ирландии (UTC+0 или +1).

    • База данных (Postgres) настроена админом по дефолту в UTC.

    Вы сохраняете datetime.now() с сервера. В базу падает 14:00. Потом вы читаете это время скриптом с ноутбука, Python думает, что это локальное время (МСК). В итоге событие «улетает» на 3 часа в будущее или прошлое. Вы никогда не узнаете, когда на самом деле произошла ошибка.

  2. Ад с переводом часов (DST)
    В странах, где переводят часы, есть «волшебный» час осенью, когда время отматывается назад.
    Было 02:59, стало 02:00.
    Если вы используете Naive time, у вас в логах появятся две записи с меткой 02:30. Какая была раньше? Какая позже? Порядок событий восстановить невозможно. Для финансовых транзакций это приговор.

Почему это непростительно?

Потому что исправить это задним числом почти невозможно. Если у вас в базе лежит миллион записей с датой 2023-10-05 10:00:00 без таймзоны, вы не знаете: это 10 утра по Лондону или по Токио? Ваша аналитика врет, графики врут, а пользователи жалуются, что подписка истекла на день раньше.

Как делать правильно

Золотое правило бэкенда: Внутри системы всегда UTC. Локальное время — только для отображения пользователю.

Нам нужны Aware (осведомленные) объекты.

from datetime import datetime, timezone

# ПРАВИЛЬНО: Берем время сразу с привязкой к UTC
now_utc = datetime.now(timezone.utc)
# Результат: 2023-10-05 14:30:00+00:00 (хвост +00:00 важен!)
  1. Всегда создавайте даты с timezone.utc.

  2. В базе данных используйте тип TIMESTAMP WITH TIME ZONE (в Postgres).

  3. Если нужно показать время пользователю из Владивостока — конвертируйте UTC в его часовой пояс в самый последний момент (на фронтенде или в шаблонизаторе).

Вместо заключения

Python — дружелюбный язык. Он дает вам заряженный пистолет и не спрашивает, умеете ли вы стрелять.

  • Изменяемые дефолтные аргументы.

  • Except без указания типа.

  • Синхронный код в async.

  • Замыкания в циклах.

  • Naive datetime.

Это не просто «плохой стиль». Это баги, которые стоят денег. Проверьте свой проект прямо сейчас. Если нашли что-то из списка — вы знаете, чем заняться в ближайший спринт.

Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.

Пишите чистый код, и да пребудет с вами Zen of Python.

(P.S. Расскажите в комментариях, какой самый глупый баг ронял ваш прод? Мой фаворит — rm -rf не в той папке, но это уже совсем другая история).