Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Unicode и кодировки в Python без боли

Unicode в Python: кодировки, mojibake и правила работы с текстом

Как перестать бояться кодировок в Python: Unicode против байтов, encode/decode, UTF-8 как дефолт, cp1251 и BOM, ошибки UnicodeDecodeError — диагностика и правила границ.

Прямой ответ

Модель, которая снимает 95% проблем: текст в Python — это str (последовательность символов Unicode, кодировки у него нет); кодировка появляется только на границах — при чтении байтов из внешнего мира (bytes.decode("utf-8")) и записи наружу (str.encode("utf-8")). Все баги «кракозябр» сводятся к двум ошибкам: байты декодированы не той кодировкой (cp1251 как utf-8) или кодировка на границе не указана и взята «по умолчанию системы» (на Windows — cp1251/cp1252, а не UTF-8). Правила: на границах — явная кодировка (open(..., encoding="utf-8") всегда), внутри — только str, а UTF-8 — дефолт всего нового (PEP 686 делает его дефолтом и в стандартном режиме Python).

str против bytes: где живёт кодировка

"привет" — пять символов (кодовых точек), ни в какой кодировке не хранится; b"\xd0\xbf..." — байты, и их смысл определяет только кодировка. Операции смешения (str + bytes) Python запрещает — и это защита, а не неудобство: каждая конвертация явная (encode/decode). Классический баг новичка — двойное кодирование: s.encode("utf-8").decode("utf-8") там, где строка уже str (бессмыслица), или s.encode("cp1251").decode("utf-8") (UnicodeDecodeError или mojibake). Диагностика mojibake механическая: «Ð¿Ñ€Ð¸Ð²ÐµÑ‚» — это UTF-8-байты, показанные как Latin-1/cp1252 (лечится s.encode("cp1252").decode("utf-8")); «ï»½Ñ€Ð¸Ð²ÐµÑ‚» — UTF-8 как cp1251; «?????» — безвозвратная потеря (символы не сконвертировались при кодировании в узкую кодировку с заменой).

Границы ввода-вывода: файлы, сеть, консоль

Файлы: open(path, encoding="utf-8") — всегда явно; без параметра Python берёт локаль системы (на Windows — cp1251 — и ваш «работает на Mac» скрипт ломается у коллеги; PEP 686/UTF-8 mode меняют дефолт, но полагаться на режим вместо явного параметра — привычка хуже). Чтение чужого файла неизвестной кодировки: попытка utf-8 → по ошибке пробуем cp1251 (для русских текстов) → детектор charset-normalizer как последняя мера (эвристика, не гарантия). BOM (\ufeff в начале файла из Excel/Windows): лечится кодировкой utf-8-sig при чтении (BOM съедается) — «невидимый символ в первом ключе JSON» обычно он. Сеть: HTTP-ответ — байты; response.encoding угадывается из заголовков/HTML и часто врёт — при кракозябрах в веб-скрейпинге проверяйте фактическую кодировку мета-тега (механика HTTP — в статье про клиенты). Консоль/логи: Windows-терминал с cp866 против UTF-8-лога — отдельный слой; современные терминалы и PYTHONIOENCODING=utf-8 решают.

Практические сценарии из жизни

CSV из Excel с кириллицей: почти всегда cp1251 (или utf-8-sig, если «сохранить как CSV UTF-8»); при выгрузке для Excel — UTF-8 с BOM (encoding="utf-8-sig"), иначе Excel покажет кракозябры (форматы файлов — в статье про CSV/parquet). Логи с grep/jq: утилиты работают с байтами и обычно прозрачны для UTF-8; проблемы начинаются на cp1251-логах — конвертируйте источник (iconv -f cp1251 -t utf-8) до обработки (приёмы разобраны в статье про обработку логов). Имена файлов/URL: процент-кодирование URL — urllib.parse.quote/unquote (UTF-8 по стандарту); NFD против NFC в macOS-именах — unicodedata.normalize("NFC", s) перед сравнением. len() и срезы: len("приве́т") считает кодовые точки, не «буквы» (комбинируемые ударения) и не байты; для «пользовательских символов» — графемные кластеры (библиотеки вроде grapheme), но 99% кода достаточно кодовых точек.

