UnicodeDecodeError در پایتون وقتی پرتاب می‌شود که بخواهید دنباله‌ای از بایت‌ها را با یک طرح انکودینگ مشخص به متن تبدیل کنید، ولی آن بایت‌ها با آن طرح سازگار نباشند. این خطا در ظاهر ساده به نظر می‌رسد، ولی ریشه‌اش اغلب در ناسازگاری میان انتظار توسعه‌دهنده و واقعیت فایل، دیتابیس یا پاسخ شبکه است. در این مقاله، تجربه‌ام از دیباگ ده‌ها پروندهٔ واقعی این خطا در پروژه‌های داده، وب و پردازش متن فارسی را با شما به اشتراک می‌گذارم.

UnicodeDecodeError چیست و از کجا می‌آید؟

UnicodeDecodeError یکی از زیرکلاس‌های UnicodeError است که وقتی پرتاب می‌شود که کد پایتون تلاش کند بایت‌ها را با یک انکودینگ مشخص به رشته تبدیل کند و ترجمه ممکن نباشد. این خطا از ValueError ارث می‌برد و ساختار ارث‌بری آن به این شکل است:

BaseException
 └── Exception
      └── ValueError
           └── UnicodeError
                ├── UnicodeDecodeError
                └── UnicodeEncodeError

نکتهٔ کلیدی این است که UnicodeDecodeError در فرآیند رمزگشایی (decode) رخ می‌دهد، یعنی وقتی می‌خواهید از bytes به str بروید. معادل آن در جهت مخالف، UnicodeEncodeError است که در تبدیل str به bytes رخ می‌دهد. تفکیک این دو در ذهن، نیمی از دیباگ است، چون راه‌حل‌ها کاملاً متفاوتند.

پیام خطا معمولاً اطلاعات دقیقی می‌دهد که چطور از آن استفاده کنیم:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 42: invalid start byte

سه بخش مهم در این پیام وجود دارد: نام انکودینگ درگیر (utf-8)، مقدار بایتی که قبول نشد (0xff)، و موقعیت دقیق در دنبالهٔ بایت‌ها (position 42). این سه، ابزار اولیه دیباگ شما هستند. مفهوم کلی یونیکد و انکودینگ‌های مختلف در ویکی‌پدیا ذیل Unicode به‌تفصیل توضیح داده شده است.

UnicodeDecodeError نمی‌گوید «این بایت‌ها اشتباه‌اند»؛ می‌گوید «این بایت‌ها با انکودینگی که انتخاب کرده‌اید سازگار نیستند». تفاوت این دو نگاه، تمام تفاوت میان حدس‌زدن و تشخیص درست است.

در پایتون ۳، متن و بایت از هم جدا شده‌اند و این جدا‌سازی به‌عنوان یکی از بهترین تصمیمات طراحی این نسخه شناخته می‌شود. اما همین جدا‌سازی، مسئولیت جدیدی روی دوش توسعه‌دهنده می‌گذارد: هر بار که از مرز بایت به متن عبور می‌کنید، باید انکودینگ درست را انتخاب کنید. برای درک چارچوب گسترده‌تر، مدیریت خطا در پایتون و آموزش پایتون از صفر را پیشنهاد می‌کنم.

تفاوت بنیادی str و bytes در پایتون ۳

برای درک عمیق UnicodeDecodeError، باید دو نوع دادهٔ اصلی پایتون ۳ را بشناسید. str دنباله‌ای از code pointهای یونیکد است؛ یعنی هر کاراکتر یک شناسهٔ عددی دارد. bytes دنباله‌ای از بایت‌های خام است (هر بایت بین ۰ تا ۲۵۵). این دو کاملاً متفاوتند و پایتون ۳ اجازه نمی‌دهد آن‌ها را مستقیم با هم جمع کنید:

>>> "سلام" + b"world"
TypeError: can only concatenate str (not "bytes") to str

>>> "سلام".encode("utf-8")
b'\xd8\xb3\xd9\x84\xd8\xa7\xd9\x85'

>>> b'\xd8\xb3\xd9\x84\xd8\xa7\xd9\x85'.decode("utf-8")
'سلام'

این جدایی، یکی از مهم‌ترین دستاوردهای پایتون ۳ است. در پایتون ۲، str هم‌زمان نقش متن و بایت را داشت و همین باعث باگ‌های بی‌شماری می‌شد که هنوز در پروژه‌های قدیمی دیده می‌شوند. اگر این جدایی را درونی نکنید، در هر مرحله از پردازش داده، با یک UnicodeDecodeError تازه روبرو خواهید شد.

سه قانون عملی که در ذهن دارم:

  • مرزهای برنامه را بشناسید. از فایل، دیتابیس، شبکه و API، داده همیشه به‌صورت bytes می‌آید. از لایهٔ منطق تجاری، همیشه str است. تبدیل در مرز انجام می‌شود، نه در وسط.
  • در معرض، همیشه encoding را صریح بدهید. در پایتون ۳، open() و str.encode() و bytes.decode() همه پارامتر encoding می‌پذیرند. صریح نوشتن، نیمی از باگ‌ها را از ابتدا حذف می‌کند.
  • هیچ‌وقت به default اعتماد نکنید. پایتون در محیط‌های مختلف، default متفاوتی انتخاب می‌کند (UTF-8 در لینوکس مدرن، cp1252 در Windows Server، utf-8-sig در فایل‌های CSV اکسل). همین تنوع، منشأ باگ‌های «روی سیستم من کار می‌کند» است.

مفهوم code point و تفاوت آن با کاراکتر گرافیکی، در ویکی‌پدیا ذیل Code point توضیح داده شده است و خواندن آن، برای درک عمیق‌تر این حوزه مفید است. برای آشنایی با الگوهای کار با فایل‌ها و مرزهای داده، کار با فایل‌ها در پایتون مرجع مکمل خوبی است.

چرا UTF-8 پیش‌فرض همیشه کافی نیست؟

