Введение

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 не в той папке, но это уже совсем другая история).