Обработка ошибок кодирования: errors=

decode/encode принимают стратегию ошибок: strict (дефолт — исключение: правильно для данных, которые надо починить у источника), replace («» — для логов/отображения, где важнее не упасть), ignore (тихая потеря — почти всегда плохая идея), backslashreplace (для отладки). Правило: в пайплайнах данных — strict и разбор источника; в отображении — replace. «Починить» битую кодировку автоматически на проде — иллюзия: чинится источник, иначе каждая следующая выгрузка приедет битой.

Кодировки в экосистеме: короткая карта

JSON — текст UTF-8 по стандарту (и ensure_ascii=False для читаемой кириллицы — детали в статье про сериализацию); Python-исходники — UTF-8 по умолчанию (PEP 3120, coding-строки из Python 2 не нужны); git — UTF-8 в именах/диффах (core.quotepath=false для читаемых имён); БД — UTF-8 в схеме/соединении (в PostgreSQL — на уровне createdb); терминал — UTF-8. Правило большого пальца 2026: любой новый артефакт создаётся в UTF-8, любая старая система, отдающая cp1251, конвертируется на границе один раз — и дальше весь конвейер живёт в Unicode.

Практический сценарий: разбор «кракозябр» из чужой выгрузки

Приходит CSV от контрагента, и в редакторе вместо названий — «ÐœÐ¾Ñква». Разбор по шагам, который работает почти всегда. Диагностика: открываем файл побайтово (open(path, 'rb').read(100)) — видим последовательности вроде d0 9c d0 be — это UTF-8-байты кириллицы; «Ðœ» на экране означает, что просмотрщик декодировал их как Latin-1/cp1252. Значит, файл в UTF-8, и правильный ответ — читать его в UTF-8, а не «чинить» текст. Второй случай: байты cc f1 ea — это cp1251-кодирование «мск»; читали как UTF-8, получили ошибку или замену. Лечим открытием в правильной кодировке и сразу пересохраняем навсегда: data = Path(src).read_text(encoding="cp1251"); Path(dst).write_text(data, encoding="utf-8") — дальше весь конвейер живёт в UTF-8, а cp1251 существует только в одной строке кода на границе. Третий случай — необратимый: в файле буквально «?????» — символы потеряны при кодировании в узкую кодировку с заменой; восстановлению не подлежит, чинить нужно источник выгрузки (требовать UTF-8 — разумное и почти всегда выполнимое требование в 2026-м). И профилактика: в спецификации обмена фиксируем «UTF-8, разделитель — точка с запятой, даты ISO» — одна строка в письме экономит часы разборов в будущем.

FAQ

Почему UnicodeDecodeError, если «файл текстовый»? Потому что «текстовый» не означает «UTF-8»: файл в cp1251/latin-1 не декодируется как UTF-8. Откройте с явной кодировкой источника или определите её (charset-normalizer), затем конвертируйте в UTF-8 навсегда.

Что такое UTF-8 «коротко»? Переменное кодирование кодовых точек Unicode в байты: ASCII — 1 байт (полная совместимость), кириллица — 2, иероглифы — 3. Де-факто стандарт веба и дефолт новых систем.

Нужно ли хранить строки «в кодировке» внутри программы? Нет: внутри — str; кодировка — свойство границ. Хранение «байтов с подразумеваемой кодировкой» внутри — источник всех mojibake.

Как проверить, что весь проект консистентен? Сканы: rg -I --files | xargs file -i (кодировки файлов), тесты на границах чтения/записи с явными кодировками, CI-переменная UTF-8-локали; и политика «encoding= явный в каждом open».

Источники: Unicode HOWTO (docs.python.org), PEP 686, документация codecs — ссылки в шапке.