در پایتون ۳٫۱۵، انکودینگ پیش‌فرض برای open() در لینوکس و macOS به UTF-8 تغییر کرد (پیش از آن، به locale.getpreferredencoding() وابسته بود). این تغییر خوب است، ولی کافی نیست. UTF-8 پیش‌فرض فقط در صورتی کار می‌کند که دادهٔ شما واقعاً با UTF-8 ذخیره شده باشد. در دنیای واقعی، سه سناریو شایع است که این پیش‌فرض شکست می‌خورد:

سناریوی اول: فایل‌های قدیمی با کدپیج‌های محلی

فایل‌های متنی قدیمی، مخصوصاً آن‌هایی که از ویندوز یا سیستم‌های قدیمی می‌آیند، اغلب با کدپیج‌هایی مثل windows-1256 (برای عربی و فارسی)، cp1252 (برای اروپای غربی)، یا iso-8859-1 ذخیره شده‌اند. هر کدام از این‌ها، بازهٔ کاراکتری محدودی دارند و اگر با UTF-8 رمزگشایی شوند، UnicodeDecodeError می‌دهند.

# فرض کنید فایلی با windows-1256 ذخیره شده
with open("old_data.txt", "r", encoding="utf-8") as f:
    content = f.read()  # UnicodeDecodeError

# راه‌حل: encoding درست
with open("old_data.txt", "r", encoding="windows-1256") as f:
    content = f.read()  # OK

سناریوی دوم: فایل‌های CSV اکسل

اکسل هنگام ذخیره به‌صورت CSV، به‌طور پیش‌فرض از کدپیج سیستم استفاده می‌کند، نه UTF-8. نتیجه: فایل‌های CSV با حروف فارسی در ویندوز، اغلب با cp1256 ذخیره می‌شوند. اگر با pandas.read_csv بدون تعیین encoding بخوانید، خطا می‌گیرید:

import pandas as pd

# اشتباه
df = pd.read_csv("report.csv")  # احتمالاً UnicodeDecodeError

# درست: چند گزینه را امتحان کنید
for enc in ("utf-8-sig", "windows-1256", "cp1256"):
    try:
        df = pd.read_csv("report.csv", encoding=enc)
        print(f"loaded with {enc}")
        break
    except UnicodeDecodeError:
        continue

الگوی BOM (utf-8-sig) مخصوص فایل‌هایی است که اکسل با آن‌ها ذخیره می‌کند و در ابتدای فایل سه بایت اضافه دارد. این سه بایت، اگر با UTF-8 ساده خوانده شوند، یک کاراکتر نامرئی در ابتدای متن تولید می‌کنند.

سناریوی سوم: داده‌های ترکیبی

در pipelineهای داده، گاهی بخشی از فایل UTF-8 و بخشی کدپیج دیگر است. این حالت در لاگ‌های سرور، داده‌های استخراج‌شده از منابع مختلف، و پروژه‌های وب اسکرپینگ با پایتون زیاد دیده می‌شود. راه‌حل: خواندن در حالت باینری و مدیریت جداگانه:

with open("mixed.log", "rb") as f:
    for line in f:
        try:
            text = line.decode("utf-8")
        except UnicodeDecodeError:
            text = line.decode("cp1252", errors="replace")
        process(text)

این الگو، انعطاف‌پذیری بالایی می‌دهد و به‌جای شکست کامل، بهترین تلاش را انجام می‌دهد.

هشت سناریوی واقعی که این خطا را می‌سازند

در طول سال‌ها کار با پایتون، UnicodeDecodeError را در این هشت الگو دیده‌ام. شناختن هر الگو، تشخیص را چند برابر سریع‌تر می‌کند.

سناریوی اول: خواندن فایل بدون encoding

رایج‌ترین الگوی خطا، فراموشی پارامتر encoding در open() است:

# اشتباه
with open("data.txt") as f:
    content = f.read()

# درست
with open("data.txt", encoding="utf-8") as f:
    content = f.read()

در پایتون ۳٫۱۵ به بعد، در لینوکس و macOS این پیش‌فرض UTF-8 شد، ولی در ویندوز همچنان به locale وابسته است. یک قاعدهٔ سرانگشتی: همیشه encoding را صریح بنویسید، حتی اگر فکر می‌کنید پیش‌فرض درست است.

سناریوی دوم: خواندن پاسخ HTTP بدون charset

در requests، اگر پاسخ سرور هدر Content-Type با charset نداشته باشد، کتابخانه ممکن است انکودینگ را از بایت‌ها حدس بزند و اشتباه کند:

import requests

response = requests.get("https://example.com/page")
# ممکن است به‌طور خودکار انکودینگ را اشتباه تشخیص دهد
text = response.text  # ممکن است UnicodeDecodeError بدهد

# راه‌حل: صریح
text = response.content.decode("utf-8", errors="replace")

در پروژه‌های scraping، این خطا بسیار شایع است. همیشه response.content را با decode صریح مدیریت کنید.

سناریوی سوم: خواندن نتیجهٔ subprocess

خروجی فرآیندهای خط فرمان، بسته به locale سیستم، انکودینگ متفاوتی دارد:

import subprocess

# اشتباه
result = subprocess.run(["ls"], capture_output=True, text=True)
# ممکن است UnicodeDecodeError بدهد

# درست: خواندن بایت و decode صریح
result = subprocess.run(["ls"], capture_output=True)
text = result.stdout.decode("utf-8", errors="replace")

این الگو مخصوصاً در سرورهای با locale غیر-UTF-8 رایج است. الگوهای مشابه در خطای OSError در پایتون هم پوشش داده شده است.

سناریوی چهارم: اتصال به دیتابیس MySQL با charset اشتباه

در MySQL، اگر charset اتصال با charset جداول ناسازگار باشد، خطا می‌دهد:

import mysql.connector

conn = mysql.connector.connect(
    host="localhost",
    user="user",
    password="pass",
    database="mydb",
    charset="utf8mb4",  # نه utf8
    collation="utf8mb4_unicode_ci",
)

نکتهٔ ظریف: در MySQL، utf8 فقط ۳ بایت را پشتیبانی می‌کند و برای emoji و برخی کاراکترهای چینی ناکافی است. همیشه از utf8mb4 استفاده کنید. اطلاعات بیشتر در اتصال پایتون به MySQL آمده است.

سناریوی پنجم: خواندن JSON با BOM

فایل‌های JSON که با Notepad ویندوز یا ابزارهای خاص ذخیره می‌شوند، ممکن است BOM داشته باشند:

import json

# اشتباه
with open("data.json") as f:
    data = json.load(f)  # ممکن است خطا بدهد

# درست
with open("data.json", encoding="utf-8-sig") as f:
    data = json.load(f)

انکودینگ utf-8-sig BOM را در ابتدای فایل به‌طور خودکار حذف می‌کند. برای لاگ‌های JSON که از منابع مختلف می‌آیند، این یک تنظیم ضروری است.

سناریوی ششم: پردازش لاگ‌های سرور

لاگ‌های Apache و Nginx ممکن است شامل درخواست‌های HTTP با بایت‌های غیرمجاز باشند. اگر با کد ساده پیمایش کنید، خطا می‌گیرید:

# اشتباه
with open("access.log") as f:
    for line in f:
        parse(line)

# درست
with open("access.log", encoding="utf-8", errors="replace") as f:
    for line in f:
        parse(line)

پارامتر errors="replace" بایت‌های ناسازگار را با کاراکتر جانشین (�) جایگزین می‌کند، بدون شکست کل عملیات. این تکنیک در پردازش لاگ‌های حجیم، تفاوت بین موفقیت و شکست است.

سناریوی هفتم: کار با zip و فایل‌های فشرده

در فایل‌های zip، نام فایل‌ها ممکن است با انکودینگ غیر-UTF-8 ذخیره شده باشند:

import zipfile

with zipfile.ZipFile("data.zip") as z:
    for info in z.infolist():
        # نام فایل ممکن است ناسازگار باشد
        name = info.filename  # ممکن است بد باشد
        # راه‌حل در برخی موارد: خواندن flag_bits
        if info.flag_bits & 0x800:
            # UTF-8 است
            pass

پارامتر flag_bits با bit 0x800 مشخص می‌کند که نام فایل UTF-8 است یا کدپیج محلی. این جزئیات در پردازش آرشیوهای ناهمگون مهم است.

سناریوی هشتم: خواندن فایل‌های CSV با pandas

pandas در حالت پیش‌فرض، انکودینگ را به موتور CSV می‌سپارد. در ویندوز، این انکودینگ معمولاً cp1252 است و برای فارسی کافی نیست:

import pandas as pd

# اشتباه
df = pd.read_csv("persian_data.csv")

# درست
df = pd.read_csv("persian_data.csv", encoding="utf-8")
# یا اگر فایل اکسل است:
df = pd.read_csv("persian_data.csv", encoding="utf-8-sig")

در پروژه‌هایی که با داده‌های فارسی سروکار دارند، این تنظیم را همیشه صریح قرار دهید. جزئیات بیشتر در کتابخانه pandas در پایتون آمده است.

سناریوهای مشابه در خطاهای مربوط به داده‌های ورودی، در خطای ValueError در پایتون هم پوشش داده شده است.

روش تشخیص در پنج گام

در برخورد با UnicodeDecodeError، پروتکل زیر را در پروژه‌های خودم اجرا می‌کنم. در بیشتر پرونده‌ها، گام دوم یا سوم مقصر را روشن می‌کند.

گام اول: خواندن دقیق پیام خطا

پیام خطا همیشه سه بخش کلیدی دارد که باید استخراج شوند:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 42: invalid start byte
  • انکودینگ درگیر: utf-8. این انکودینگی است که شما انتخاب کرده‌اید، نه لزوماً انکودینگ اصلی فایل.
  • بایت مشکل‌دار: 0xff. مقدار عددی بایتی که رمزگشایی نشد. این بایت معمولاً در کدپیج‌های قدیمی، کاراکتر معناداری است ولی در UTF-8 معتبر نیست.
  • موقعیت: position 42. اگر فایل کوچک است، همان خط را باز کنید و بایت ۴۲ام را با یک hex editor ببینید.

این سه اطلاعات، معمولاً به تشخیص انکودینگ اصلی کمک می‌کند. برای نمونه، اگر بایت مشکل‌دار 0xff است و در text استفاده از حروف فارسی می‌بینید، احتمالاً فایل با windows-1256 ذخیره شده است.

گام دوم: مشاهدهٔ بایت‌های خام

ابزار hex editor یا خواندن در حالت باینری به شما دید دقیقی می‌دهد:

with open("data.txt", "rb") as f:
    raw = f.read(200)
    print(raw[:200])
    print(raw[:20].hex())

در لینوکس، xxd یا hexdump هم می‌توانند مفید باشند:

$ xxd -l 200 data.txt
00000000: d8b3 d984 d8a7 d985 200a ... ............

اگر بایت‌های ابتدایی با d8 یا d9 شروع شوند، احتمالاً UTF-8 است. اگر با ef bb bf شروع شوند، BOM است. اگر بایت‌های اول در بازهٔ ۰x80 تا ۰xFF باشند، احتمالاً کدپیج محلی است.

گام سوم: امتحان چند انکودینگ

الگوی تشخیص سریع: چند انکودینگ رایج را امتحان کنید:

ENCODINGS = ["utf-8", "utf-8-sig", "windows-1256", "cp1252", "iso-8859-1"]

def try_encodings(path):
    with open(path, "rb") as f:
        raw = f.read(10_000)
    for enc in ENCODINGS:
        try:
            text = raw.decode(enc)
            print(f"OK with {enc}: {text[:100]!r}")
        except UnicodeDecodeError:
            continue

در پروژه‌های خودم، وقتی نمی‌دانم فایل با چه انکودینگی ذخیره شده، این اسکریپت را اجرا می‌کنم. در ۸۰٪ موارد، یکی از این پنج گزینه جواب می‌دهد.

گام چهارم: استفاده از chardet یا charset-normalizer

کتابخانه‌های تشخیص خودکار انکودینگ، در موارد سخت کمک می‌کنند:

import chardet

with open("unknown.txt", "rb") as f:
    raw = f.read()

result = chardet.detect(raw)
print(result)  # {'encoding': 'Windows-1256', 'confidence': 0.85, 'language': 'Arabic'}

نکتهٔ مهم: این کتابخانه‌ها حدس می‌زنند، نه این‌که تشخیص قطعی بدهند. اگر confidence پایین باشد (کمتر از ۰٫۷)، احتمالاً حدس اشتباه است. همیشه نتیجه را با یکی دو پاراگراف از متن بررسی کنید.

گام پنجم: بررسی منبع داده

اگر داده از دیتابیس یا API می‌آید، قبل از هر چیز منبع را بررسی کنید:

# در MySQL
SELECT @@character_set_database, @@collation_database;
SHOW VARIABLES LIKE "character_set%";

# در PostgreSQL
SHOW server_encoding;
SHOW client_encoding;

در ۹۰٪ پرونده‌های دیتابیسی، تنظیم charset منبع، خطا را حل می‌کند. برای اطلاعات بیشتر دربارهٔ رفتار دیتابیس، رفع خطای MySQL server has gone away نکات مرتبط را ارائه می‌دهد.

اگر خطا در لایه‌های پایین‌تر سیستمعامل هم رخ دهد، مقاله‌های خطای PermissionError در پایتون و خطای MemoryError در پایتون تفکیک این لایه‌ها را روشن می‌کنند.

پارامتر errors: پنج گزینه و کاربردشان

تابع decode() و پارامتر encoding در open()، هر دو پارامتر errors را می‌پذیرند که تعیین می‌کند در برخورد با بایت نامعتبر چه رفتاری انجام شود. پنج گزینهٔ اصلی وجود دارد و انتخاب درست، بستگی به هدف شما دارد:

گزینهرفتارکاربرد
strictUnicodeDecodeError پرتاب می‌شودپیش‌فرض؛ برای داده‌های کنترلی
ignoreبایت‌های نامعتبر حذف می‌شوندنامناسب؛ داده را خاموش از دست می‌دهد
replaceبایت‌های نامعتبر با U+FFFD جایگزین می‌شوندپردازش لاگ و متن ناهمگون
backslashreplaceبایت‌ها با \xHH جایگزین می‌شونددیباگ و بررسی منشأ خطا
surrogateescapeبایت‌ها به ناحیهٔ خصوصی U+DC80..U+DCFF منتقل می‌شونددور زدن بدون از دست دادن داده

هر گزینه، یک فلسفهٔ متفاوت را نمایندگی می‌کند. در ادامه، هرکدام را با مثال بررسی می‌کنیم.

strict: پیش‌فرض سخت‌گیرانه

این گزینه، پیش‌فرض پایتون است. در برخورد با بایت نامعتبر، بلافاصله استثنا پرتاب می‌شود:

b"\xff".decode("utf-8", errors="strict")
# UnicodeDecodeError

این گزینه برای داده‌های کنترل‌شده (مثل API خودتان) مناسب است، چون هر خطا نشانهٔ یک باگ جدی است.

ignore: خاموش و خطرناک

این گزینه، بایت‌های نامعتبر را بی‌صدا حذف می‌کند:

b"hello\xffworld".decode("utf-8", errors="ignore")
# 'helloworld' — بایت حذف شد

توصیه: هرگز از ignore برای داده‌های واقعی استفاده نکنید. حذف بی‌صدا، هم داده را از بین می‌برد و هم باگ‌های پنهان می‌سازد.

replace: استاندارد برای داده‌های ناهمگون

این گزینه، بایت‌های نامعتبر را با کاراکتر جانشین U+FFFD (که به‌صورت � نمایش داده می‌شود) جایگزین می‌کند:

b"hello\xffworld".decode("utf-8", errors="replace")
# 'hello\ufffdworld'

این گزینه، انتخاب پیش‌فرض من برای پردازش لاگ‌ها، داده‌های ورودی کاربر و متن‌های ناهمگون است. هم از شکست جلوگیری می‌کند و هم نشان می‌دهد کجا داده‌ای از دست رفته است.

backslashreplace: برای دیباگ

این گزینه، بایت‌های نامعتبر را با دنبالهٔ escape جایگزین می‌کند:

b"hello\xffworld".decode("utf-8", errors="backslashreplace")
# 'hello\\xffworld'

خروجی شامل \xff است که برای دیباگ بسیار مفید است. در محیط تولید مناسب نیست، ولی در تحلیل اولیهٔ داده، ابزار عالی‌ای است.

surrogateescape: بدون از دست دادن داده

این گزینه، بایت‌های نامعتبر را به کدپوینت‌های خصوصی در بازهٔ U+DC80 تا U+DCFF نگاشت می‌کند که در یونیکد معنای خاصی ندارند:

raw = b"hello\xffworld"
text = raw.decode("utf-8", errors="surrogateescape")
# متن قابل ذخیره است و می‌توان آن را بدون تغییر به بایت برگرداند
restored = text.encode("utf-8", errors="surrogateescape")
assert restored == raw

این گزینه در سیستم‌های فایل و پردازش اسم فایل‌ها با انکودینگ مشکل‌دار بسیار ارزشمند است. تنها گزینه‌ای است که رفت‌وبرگشت بدون از دست دادن داده را تضمین می‌کند.

انتخاب درست بین این پنج گزینه، به فلسفهٔ پروژه بستگی دارد: اگر داده حساس است و می‌خواهید همه‌چیز را بدانید، strict؛ اگر پردازش انبوه است و بی‌نقصی هر رکورد مهم نیست، replace؛ اگر می‌خواهید داده دست‌نخورده بماند، surrogateescape. برای مطالعهٔ بیشتر در این حوزه، خطای RuntimeError در پایتون نکات مکمل را ارائه می‌دهد.

تشخیص خودکار انکودینگ فایل

در پروژه‌هایی که با فایل‌های ناهمگون از منابع مختلف کار می‌کنند، تشخیص خودکار انکودینگ، ابزار نجات‌بخش است. سه کتابخانهٔ اصلی در این حوزه وجود دارد:

chardet

کتابخانهٔ کلاسیک که سال‌ها استاندارد صنعت بوده است:

import chardet

with open("unknown.txt", "rb") as f:
    raw = f.read()

result = chardet.detect(raw)
# {'encoding': 'Windows-1256', 'confidence': 0.85, 'language': 'Arabic'}

نقطهٔ قوت: پشتیبانی از انکودینگ‌های گسترده. نقطهٔ ضعف: ممکن است در فایل‌های کوچک یا با کاراکترهای خاص، خطا کند.

charset-normalizer

کتابخانهٔ جانشین که توسط تیم requests توصیه شده:

from charset_normalizer import from_bytes

with open("unknown.txt", "rb") as f:
    raw = f.read()

best = from_bytes(raw).best()
if best:
    print(best.encoding)
    print(str(best))  # متن decodeشده

نقطهٔ قوت: دقت بالاتر و API مدرن. نقطهٔ ضعف: وابستگی به پایتون ۳٫۷ به بعد.

unicodedammit

کتابخانهٔ سبک که هدفش تشخیص و ترمیم بایت‌های خراب است:

import unicodedammit

fixed = unicodedammit.fix_bad_unicode("hello\ufffdworld")

این کتابخانه برای مواردی مفید است که بخشی از داده از قبل با errors="replace" پردازش شده و می‌خواهید آن‌ها را ترمیم کنید.

الگوی ترکیبی

در پروژه‌های خودم، الگوی ترکیبی زیر را استفاده می‌کنم:

from charset_normalizer import from_bytes

def read_any(path, min_confidence=0.7):
    with open(path, "rb") as f:
        raw = f.read()
    best = from_bytes(raw).best()
    if best and best.encoding and best.coherence >= min_confidence:
        return str(best)
    # fallback امن
    try:
        return raw.decode("utf-8")
    except UnicodeDecodeError:
        return raw.decode("utf-8", errors="replace")

این الگو، در ۹۵٪ موارد جواب می‌دهد و در ۵٪ باقی‌مانده، به‌طور امن به fallback می‌رود. سناریوهای مشابه در پردازش داده‌های خارجی در خطای ImportError در پایتون هم پوشش داده شده است.

الگوهای امن خواندن متن

پس از تشخیص، الگوهای زیر را در پروژه‌های خودم رعایت می‌کنم. این شش الگو، بیشترین اثر را در کاهش UnicodeDecodeError دارند.

الگوی اول: encoding صریح در همه‌جا

قاعدهٔ سرانگشتی: هرجا open() می‌نویسید، encoding را صریح بنویسید:

# سراسر پروژه
with open(path, "r", encoding="utf-8") as f:
    content = f.read()

# یا نوشتن
with open(path, "w", encoding="utf-8") as f:
    f.write(content)

در پایتون ۳٫۱۵ به بعد، اگر PYTHONWARNDEFAULTENCODING=1 را ست کنید، خود پایتون به شما هشدار می‌دهد هر جا این پارامتر فراموش شده است. این ویژگی را در همه پروژه‌های جدید فعال کنید.

الگوی دوم: تابع wrapper مرکزی

به‌جای تکرار پارامترها در همه‌جا، یک تابع wrapper بسازید:

from pathlib import Path

def read_text(path, encoding="utf-8", errors="strict"):
    return Path(path).read_text(encoding=encoding, errors=errors)

def write_text(path, text, encoding="utf-8"):
    Path(path).write_text(text, encoding=encoding)

مزیت: تغییر تنظیمات در آینده، فقط در یک نقطه انجام می‌شود.

الگوی سوم: خواندن باینری + decode کنترل‌شده

وقتی انکودینگ داده مشکوک است، خواندن باینری و decode دستی کنترل بیشتری می‌دهد:

def read_binary_then_decode(path):
    with open(path, "rb") as f:
        raw = f.read()
    for enc in ("utf-8", "utf-8-sig", "windows-1256", "cp1252"):
        try:
            return raw.decode(enc), enc
        except UnicodeDecodeError:
            continue
    return raw.decode("utf-8", errors="replace"), "utf-8-with-replace"

این الگو، انعطاف‌پذیری بالایی می‌دهد و انتخاب انکودینگ را شفاف می‌کند.

الگوی چهارم: Python 3.15+ با UTF-8 mode

از پایتون ۳٫۱۵، می‌توانید حالت UTF-8 را در سطح مفسر فعال کنید:

# در متغیر محیطی
PYTHONUTF8=1 python app.py

# یا در کد
import sys
sys.flags.utf8_mode = True  # فقط در زمان راه‌اندازی

این حالت، پیش‌فرض‌های سیستم را به UTF-8 تبدیل می‌کند و در پروژه‌های بین‌المللی مفید است.

الگوی پنجم: مدیریت BOM در CSV

برای فایل‌های CSV که از اکسل می‌آیند، همیشه utf-8-sig را ترجیح دهید:

import csv

with open("data.csv", encoding="utf-8-sig", newline="") as f:
    reader = csv.DictReader(f)
    for row in reader:
        process(row)

ترکیب utf-8-sig و newline="" استاندارد پردازش CSV است. اگر این ترکیب را رعایت نکنید، ممکن است خطوط اضافی یا کاراکتر BOM در داده ببینید.

الگوی ششم: لاگ ساخت‌یافته به‌جای لاگ متنی

در پروژه‌های بزرگ، لاگ متنی با کاراکترهای بین‌المللی، منبع دائمی UnicodeDecodeError است. راه‌حل: لاگ JSON با UTF-8:

import json
import logging

handler = logging.FileHandler("app.log", encoding="utf-8")
formatter = logging.Formatter('%(message)s')
handler.setFormatter(formatter)

logger = logging.getLogger()
logger.addHandler(handler)

def log_event(**kwargs):
    logger.info(json.dumps(kwargs, ensure_ascii=False))

ترکیب encoding="utf-8" در FileHandler و ensure_ascii=False در json.dumps، تضمین می‌کند که فارسی و عربی به‌درستی ذخیره می‌شوند. سناریوهای مشابه در ساخت API با پایتون هم پوشش داده شده است.

UnicodeDecodeError در دیتابیس و شبکه

در دیتابیس‌ها و شبکه، مسئله پیچیده‌تر می‌شود چون لایه‌های متعددی درگیرند. تجربه‌ام از پرونده‌های واقعی در این حوزه را با شما به اشتراک می‌گذارم.

MySQL و utf8mb4

در MySQL، charset با نام utf8 فقط ۳ بایت را پشتیبانی می‌کند و برای emoji و کاراکترهای چینی کافی نیست. همیشه از utf8mb4 استفاده کنید:

-- در سطح جدول
CREATE TABLE posts (
    id INT PRIMARY KEY,
    title VARCHAR(255)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- در سطح اتصال
SET NAMES utf8mb4;

در پایتون با mysql-connector-python:

conn = mysql.connector.connect(
    host="localhost",
    user="user",
    password="pass",
    database="mydb",
    charset="utf8mb4",
    use_unicode=True,
)

نکتهٔ ظریف: پارامتر use_unicode=True به درایور می‌گوید که نتایج را به‌صورت str برگرداند، نه bytes. این تنظیم را در همه پروژه‌های فارسی فعال کنید.

PostgreSQL و client_encoding

در PostgreSQL، انکودینگ دیتابیس در زمان ساخت تعیین می‌شود و بعداً تغییرپذیر نیست. اگر دیتابیس با SQL_ASCII ساخته شده باشد، ممکن است داده‌های غیر-ASCII را خراب ذخیره کند. بررسی:

SHOW server_encoding;
SHOW client_encoding;

در psycopg2، انکودینگ client به‌طور پیش‌فرض با locale سیستم ست می‌شود:

conn = psycopg2.connect(
    host="localhost",
    database="mydb",
    user="user",
    password="pass",
    client_encoding="UTF8",
)

SQLite و انکودینگ پیش‌فرض

SQLite به‌طور پیش‌فرض از UTF-8 استفاده می‌کند و مشکل کمتری دارد. ولی اگر دیتابیس با انکودینگ دیگری ساخته شده باشد، UnicodeDecodeError ممکن است در خواندن ظاهر شود:

import sqlite3

conn = sqlite3.connect("mydb.sqlite")
conn.text_factory = lambda b: b.decode("utf-8", errors="replace")

پارامتر text_factory در SQLite، نحوهٔ تبدیل بایت به متن را کنترل می‌کند. تنظیم آن به str با decode سفارشی، در دیتابیس‌های مشکوک مفید است.

پاسخ HTTP و Content-Type

در شبکه، سرور با هدر Content-Type انکودینگ را اعلام می‌کند:

Content-Type: text/html; charset=utf-8

اگر سروری این هدر را نفرستد یا charset اشتباه بفرستد، requests ممکن است حدس بزند و اشتباه کند. راه‌حل عملی:

import requests

resp = requests.get(url)
# خواندن از هدر
encoding = resp.encoding or "utf-8"
# یا تشخیص از محتوا
from charset_normalizer import from_bytes

if resp.encoding is None:
    detected = from_bytes(resp.content).best()
    encoding = detected.encoding if detected else "utf-8"

text = resp.content.decode(encoding, errors="replace")

این الگو را در پروژه‌های وب اسکرپینگ با پایتون زیاد استفاده کرده‌ام، چون سایت‌های ایرانی و عربی اغلب charset درست اعلام نمی‌کنند.

کتابخانه‌ها: pandas، requests، csv و Django

هر کتابخانه، رفتار اختصاصی با انکودینگ دارد. شناخت این رفتارها، در محیط‌های تولیدی حیاتی است.

pandas و read_csv

تابع read_csv پارامتر encoding می‌پذیرد و پیش‌فرضش UTF-8 است. برای فایل‌های اکسل ذخیره‌شده در ویندوز، utf-8-sig گزینهٔ امنی است:

import pandas as pd

df = pd.read_csv("data.csv", encoding="utf-8-sig")

# یا با تشخیص خودکار
for enc in ("utf-8", "utf-8-sig", "windows-1256", "cp1252"):
    try:
        df = pd.read_csv("data.csv", encoding=enc)
        print(f"loaded with {enc}")
        break
    except UnicodeDecodeError:
        continue

در کتابخانهٔ pandas، حتی پارامتر sep و quotechar هم می‌توانند مشکل‌ساز شوند اگر انکودینگ اشتباه باشد. برای مطالعهٔ بیشتر، کتابخانه pandas در پایتون مرجع مکمل خوبی است.

requests و response.text

کتابخانهٔ requests انکودینگ را از هدر Content-Type استخراج می‌کند و اگر نبود، از charset_normalizer (یا chardet) استفاده می‌کند. در ۹۵٪ موارد این کافی است، ولی در ۵٪ موارد اشتباه می‌کند:

import requests

resp = requests.get(url)

# روش امن
text = resp.content.decode("utf-8", errors="replace")

# یا با تشخیص
if resp.encoding is None or resp.encoding.lower() == "iso-8859-1":
    # این انکودینگ پیش‌فرض HTTP است و معمولاً اشتباه
    resp.encoding = "utf-8"

text = resp.text

csv و newline

ماژول csv در پایتون نیاز به newline="" دارد تا خطوط به‌درستی مدیریت شوند:

import csv

with open("data.csv", encoding="utf-8-sig", newline="") as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(row)

ترکیب encoding و newline="" استاندارد طلایی پردازش CSV در پایتون است.

Django و HttpResponse

در Django، فایل‌های settings.py و views.py باید با UTF-8 ذخیره شوند. اگر ویرایشگر شما به‌طور تصادفی با کدپیج دیگری ذخیره کند، خطاهای سینتکسی عجیبی می‌بینید. تنظیم پیش‌فرض ویرایشگر (VS Code، PyCharm) باید UTF-8 باشد:

# در Django، هنگام ارسال پاسخ:
from django.http import JsonResponse

return JsonResponse(
    {"message": "سلام"},
    json_dumps_params={"ensure_ascii": False},
)

پارامتر ensure_ascii=False باعث می‌شود پاسخ JSON، متن فارسی را به‌صورت خوانا نشان دهد و نه دنبالهٔ \uXXXX. برای مطالعهٔ تفصیلی دربارهٔ Django، آموزش Django برای مبتدیان را ببینید.

asyncio و aiofiles

در پردازش async، کتابخانهٔ aiofiles همان پارامترهای open() را می‌پذیرد:

import aiofiles

async def read_async(path):
    async with aiofiles.open(path, "r", encoding="utf-8") as f:
        return await f.read()

الگوی صریح encoding در async هم ضروری است. سناریوهای مشابه در خطای RuntimeError در پایتون هم پوشش داده شده است.

پرسش‌های پرتکرار درباره UnicodeDecodeError

این بخش، پرسش‌هایی را پوشش می‌دهد که در جلسات مشاوره و انجمن‌های فنی بیشترین تکرار را داشته‌اند. پاسخ‌ها به‌شکلی نوشته شده‌اند که برای جستجوهای مستقیم و دستیارهای هوش مصنوعی به‌عنوان پاسخ معتبر قابل استخراج باشند.

تفاوت UnicodeDecodeError و UnicodeEncodeError چیست؟

UnicodeDecodeError هنگام تبدیل bytes به str رخ می‌دهد و یعنی بایت‌ها با انکودینگ انتخابی سازگار نیستند. UnicodeEncodeError هنگام تبدیل str به bytes رخ می‌دهد و یعنی کاراکتری وجود دارد که در آن انکودینگ قابل‌نمایش نیست (مثلاً کاراکتر فارسی در ascii). هر دو از UnicodeError ارث می‌برند، ولی راه‌حل‌هایشان متفاوت است.

چرا فایل‌ها اغلب با UTF-8 باز نمی‌شوند؟

سه دلیل اصلی: اول، فایل با کدپیج دیگری ذخیره شده (مثل windows-1256 یا cp1252). دوم، فایل BOM دارد و با utf-8 ساده خوانده می‌شود. سوم، فایل واقعاً باینری است و اشتباهاً به‌عنوان متن باز شده. برای تشخیص، ابتدا در حالت باینری بخوانید و چند بایت اول را با xxd یا hex بررسی کنید.

پارامتر errors="replace" داده را از دست می‌دهد؟

بله، ولی از دست دادن، کنترل‌شده و قابل‌مشاهده است: بایت‌های نامعتبر با U+FFFD (کاراکتر جانشین) جایگزین می‌شوند. مزیت این روش، شکست کل عملیات نیست و می‌توانید در متن نتیجه، ببینید کجاها داده از دست رفته. برای دیباگ، backslashreplace بهتر است چون مقدار بایت را نگه می‌دارد. برای حفظ کامل داده، surrogateescape بهترین گزینه است چون رفت‌وبرگشت را تضمین می‌کند.

چرا chardet گاهی اشتباه تشخیص می‌دهد؟

چون تشخیص انکودینگ ذاتاً یک مسئلهٔ احتمالاتی است. برای فایل‌های کوچک یا با محتوای عمدتاً ASCII، چند انکودینگ می‌توانند معتبر باشند و الگوریتم یکی را انتخاب می‌کند. توصیه: همیشه مقدار confidence را بررسی کنید و اگر کمتر از ۰٫۷ است، نتیجه را با خواندن چند خط اول به‌صورت چشمی تأیید کنید. برای فایل‌های مهم، بهتر است انکودینگ را با سازندهٔ اصلی هماهنگ کنید، نه این‌که به تشخیص خودکار سپرده شود.

آیا pandas می‌تواند خودش انکودینگ را تشخیص دهد؟

pandas خودش تشخیص نمی‌دهد، ولی پارامتر encoding_errors را می‌پذیرد که به موتور CSV پاس داده می‌شود. برای تشخیص خودکار، می‌توانید از chardet یا charset_normalizer استفاده کنید و سپس نتیجه را به read_csv بدهید:

import chardet
import pandas as pd

with open("data.csv", "rb") as f:
    raw = f.read(100_000)

detected = chardet.detect(raw)
df = pd.read_csv("data.csv", encoding=detected["encoding"])

چرا در VS Code کد با error اجرا می‌شود ولی در ترمینال نه؟

چون VS Code و ترمینال ممکن است locale متفاوتی داشته باشند. در سیستم‌های لینوکس، متغیر LANG و LC_ALL تعیین‌کنندهٔ locale هستند. اگر در VS Code این متغیرها روی en_US.UTF-8 باشند و در ترمینال روی C، رفتار پایتون تفاوت می‌کند. بررسی:

$ echo $LANG $LC_ALL
$ locale

راه‌حل: تنظیم LANG=en_US.UTF-8 در فایل ~/.bashrc یا استفاده از حالت UTF-8 در پایتون.

چگونه یک فایل با BOM را تشخیص دهیم؟

BOM در UTF-8، سه بایت اول EF BB BF است. برای تشخیص:

with open("data.txt", "rb") as f:
    head = f.read(3)
    if head == b"\xef\xbb\xbf":
        print("file has UTF-8 BOM")
    elif head[:2] in (b"\xff\xfe", b"\xfe\xff"):
        print("file has UTF-16 BOM")

برای فایل‌های دارای BOM، همیشه utf-8-sig را انتخاب کنید که BOM را در ابتدا حذف می‌کند.

آیا می‌توان فایل را بدون decode خواند و به‌عنوان متن استفاده کرد؟

خیر. در پایتون ۳، open() به‌طور پیش‌فرض در حالت متنی داده را decode می‌کند. اگر می‌خواهید از decode دوری کنید، فایل را در حالت باینری با "rb" باز کنید و سپس خودتان بایت‌ها را مدیریت کنید. این رویکرد در پردازش فایل‌های حجیم یا داده‌های باینری توصیه می‌شود. برای مطالعهٔ بیشتر، کار با فایل‌ها در پایتون نکات مرتبط را ارائه می‌دهد.

چرا پس از ارتقای پایتون، کدی که کار می‌کرد الان خطا می‌دهد؟

چون رفتار پیش‌فرض انکودینگ در نسخه‌های مختلف پایتون تفاوت دارد. در پایتون ۳٫۱۵، default به UTF-8 تغییر کرد و همین ممکن است کدی که قبلاً با cp1252 کار می‌کرد، حالا با UTF-8 شکست بخورد. راه‌حل: صریحاً encoding را در همه‌جا مشخص کنید تا رفتار بین نسخه‌ها یکسان بماند.

آیا در numpy هم UnicodeDecodeError داریم؟

numpy با داده‌های باینری و عددی سروکار دارد و خودش انکودینگ متن را مدیریت نمی‌کند. ولی اگر آرایهٔ numpy شامل رشته‌ها باشد، هنگام تبدیل به لیست پایتون یا ذخیره در فایل، ممکن است با انکودینگ درگیر شود. برای پیمایش فایل‌های باینری بزرگ، همیشه از np.fromfile یا np.memmap استفاده کنید که بایت‌ها را بدون decode می‌خوانند.

چطور در pytest، UnicodeDecodeError را تست کنیم؟

با استفاده از pytest.raises:

import pytest

def test_invalid_utf8():
    with pytest.raises(UnicodeDecodeError):
        b"\xff".decode("utf-8")

    # با errors=replace خطا نمی‌دهد
    result = b"\xff".decode("utf-8", errors="replace")
    assert result == "\ufffd"

این الگو، هم رفتار strict و هم رفتار replace را پوشش می‌دهد. برای مطالعهٔ مکمل دربارهٔ رفتار خطاها در تست، خطای AssertionError در پایتون نکات مرتبط را ارائه می‌دهد.

آیا می‌توان UnicodeDecodeError را در سطح pandas نادیده گرفت؟

بله، با encoding_errors="replace" در read_csv:

df = pd.read_csv("data.csv", encoding="utf-8", encoding_errors="replace")

ولی توصیه می‌کنم از این کار پرهیز کنید مگر این‌که دلیل روشنی داشته باشید. حذف بی‌صدا یا جایگزینی داده، باگ‌های پنهان می‌سازد. اگر می‌دانید که فایل شامل بایت‌های نامعتبر است، بهتر است ابتدا آن‌ها را شناسایی و از منبع ترمیم کنید.

برای مطالعات مکمل در حوزهٔ خطاهای پایتون، خطای StopIteration در پایتون و خطای NameError در پایتون نمونه‌های خوبی از خطاهای کلاسیک پایتون هستند.

آنچه ناسازگاری انکودینگ به معماری کد من آموخت

UnicodeDecodeError بیش از آنکه یک خطای فنی باشد، یک «درس معماری» است. سه اصلی که پس از سال‌ها کار با آن، در طراحی کد خودم رعایت می‌کنم:

نخست، انکودینگ را در مرزها صریح کنید، نه در وسط. هرجا از یک منبع خارجی (فایل، دیتابیس، شبکه) داده می‌آید، در همان خط اول، انکودینگ را مشخص کنید. اگر انکودینگ را به لایه‌های داخلی کد نشت دهید، باگ‌های ظریف در پردازش ایجاد می‌شود. تجربه‌ام این است که تیم‌هایی که در مرزها encoding صریح دارند، تقریباً هیچ‌وقت UnicodeDecodeError نمی‌بینند.

دوم، دادهٔ خام را حفظ کنید. اگر داده از یک منبع خارجی می‌آید و انکودینگ آن مشکوک است، ابتدا بایت‌های خام را ذخیره کنید و سپس در مرحلهٔ جداگانه به متن تبدیل کنید. این رویکرد این مزیت را دارد که اگر انکودینگ را در آینده تصحیح کنید، می‌توانید دوباره از بایت‌های اصلی شروع کنید. در پروژه‌هایی که با داده‌های حساس یا به‌سرعت‌تغییر کار می‌کنند، این الگو بسیار مفید است.

سوم، ابزارهای تشخیص را درون‌ساز کنید، نه وابستگی خارجی. کتابخانه‌هایی مثل chardet و charset_normalizer عالی هستند، ولی نباید تنها ابزار شما باشند. اگر می‌دانید که داده‌های پروژه‌تان از کجا می‌آیند (مثلاً مشتری همیشه با اکسل و ویندوز کار می‌کند)، انکودینگ را به‌طور صریح در کد ست کنید و از تشخیص خودکار پرهیز کنید. تشخیص خودکار، در شرایط ناشناخته خوب است، ولی در شرایط شناخته، هزینه و ریسک اضافی است.

در پایان، اگر در پروژه‌ای با حالت خاصی از UnicodeDecodeError برخورد کردید که این‌جا پوشش داده نشده — مثلاً در ترکیب با PyArrow، Dask، Spark، یا در محیط‌های Windows با محدودیت‌های خاص — تجربه‌تان را در دیدگاه‌ها بنویسید. به‌ویژه اگر راه‌حلی متفاوت از رویکردهای معمول پیدا کرده‌اید که می‌تواند برای خوانندهٔ بعدی ارزشمند باشد. 🌍