ERP'den, bankadan, e-ticaret panelinden ya da Türkçe Windows'taki Excel'den aldığınız bir CSV dosyasını pandas ile açmaya çalıştığınızda büyük olasılıkla şu satırla karşılaşırsınız:
Code:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xfe in position 6: invalid start byte
Bu hata dosyanın bozuk olduğunu söylemez. pandas'ın dosyayı yanlış karakter kodlamasıyla (encoding) çözmeye çalıştığını söyler. Kodlama sorunu çözülünce de iş bitmez: noktalı virgülle ayrılmış sütunlar, virgüllü ondalıklar, gün.ay.yıl biçimindeki tarihler ve baştaki sıfırları kaybolan sipariş numaraları sırada bekler. Bu rehberde hatayı teşhis etmeyi, dosyayı doğru parametrelerle okumayı, Excel'in Türkçe karakterleri bozmadan açacağı dosyalar yazmayı, bozulmuş metni onarmayı ve içe aktarmadan sonra Türkçe metinde karşılaşılan büyük/küçük harf ve sıralama tuzaklarını anlatıyoruz. Aşağıdaki hata mesajlarının ve çıktıların tamamını 27 Ekim 2026'da kendi örnek dosyalarımızla, pandas 2.3.3 ve 3.1.0 (Python 3.11) ve macOS komut satırı araçlarıyla ürettik; iki pandas sürümünün farklı davrandığı yerleri ayrıca belirttik.
Hata mesajı ne anlatıyor?
read_csv() fonksiyonu, aksi belirtilmedikçe dosyayı UTF-8 olarak çözer (encoding parametresinin varsayılanı 'utf-8'). UTF-8'de ASCII karakterleri (İngilizce harfler, rakamlar, noktalama) tek bayttır; ş, ı, ğ gibi harfler ise belirli kurallara uyan iki baytlık dizilerdir. Türkçe Windows'ta çalışan eski programlar ve Excel'in düz "CSV" kaydı ise metni Windows-1254 (cp1254) kod sayfasıyla (code page) yazar ve her harf tek bayt tutar. UTF-8 çözücüsü bu tek baytlık Türkçe harflerden birine rastladığında kurala uymayan bir dizi görür ve durur. Mesajın parçaları şöyle okunur:- byte 0xfe: Çözülemeyen bayt. Aşağıdaki tabloda 0xFE'nin cp1254'te ş harfi olduğunu görebilirsiniz.
- in position 6: Baytın, pandas'ın o anda çözdüğü parçanın içindeki konumu (sayım 0'dan başlar). Bu parça, pandas sürümüne ve motora göre bir okuma bloğu ya da tek bir hücredir. Küçük örnek dosyamızda bu konum dosyadaki konumla aynıdır ve başlık satırındaki "Sipariş" kelimesinin ş harfine denk gelir; büyük dosyalarda aynı olmaz.
- invalid start byte / invalid continuation byte: Baytın UTF-8'in hangi kuralını bozduğunu söyler. Teşhis için asıl önemli olan bayt değeridir.
Türkçe harflerin bayt karşılıkları (Python ile doğruladık):
| Harf | cp1254 ve ISO-8859-9 | UTF-8 | cp1252 ve Latin-1'de |
|---|---|---|---|
| ı / İ | FD / DD | C4 B1 / C4 B0 | Yok; aynı baytlar ý / Ý olur |
| ş / Ş | FE / DE | C5 9F / C5 9E | Yok; aynı baytlar þ / Þ olur |
| ğ / Ğ | F0 / D0 | C4 9F / C4 9E | Yok; aynı baytlar ð / Ð olur |
| ü / Ü | FC / DC | C3 BC / C3 9C | Aynı bayt, aynı harf |
| ö / Ö | F6 / D6 | C3 B6 / C3 96 | Aynı bayt, aynı harf |
| ç / Ç | E7 / C7 | C3 A7 / C3 87 | Aynı bayt, aynı harf |
Tablodan iki pratik sonuç çıkar. Mesajda 0xfd, 0xfe, 0xdd, 0xde, 0xf0 ya da 0xd0 görüyorsanız dosya büyük olasılıkla Türkçe Windows kodlamasındadır; bu baytlar ı, ş, İ, Ş, ğ ve Ğ harfleridir. 0xfc (ü), 0xf6 (ö) ya da 0xe7 (ç) ise tek başına yeterli kanıt değildir, çünkü bu baytlar Batı Avrupa kodlamalarında (cp1252, Latin-1) da aynı harfi verir. cp1252 ile cp1254 aslında yalnızca sekiz bayt konumunda ayrışır: yukarıdaki altı Türkçe harf ile cp1252'de Ž ve ž olan, cp1254'te ise tanımsız kalan 0x8E ve 0x9E.
Sık görülen mesajlar ve ilk denenecek çözüm:
| Mesaj | Olası neden | İlk deneme |
|---|---|---|
| 'utf-8' codec can't decode byte 0xfd (0xfe, 0xdd, 0xde, 0xf0, 0xd0) | Türkçe Windows (cp1254) dosyası | encoding='cp1254' |
| Aynı hata 0xfc, 0xf6, 0xe7 gibi baytlarla | cp1254, ISO-8859-9 ya da cp1252 | Baytlara bakın; Türkçe veride önce cp1254 |
| Aynı hata 0x80, 0x92, 0x93, 0x94 ya da 0x96 ile | Euro işareti, Windows'un akıllı tırnakları ve tireleri | cp1254 (Batı Avrupa verisinde cp1252) |
| byte 0xff in position 0 | UTF-16 dosyası (Excel'in "Unicode Metin" kaydı) | encoding='utf-16', sep='\t' |
| byte 0xd0 in position 0 | Uzantısı .csv yapılmış eski .xls çalışma kitabı | pd.read_excel() |
| Hata küçük bir konumda, dosya "PK" ile başlıyor | Uzantısı .csv yapılmış .xlsx dosyası | pd.read_excel() |
| 'charmap' codec can't decode byte 0x9e | encoding='cp1254' verdiniz ama dosya UTF-8 (Ş ve Ğ'nin UTF-8 baytları 0x9E ile biter) | encoding='utf-8-sig' |
Tahmin etmeden önce teşhis edin
Rastgele kodlama denemek yerine dosyanın ham baytlarına bakın. Hata mesajındaki konuma ise her zaman güvenmeyin. Testimizde 2,7 MB'lık bir dosyanın son satırındaki "Muğla" hücresinde ğ harfinin dosyadaki konumu 2.688.910 iken pandas 3.1 "position 2" (hücre içindeki konum), pandas 2.3 ise "position 67470" (256 KB'lık okuma bloğu içindeki konum) bildirdi. Gerçek konumu Python'a buldurun:
Python:
with open("siparisler.csv", "rb") as f:
raw = f.read()
print(raw[:4]) # dosya imzası ya da BOM
try:
raw.decode("utf-8")
print("Dosyanın tamamı geçerli UTF-8")
except UnicodeDecodeError as e: # e.start: baytın dosyadaki gerçek konumu
print(e.start, raw[max(0, e.start - 15):e.start + 15])
Code:
b'Sipa'
6 b'Sipari\xfe No;Tarih;\xdeehi'
Çıktıdaki 6, sorunlu baytın dosyadaki gerçek konumudur. \xfe ş, \xde ise Ş harfidir; dosya cp1254 kodlamasındadır. İlk baytlar dosyanın türünü de ele verir:
- EF BB BF: UTF-8 BOM (byte order mark, bayt sırası işareti). Excel'in "CSV UTF-8" kaydı böyle başlar.
- FF FE: UTF-16 LE. Excel'in "Unicode Metin" kaydıdır; sütunlar sekmeyle ayrılır.
- 50 4B 03 04 ("PK"): Bir zip arşivi, yani .xlsx. pd.read_excel() ile okuyun (openpyxl gerekir).
- D0 CF 11 E0: Eski .xls biçimi. pd.read_excel() ile okunur (xlrd gerekir).
- <html ya da <table: Bazı sistemlerin "Excel'e aktar" düğmesi aslında HTML tablo üretir. read_csv() böyle bir dosyayı hata vermeden anlamsız bir tabloya çevirebilir; testimizde HTML'in tamamı tek bir sütun adı oldu. pd.read_html() kullanın; sonuç bir DataFrame listesidir.
Aynı bilgiye terminalden de ulaşabilirsiniz:
Bash:
xxd -l 48 siparisler.csv # ya da: hexdump -C siparisler.csv | head -3
file siparisler.csv
Code:
00000000: 5369 7061 7269 fe20 4e6f 3b54 6172 6968 Sipari. No;Tarih
00000010: 3bde 6568 6972 3bdc 72fc 6e3b 4164 6574 ;.ehir;.r.n;Adet
00000020: 3b54 7574 6172 0d0a 3030 3132 353b 3033 ;Tutar..00125;03
siparisler.csv: ISO-8859 text, with CRLF line terminators
Büyük bir dosyada belirli bir konumun çevresine bakmak için xxd'ye başlangıç konumunu verin: xxd -s 2688900 -l 32 buyuk.csv.
file komutunun "ISO-8859 text" yanıtı yalnızca dosyanın UTF-8 olmadığını ve tek baytlık bir kodlama kullandığını söyler; bu araç tek baytlık kod sayfalarını birbirinden ayıramaz. Akıllı tırnak içeren başka bir cp1254 dosyamız için "Non-ISO extended-ASCII text" dedi. MIME çıktısı (macOS'ta file -I, Linux'ta file -i) aynı dosyaya "charset=iso-8859-1" der ve BOM'u hiç göstermez. file'a yalnızca "UTF-8", "UTF-8 (with BOM)", "UTF-16" ya da "UTF-8 değil" ayrımı için güvenin. Çıktıdaki "with CRLF line terminators" ifadesi (xxd'de 0d 0a) dosyanın Windows'ta üretildiğine dair ek bir ipucudur.
charset-normalizer ve chardet kütüphaneleri kaynağını bilmediğiniz dosyalar için ipucu verir, karar vermez. Testimizde charset-normalizer küçük cp1254 örneklerine iso8859_10, cp1250 ve cp1257 gibi yanlış adlar verdi; cp1254'ü yalnızca 15 MB'lık büyük test dosyamızda doğru buldu. chardet Windows-1254 dedi ama güven puanı 0,06'nın altında kaldı. Bu araçların sonucunu bir aday olarak alın ve okunan metni gözle doğrulayın.
Doğru kodlamayla (encoding) okuma
Türkçe veride iki kodlama, sorunların neredeyse tamamını çözer:
Python:
import pandas as pd
# Türkçe Windows'ta düz "CSV" kaydı, eski ERP ve banka çıktıları
df = pd.read_csv("siparisler.csv", sep=";", encoding="cp1254")
# Excel'in "CSV UTF-8" kaydı ve çoğu web sistemi (BOM'lu ya da BOM'suz)
df = pd.read_csv("siparisler_utf8.csv", sep=";", encoding="utf-8-sig")
Dikkat edilecek noktalar:
- cp1254 mü, ISO-8859-9 mu? Türkçe harfler ikisinde de aynı bayttadır. Fark 0x80-0x9F aralığındadır: cp1254 bu aralıkta €, “ ”, ‘ ’, –, — ve … işaretlerini taşır; ISO-8859-9'da aynı baytlar görünmeyen kontrol karakterleridir. Testimizde “Özel” seri – 2026 ve € 12,50 içeren bir satır ISO-8859-9 ile okununca tırnaklar, tire ve euro işareti hiçbir hata vermeden görünmez kontrol karakterlerine dönüştü. Windows ve Excel çıktıları için cp1254 seçin. Python windows-1254 adını da tanır; latin5 ise ISO-8859-9'un takma adıdır.
- utf-8-sig iki durumda da çalışır: Dosyanın başında BOM varsa atar, yoksa normal UTF-8 gibi okur. Testlerimizde pandas, encoding='utf-8' ile de BOM'u kendisi ayıkladı; Python'un csv modülü ve open() ise ayıklamaz. csv.DictReader ilk sütunun adını '\ufeffSipariş No' olarak okudu ve row["Sipariş No"] KeyError verdi.
- İlk sütun adı  ile başlıyorsa BOM'lu bir UTF-8 dosyası cp1252, cp1254 ya da Latin-1 ile okunmuştur. Kodlamayı utf-8-sig yapın.
- encoding_errors yalnızca keşif içindir. pandas 1.3 ile gelen bu parametre, çözülemeyen baytlarda ne yapılacağını belirler. 'replace' çözülemeyen her baytın yerine � (U+FFFD) koyar, 'ignore' baytı sessizce siler. Testimizde sütun adları 'replace' ile "Sipari� No" ve "�ehir", 'ignore' ile "Sipari No" ve "ehir" oldu. Dosyaya hızlıca göz atmak için kullanılabilir; üretim kodunda veri kaybı ve tutmayan birleştirme (merge) anahtarları demektir.
- encoding='latin-1' hatayı susturur ama çözmez. Latin-1 her bayta bir karakter karşılık getirdiği için asla hata vermez. Türkçe dosyada "Şehir" sütun adı "Þehir", "İstanbul" ise "Ýstanbul" olur.
Kaynağı belli olmayan dosyalar için adayları sırayla deneyen küçük bir yardımcı fonksiyon:
Python:
import codecs
def sniff_encoding(path, candidates=("utf-8", "cp1254"), chunk_size=1 << 20):
"""Önce BOM'a ve dosya imzasına bakar, sonra dosyanın tamamını
hatasız çözen ilk aday kodlamayı döndürür."""
with open(path, "rb") as f:
head = f.read(4)
if head.startswith(codecs.BOM_UTF8):
return "utf-8-sig"
if head.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
return "utf-16"
if head.startswith((b"PK\x03\x04", b"\xd0\xcf\x11\xe0")):
raise ValueError(f"{path}: bu bir Excel çalışma kitabı, pd.read_excel() kullanın")
for enc in candidates:
decoder = codecs.getincrementaldecoder(enc)()
try:
with open(path, "rb") as f:
while block := f.read(chunk_size):
decoder.decode(block)
decoder.decode(b"", final=True)
return enc
except UnicodeDecodeError:
continue
raise ValueError(f"{path}: {candidates} adaylarından hiçbiri uymadı")
print(sniff_encoding("siparisler.csv")) # cp1254
Adayların sırası bilinçli seçildi. UTF-8'in kuralları katıdır; tek baytlık Türkçe bir dosyanın UTF-8 doğrulamasından geçmesi pratikte olmaz, bu yüzden UTF-8 önce denenir. cp1254 ise neredeyse her bayt dizisini çözer. Bu yüzden sona konur ve "hatasız çözüldü" sonucu doğru kodlama olduğunu kanıtlamaz; Lehçe (cp1250) bir dosya da çoğu zaman bu testten geçer. Okunan metni aşağıdaki bozuk karakter kontrolüyle doğrulamanın nedeni budur. Fonksiyon dosyanın tamamını 1 MB'lık bloklar hâlinde çözer, çünkü iki milyonuncu satırdaki tek bir Türkçe harf, yalnızca ilk birkaç kilobayta bakan bir testi yanıltmaya yeter.
Türkçe Excel CSV'si: ayraç, ondalık ve tarih
Windows'ta Excel, CSV kaydederken ayraçları bölge ayarlarından alır. Türkçe (Türkiye) biçiminde liste ayırıcı (list separator) noktalı virgül, ondalık ayırıcı virgül, binlik ayırıcı noktadır. Düz "CSV (virgülle ayrılmış)" kaydı sistemin ANSI kod sayfasıyla, Türkçe Windows'ta cp1254 ile yazılır. "CSV UTF-8" kaydı BOM ekler ama ayraç yine noktalı virgüldür. Bu değerler Windows ayarlarından geldiği için aynı çalışma kitabı iki farklı bilgisayarda iki farklı dosya üretebilir. Aynı sorunun Tableau tarafını Tableau'da ayraç, tarih ve ondalık sembolünü doğru tanımlamak konusunda ele almıştık.Örnek dosyamızın tamamı:
Code:
Sipariş No;Tarih;Şehir;Ürün;Adet;Tutar
00125;03.01.2026;İstanbul;Çelik rulman;12;15.006,00
00126;15.01.2026;Iğdır;Şerit LED;3;1.049,70
00127;28.02.2026;Çanakkale;Ölçüm probu;1;12.480,00
00128;02.03.2026;Şanlıurfa;Güç kaynağı;5;4.495,00
Doğru okuma:
Python:
df = pd.read_csv(
"siparisler.csv",
sep=";",
encoding="cp1254",
decimal=",",
thousands=".",
dtype={"Sipariş No": str},
parse_dates=["Tarih"],
date_format="%d.%m.%Y",
)
print(df.dtypes)
Code:
Sipariş No str
Tarih datetime64[us]
Şehir str
Ürün str
Adet int64
Tutar float64
dtype: object
Bu çıktı pandas 3.1'e aittir. pandas 3.0 ile metin sütunları için ayrı bir str veri tipi varsayılan oldu ve metinden okunan tarihler varsayılan olarak mikrosaniye çözünürlüğe (datetime64[us]) geçti. pandas 2.x aynı değerleri object ve datetime64[ns] olarak gösterir. date_format parametresi pandas 2.0 ile geldi; daha eski bir sürüm kullanıyorsanız dayfirst=True verin.
Testlerimizde ortaya çıkan tuzaklar:
- thousands='.' bütün sütunlara uygulanır. Tarih sütununu parse_dates ile çözümlemezseniz "03.01.2026" tamsayı 3012026 olur. "1.234" biçimindeki bir ürün kodu 1234'e, "2.1.0" biçimindeki bir sürüm numarası 210'a dönüştü. Bu tür sütunları dtype=str ile okuyun, tarihleri açıkça çözümleyin.
- Biçim verilmeyen parse_dates sessizce yanlış tarih üretebilir. Bütün günlerin 12 ya da daha küçük olduğu bir dosyada "03.01.2026", 1 Mart 2026 olarak okundu, yani ay önce sayıldı. Günü 12'den büyük olan satırlar içeren dosyada ise Tarih sütunu hiçbir uyarı vermeden metin olarak kaldı. date_format="%d.%m.%Y" iki sorunu da çözer.
- Geçersiz tek bir tarih bütün sütunu metin bırakır. "31.02.2026" içeren dosyada hata çıkmadı, Tarih sütunu metin olarak geldi. Sorunlu satırları pd.to_datetime(df["Tarih"], format="%d.%m.%Y", errors="coerce").isna() ile bulun.
- "1.250,50 TL" gibi para birimli değerler metin kalır. Birimi ve ayraçları temizleyip sayıya çevirin:
Python:
df = pd.read_csv("tutarlar.csv", sep=";", encoding="cp1254", dtype={"Tutar": str})
tutar = (df["Tutar"].str.replace("TL", "", regex=False).str.strip()
.str.replace(".", "", regex=False)
.str.replace(",", ".", regex=False))
df["Tutar"] = pd.to_numeric(tutar) # "1.250,50 TL" -> 1250.5
sep=None verildiğinde pandas ayracı Python'un csv.Sniffer aracıyla tahmin eder (sniffing). C motoru bunu desteklemediği için Python motoruna geçer; engine="python" yazmazsanız bir ParserWarning görürsünüz. Temiz dosyalarda işe yarar ama testlerimizde iki kez yanıldı. Başında "Rapor: Ocak 2026" gibi bir başlık satırı olan dosyada ayracı boşluk sandı. Tek sütunlu bir şehir listesinde ise "r" harfini ayraç seçti ve "Bursa"yı "Bu" ile "sa" olarak ikiye böldü. sep=None'ı keşif için kullanın; üretimde ayracı açıkça yazın ya da Sniffer'ı gerçek adaylarla sınırlayın: csv.Sniffer().sniff(ornek, delimiters=";,\t|").delimiter. Bu sınırlı çağrı, başlık satırlı dosyamızda boşluğu seçmek yerine "Could not determine delimiter" hatası verdi. Hata almak, sessizce yanlış ayraçla okumaktan iyidir.
Yanlış ayracın belirtileri:
- Bütün veri tek sütunda toplanır ve sütun adı "Şehir;Adet" gibi görünür.
- Sessiz kayma: Noktalı virgüllü örnek dosyamızı varsayılan virgül ayracıyla okuduğumuzda, her satırda tek bir ondalık virgül bulunduğu için pandas hata vermedi. Virgülün solundaki metni satır dizini (index), sağındaki ondalık kısmı değer yaptı.
- Satır sonlarında fazladan bir noktalı virgül varsa ama başlık satırında yoksa ilk sütun yine sessizce satır dizinine kayar; index_col=False bunu düzeltir. Başlıkta da varsa "Unnamed: 2" gibi boş bir sütun oluşur.
- Satırlardaki ayraç sayısı değişiyorsa "Expected N fields" hatası alırsınız (aşağıda).
Excel'in doğru açacağı dosyayı yazmak
Excel'e gidecek bir dosya için:
Python:
df.to_csv(
"rapor_excel.csv",
sep=";",
decimal=",",
encoding="utf-8-sig",
index=False,
date_format="%d.%m.%Y",
float_format="%.2f",
)
Code:
Sipariş No;Tarih;Şehir;Ürün;Adet;Tutar
00125;03.01.2026;İstanbul;Çelik rulman;12;15006,00
00126;15.01.2026;Iğdır;Şerit LED;3;1049,70
- utf-8-sig BOM yazar. Excel, çift tıklanarak açılan bir CSV'nin UTF-8 olduğunu çoğunlukla bu üç bayttan anlar. BOM'suz UTF-8 dosyada Excel genellikle ANSI kod sayfasını varsayar ve "ı", "Ü" gibi karakterler gösterir. Yeni Excel sürümlerinde bu davranış güncellemeye ve bölge ayarına göre farklılık gösterebilir; BOM ise her durumda güvenilir bir işarettir. Elinizdeki BOM'suz bir dosyayı Excel'de Veri sekmesindeki Metinden/CSV'den (From Text/CSV) komutuyla açıp dosya kaynağı olarak 65001: Unicode (UTF-8) seçebilirsiniz.
- float_format metin olarak verilirse decimal=',' ile birlikte çalışır ve 15006,00 üretir. Fonksiyon (callable) olarak verilirse decimal yok sayılır; testimizde çıktı 15006.00 oldu.
- index=False Excel'de başlıksız fazladan bir ilk sütun oluşmasını önler.
- Dosya Excel'e değil de veritabanına, başka bir Python betiğine ya da Linux aracına gidiyorsa BOM'suz UTF-8 ve virgül ya da sekme ayracı kullanın; BOM ve noktalı virgül Excel'e özgü ihtiyaçlardır. MATLAB'den aynı kurallara uygun CSV üretmek için MATLAB workspace verisini CSV'ye aktarma konusuna bakabilirsiniz.
- Eski bir sistem cp1254 istiyorsa dikkat: Python'un cp1254 kodlamasında ₺ işareti (U+20BA) yoktur. Testimizde to_csv(encoding="cp1254") "'charmap' codec can't encode character '\u20ba'" hatası verdi ve geride yalnızca başlık satırı yazılmış yarım bir dosya bıraktı. errors="replace" bu karakterin yerine ? yazar. En temizi, yazmadan önce ₺ işaretini "TL" ile değiştirmektir.
Mümkünse CSV yerine doğrudan .xlsx üretin:
Python:
df.to_excel("rapor.xlsx", index=False, sheet_name="Siparişler") # openpyxl gerekir
geri = pd.read_excel("rapor.xlsx", dtype={"Sipariş No": str})
.xlsx metni Unicode, sayıları da sayı olarak saklar; kodlama, ayraç ve ondalık sorunu ortadan kalkar ve Excel sayıları kullanıcının bölge ayarına göre gösterir. openpyxl kurulu değilse "No module named 'openpyxl'" hatası alırsınız. Metin olarak yazılan "00125" Excel'de metin hücresi olarak kaldı, ama dtype vermeden geri okuduğumuzda yine 125 oldu; okurken de dtype verin. Tarihler varsayılan olarak "YYYY-MM-DD HH:MM:SS" biçimli hücrelere yazılır. pandas 3.1 ve openpyxl ile yaptığımız denemede ExcelWriter'ın date_format ve datetime_format parametreleri bu biçimi değiştirmedi. Gün.ay.yıl görünümü istiyorsanız dosyayı yazdıktan sonra openpyxl ile hücre biçimini değiştirin:
Python:
from openpyxl import load_workbook
wb = load_workbook("rapor.xlsx")
ws = wb["Siparişler"]
for (hucre,) in ws.iter_rows(min_row=2, min_col=2, max_col=2): # B sütunu: Tarih
hucre.number_format = "DD.MM.YYYY"
wb.save("rapor.xlsx")
Bozuk karakterleri (mojibake) onarmak
En iyi onarım, kaynak dosyayı doğru kodlamayla yeniden okumaktır. Kaynak elinizde değilse, örneğin bozuk metin çoktan veritabanına yazılmışsa, önce hangi hatanın yapıldığını tanıyın:| Gördüğünüz | Olması gereken | Ne oldu? | Onarım |
|---|---|---|---|
| ü ö ç ı İ ÅŸ ÄŸ | ü ö ç ı İ ş ğ | UTF-8 dosya cp1252, cp1254 ya da Latin-1 ile okunmuş | encode("cp1252").decode("utf-8") ya da ftfy |
| ý Ý þ Þ ð Ð (ü, ö, ç doğru) | ı İ ş Ş ğ Ğ | cp1254 dosya Latin-1 ya da cp1252 ile okunmuş | encode("latin-1").decode("cp1254") ya da altı harflik tablo |
| � ya da eksik harf | - | encoding_errors='replace' ya da 'ignore' kullanılmış | Onarılamaz; kaynağı yeniden okuyun |
Python:
def undo_utf8_as_cp1252(text):
"""'İstanbul' -> 'İstanbul'. Sağlam metne dokunmaz."""
if not isinstance(text, str):
return text
for wrong in ("cp1252", "latin-1"):
try:
return text.encode(wrong).decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
continue
return text
LATIN1_TO_TR = str.maketrans("ÝýÞþÐð", "İıŞşĞğ")
# Birinci desen (ü, ı, ÅŸ) için:
df["Şehir"] = df["Şehir"].map(undo_utf8_as_cp1252)
# İkinci desen (Ý, ý, Þ) için:
df["Şehir"] = df["Şehir"].str.translate(LATIN1_TO_TR)
Fonksiyondaki try/except bloğu sağlam metni korur. Doğru yazılmış Türkçe metin bu dönüşümden geçemez: "Muğla" cp1252'ye kodlanamaz (ğ yok), "Çanakkale" ise UTF-8 olarak çözülemez. Fonksiyon bu durumda değeri olduğu gibi bırakır. Bozuk ve sağlam değerlerin karıştığı test sütunumuzda yalnızca bozuk olanlar düzeldi. Altı harflik tabloyu ise yalnızca sütunda gerçek İzlandaca harfler (ý, þ, ð) bulunmadığından eminseniz uygulayın; Türkçe veride bu neredeyse her zaman böyledir.
ftfy kütüphanesi (pip install ftfy) birinci deseni kendiliğinden tanır: ftfy.fix_text("IÄŸdır") "Iğdır" döndürür, ftfy.fix_and_explain() de hangi dönüşümü uyguladığını gösterir. Ancak testimizde (ftfy 6.3.1) ikinci desene dokunmadı. "Ýstanbul" ve "Þanlýurfa" geçerli Latin harfleriyle yazıldığı için ftfy bunların Türkçe olduğunu bilemez; ikinci desen için yukarıdaki tabloyu kullanın.
İçe aktarmadan sonra Türkçe metin tuzakları
Büyük/küçük harf dönüşümü
Python'un str.upper() ve str.lower() metotları dilden bağımsız Unicode kurallarını uygular ve işletim sisteminin yerel ayarından (locale) etkilenmez:
Python:
>>> "izmir".upper()
'IZMIR'
>>> "IĞDIR".lower()
'iğdir'
>>> len("İ".lower()) # 'i' + birleşik nokta (U+0307)
2
Türkçede i'nin büyük hâli İ, I'nın küçük hâli ı'dır; Python ise i'yi I'ya, I'yı i'ye çevirir. Çözüm, bu dört harfi dönüşümden önce açıkça eşlemektir:
Python:
TR_UPPER = str.maketrans({"i": "İ", "ı": "I"})
TR_LOWER = str.maketrans({"I": "ı", "İ": "i"})
def tr_upper(s):
return s.translate(TR_UPPER).upper()
def tr_lower(s):
return s.translate(TR_LOWER).lower()
df["sehir_buyuk"] = df["Şehir"].str.translate(TR_UPPER).str.upper() # İSTANBUL, IĞDIR
df["sehir_kucuk"] = df["Şehir"].str.translate(TR_LOWER).str.lower() # istanbul, ığdır
pandas 3'e özgü bir ayrıntı: pyarrow kuruluysa varsayılan str veri tipi pyarrow tabanlıdır. Testimizde bu tipte str.lower(), "İzmir" için 5 karakterlik "izmir" döndürdü; object tipinde aynı işlem Python gibi 6 karakterlik "i̇zmir" üretti. Yani aynı kod veri tipine göre farklı sonuç verebiliyor; açık eşleme ikisinde de aynı sonucu verir.
casefold() da Türkçeye duyarlı değildir: "İSTANBUL".casefold() == "istanbul".casefold() False döner, "IĞDIR".casefold() ise "iğdir" olur. Büyük/küçük harfe duyarsız karşılaştırmada ve merge anahtarlarında iki tarafa da tr_lower() uygulayın.
Türkçe alfabetik sıralama
Varsayılan sıralama Unicode kod noktalarına göre yapılır ve Ç, Ö, Ş, Ü, İ ile başlayan değerleri Z'nin arkasına atar. Testimizde şehir listesi "... Zonguldak, Çanakkale, Ödemiş, Ünye, İzmir, Şanlıurfa" diye bitti. Basit ve taşınabilir bir çözüm:
Python:
ALFABE = " 0123456789abcçdefgğhıijklmnoöpqrsştuüvwxyz"
SIRA = {harf: i for i, harf in enumerate(ALFABE)}
SAPKASIZ = str.maketrans("âîû", "aiu")
def tr_sort_key(s):
return [SIRA.get(ch, 100 + ord(ch)) for ch in tr_lower(s).translate(SAPKASIZ)]
df = df.sort_values("Şehir", key=lambda col: col.map(tr_sort_key))
Anahtar, boşluğu ve rakamları harflerden önce sıralar ("Ali Rıza", "Alican"dan önce gelir) ve Kâhta, Kâzım gibi adlardaki şapkalı harfleri düz harf gibi değerlendirir. Listede olmayan karakterler (noktalama, yabancı harfler) en sona düşer.
locale.setlocale(locale.LC_COLLATE, "tr_TR.UTF-8") ile key=lambda col: col.map(locale.strxfrm) da macOS'taki testimizde aynı doğru sırayı verdi. Ancak Linux'ta Türkçe yerel ayarın sistemde kurulu olması gerekir, Windows'ta yerel ayar adları farklıdır ve setlocale bütün süreci etkiler. Eksiksiz sıralama kuralları (collation) gerekiyorsa PyICU kütüphanesinin Collator sınıfı en kapsamlı seçenektir.
Unicode normalleştirme: NFC ve NFD
Aynı görünen iki metin farklı kod noktalarından oluşabilir: "ş" tek bir karakter (NFC) olabileceği gibi "s" artı birleşik çengel (NFD) de olabilir. Testimizde "Şişli Güneşli" NFC'de 13, NFD'de 17 karakterdi. Ekranda aynı görünen iki değer == ile eşit çıkmadı ve nunique() 2 saydı. Dosya adlarından, PDF'lerden ya da farklı sistemlerden gelen metinleri birleştirmeden önce normalleştirin:
Python:
df["Şehir"] = df["Şehir"].str.normalize("NFC").str.strip()
ı harfinin ayrışık bir biçimi yoktur; İ ise NFD'de I harfine ve U+0307 birleşik noktasına ayrılır.
read_csv'nin diğer sık hataları
ParserError: Expected N fields in line M, saw K
Tam mesaj "Error tokenizing data. C error: Expected 2 fields in line 3, saw 3" biçimindedir ve bir satırda başlıktakinden fazla alan olduğunu söyler. Nedeni çoğunlukla yanlış ayraç, virgülle ayrılmış dosyada tırnak içine alınmamış bir ondalık virgül ("Defter,24,5") ya da bir tırnak hatasıdır. pandas 1.3 ile gelen on_bad_lines parametresi (eski error_bad_lines ve warn_bad_lines 2.0'da kaldırıldı) üç seçenek sunar: 'warn' satırı atlayıp "Skipping line 3: expected 2 fields, saw 3" uyarısı verir, 'skip' sessizce atlar, Python motoruyla verilen bir fonksiyon ise satırı onarabilir:
Python:
def onar(satir): # ['Defter', '24', '5'] -> ['Defter', '24.5']
return [satir[0], satir[1] + "." + satir[2]]
df = pd.read_csv("fiyatlar.csv", on_bad_lines=onar, engine="python")
C motoru fonksiyon kabul etmez ve "on_bad_line can only be a callable function if engine='python' or 'pyarrow'" hatası verir. Satır atladıysanız atlanan satırları mutlaka sayın; eksik veriyle sessizce rapor üretmek, hata almaktan daha pahalıdır.
ParserError: EOF inside string
Bir alan tırnak işaretiyle başlayıp hiç kapanmıyorsa (örneğin hücrede kapanış tırnağı olmadan "3/4 yazıyorsa), pandas dosyanın sonuna kadar her şeyi tırnak içi sayar ve "EOF inside string starting at row 1" hatası verir. Alanın ortasındaki ya da sonundaki tırnak (3/4") testimizde sorun çıkarmadı. Kaynağı düzeltemiyorsanız quoting=csv.QUOTE_NONE (ya da 3) ile tırnakları sıradan karakter olarak okuyun; tırnak işareti veride kalır.DtypeWarning: Columns (...) have mixed types
C motoru büyük dosyayı varsayılan olarak (low_memory=True) parçalar hâlinde işler ve her parçanın tipini ayrı tahmin eder. 400.000 satırlık test dosyamızda ilk 300.000 satırı sayısal, kalanı "A300000" gibi harfli olan Kod sütununda 262.144 değer int, 137.856 değer str olarak geldi. Sayısal görünen kodlar ilk blokta sayı, ikinci blokta metin olduğu için df["Kod"] == "10005" filtresi hiçbir satır bulmadı (10005 ilk blokta sayı olarak okunmuştu). Aynı değer iki blokta da geçiyorsa eşleşmelerin yalnızca bir kısmı gelir. pandas 3.0'dan beri uyarıda sütun adı da yer alır ("Columns (0: Kod)"); 2.x yalnızca sütun sırasını verir ("Columns (0)"). Çözüm dtype={"Kod": str}, ikinci seçenek low_memory=False.Baştaki sıfırların kaybolması
"00125" gibi değerler sayı sanılıp 125'e dönüşür. Sipariş, müşteri, vergi ve kimlik numaralarını, posta kodlarını, telefonları ve IBAN'ları her zaman dtype=str ile okuyun. Excel'de de aynı sorun yaşanır: varsayılan ayarlarda Excel, çift tıklanarak açılan CSV'deki baştaki sıfırları atar ve 15 haneden uzun sayılarda 15. haneden sonrasını sıfıra çevirir. Dosyayı Excel'de açıp yeniden kaydetmek bu kaybı kalıcı hâle getirir. Microsoft 365 ve Excel 2024'te bu iki dönüşümü Dosya > Seçenekler > Veri altındaki otomatik veri dönüştürme (Automatic Data Conversion) ayarlarından kapatabilirsiniz.Büyük dosyalar
Python:
toplam = 0.0
for parca in pd.read_csv("buyuk.csv", sep=";", encoding="cp1254",
decimal=",", thousands=".",
usecols=["Kod", "Şehir", "Tutar"],
dtype={"Kod": str},
chunksize=100_000):
toplam += parca["Tutar"].sum()
- usecols ve dtype okunan veriyi ve bellek kullanımını küçültür. Az sayıda farklı değeri olan sütunlarda dtype="category" kullanın. 400.000 satırlık dosyamızda Şehir sütununu kategori yapmak, bellek kullanımını pandas 3.1'de 26,5 MB'tan 20,7 MB'a, pandas 2.3'te 114,8 MB'tan 79,5 MB'a indirdi. İki sürüm arasındaki büyük fark, pyarrow kuruluyken pandas 3'ün kullandığı pyarrow tabanlı str tipinden gelir.
- engine="pyarrow" (pandas 1.4 ile geldi) çok iş parçacıklı çalışır ama her parametreyi desteklemez. Testimizde thousands, dayfirst ve chunksize ValueError verdi; encoding_errors="replace" ise UnicodeDecodeError'ı engellemedi. date_format sorunsuz çalıştı.
- pandas 3.1 (22 Ekim 2026) varsayılan C motoruyla, en az 5 MB veri içeren yerel ve sıkıştırılmamış dosyaları birden çok çekirdekte paralel okur; iş parçacığı sayısını mode.max_threads seçeneği belirler. Bu hızlandırma UTF-8 dışındaki kodlamalarda ve parse_dates, usecols, chunksize gibi parametreler verildiğinde devreye girmez. Aynı cp1254 dosyasını sık okuyorsanız bir kez UTF-8'e çevirin:
Bash:
iconv -f CP1254 -t UTF-8 buyuk.csv > buyuk_utf8.csv
Sağlam bir içe aktarma fonksiyonu
Yukarıdakileri tek fonksiyonda toplayalım. Fonksiyon kodlamayı belirler, Türkçe Excel varsayılanlarıyla okur, tarih sütunlarının gerçekten tarihe dönüştüğünü ve metinde bozuk karakter izi kalmadığını doğrular. Bir şey ters giderse sessizce devam etmek yerine hata verir.
Python:
SUSPECT = "ÃÄÅÝýÞþÐð\ufffd"
def mojibake_report(df, rows=1000):
text = " ".join(map(str, df.columns))
obj = df.select_dtypes(include=["object", "string"]).head(rows)
if not obj.empty:
text += " " + " ".join(obj.astype(str).to_numpy().ravel())
return {ch: text.count(ch) for ch in SUSPECT if ch in text}
def read_tr_csv(path, *, text_cols=(), date_cols=(), date_format="%d.%m.%Y", **kwargs):
enc = sniff_encoding(path)
options = dict(sep=";", decimal=",", thousands=".", encoding=enc)
options.update(kwargs)
df = pd.read_csv(
path,
dtype={c: str for c in text_cols} or None,
parse_dates=list(date_cols) or None,
date_format=date_format if date_cols else None,
**options,
)
df.columns = [str(c).strip() for c in df.columns]
for c in date_cols:
if not pd.api.types.is_datetime64_any_dtype(df[c]):
raise ValueError(f"{path}: {c!r} sütunu {date_format} biçimine uymuyor")
suspects = mojibake_report(df)
if suspects:
raise ValueError(f"{path}: {enc} ile okundu, şüpheli karakterler: {suspects}")
return df
df = read_tr_csv("siparisler.csv", text_cols=["Sipariş No"], date_cols=["Tarih"])
Virgülle ayrılmış bir UTF-8 dosya için read_tr_csv("veri.csv", sep=",", decimal=".", thousands=None) yazın; verdiğiniz parametreler varsayılanları ezer. Tarih sütunu biçime uymazsa ya da metinde Ã, Ä, ý, Þ, � gibi izler varsa fonksiyon dosya adını ve kullanılan kodlamayı söyleyerek durur. Ä ve Å Almanca ya da İskandinav adlarında meşru olarak geçebilir; verinizde böyle adlar varsa SUSPECT listesini daraltın.
Kontrol listesi:
- İlk baytlara bakın: EF BB BF, FF FE, PK, D0 CF ya da <html.
- Kodlamayı belirleyin (önce utf-8-sig, sonra cp1254) ve okunan metni gözle kontrol edin.
- sep, decimal ve thousands değerlerini dosyaya göre açıkça verin; üretimde sep=None'a güvenmeyin.
- Kod ve numara sütunlarını dtype=str ile okuyun.
- Tarihleri date_format ile okuyun ve sütunun datetime tipine dönüştüğünü doğrulayın.
- Satır sayısını kaynakla karşılaştırın; on_bad_lines kullandıysanız atlanan satırları sayın.
- Bozuk karakter izlerini arayın: Ã, Ä, Å, ý, Þ, ð, �.
- Anahtar sütunları normalleştirin: strip(), NFC, Türkçe küçük harf.
- Excel'e giden dosyada sep=";", decimal=",", encoding="utf-8-sig", index=False kullanın; diğer sistemlere BOM'suz UTF-8 gönderin.
Sık sorulan sorular
encoding='latin-1' hatayı susturuyor, neden kullanmayayım?Latin-1 her bayta bir karakter karşılık getirdiği için asla hata vermez. Türkçe bir dosyada ı, ş, ğ, İ, Ş, Ğ harflerini ý, þ, ð, Ý, Þ, Ð olarak okur. Hata kaybolur ama veri bozulur ve bu bozulma çoğu zaman haftalar sonra bir raporda fark edilir.
cp1254 ile ISO-8859-9 arasında hangisini seçmeliyim?
Windows ve Excel çıktılarında cp1254. Türkçe harfler ikisinde aynıdır; cp1254 ayrıca euro işaretini, akıllı tırnakları, uzun tireyi ve üç noktayı da doğru okur.
Dosyayı Excel'de açıp "CSV UTF-8" olarak kaydetmek yeterli mi?
Kodlamayı düzeltir ama ayraç ve ondalık yine bölge ayarlarından gelir, yani pandas'ta sep=";" ve decimal="," gerekebilir. Daha önemlisi, varsayılan ayarlarda Excel dosyayı açarken baştaki sıfırları atar ve uzun sayıları keser; bu yolla kaydedilen dosya artık kaynakla aynı değildir.
pandas BOM'u zaten temizliyorsa neden utf-8-sig kullanayım?
Aynı dosyayı csv modülü, open() ya da başka bir araç da okuyabilir ve onlar BOM'u temizlemez. utf-8-sig BOM'suz UTF-8 dosyayı da sorunsuz okuduğu için UTF-8 olma ihtimali olan her dosyada güvenli bir varsayılandır.
Python 3.15'e geçince eski CSV betiğim neden bozuldu?
Python 3.15 ile UTF-8 modu varsayılan oldu (PEP 686): open() artık encoding verilmezse her işletim sisteminde UTF-8 kullanır. Daha önce Türkçe Windows'ta varsayılan cp1254'tü; encoding yazmadan Excel CSV'si okuyan betikler bu sayede çalışıyordu. read_csv() ise yerel ayardan bağımsız olarak zaten UTF-8 varsaydığı için pandas bu değişiklikten etkilenmez. Geçici çözüm PYTHONUTF8=0 ortam değişkenidir; kalıcı çözüm her open() ve read_csv() çağrısında encoding'i açıkça yazmaktır.
Sonuç ve sonraki adımlar
UnicodeDecodeError can sıkıcı ama zararsız bir hatadır: işi durdurur ve pandas'ın dosyayı yanlış kodlamayla okuduğunu açıkça söyler. Asıl tehlikeli olanlar hata vermeyen durumlardır: yanlış ayraçla kayan sütunlar, ayla günü karışan tarihler, sayıya dönüşen kodlar ve sessizce bozulan harfler. Önce baytlara bakın, kodlamayı ve ayraçları açıkça yazın, sonra sonucu doğrulayın.- Bugün: Projenizdeki her read_csv() ve open() çağrısına açık bir encoding ekleyin.
- Bu hafta: İçe aktarmaları read_tr_csv() gibi doğrulama yapan tek bir fonksiyonda toplayın ve satır sayılarını kayda geçirin.
- Sonra: Veri sağlayıcılarınızdan BOM'lu UTF-8 ya da doğrudan .xlsx isteyin; Excel'e giden çıktıları utf-8-sig ile yazın.
Load a CSV exported from Excel, an ERP, a bank portal or an online shop into pandas, and sooner or later you will see this:
Code:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xfe in position 6: invalid start byte
The file is not corrupt. pandas tried to decode it as UTF-8, and the file was written in a different encoding. Fixing that is only the first step: semicolon separators, decimal commas, day-first dates and ID numbers that lose their leading zeros are usually next in line. This guide covers how to diagnose the error, read the file with the right parameters, write files that Excel opens without broken characters, repair text that has already been garbled (mojibake), and avoid the case-mapping and sorting traps of Turkish text after import. Our example is a Turkish export, which adds a few traps of its own (six letters that Latin-1 does not have, plus the dotted and dotless i), but the diagnosis works the same way for French, German or Spanish files in cp1252. We reproduced every error message and output below on 27 October 2026 with our own sample files, pandas 2.3.3 and 3.1.0 on Python 3.11, and the macOS command-line tools, and we point out where the two pandas versions behave differently.
What the error is telling you
Unless you say otherwise, read_csv() decodes a file as UTF-8 (the encoding parameter defaults to 'utf-8'). In UTF-8, ASCII characters (English letters, digits, punctuation) take one byte, while letters such as ş, ı or é take a two-byte sequence that has to follow strict rules. Older Windows software, and Excel's plain "CSV" save on a Turkish system, write text in the Windows-1254 code page (cp1254) instead, one byte per letter. When the UTF-8 decoder hits one of those single bytes, the sequence breaks the rules and decoding stops. How to read the message:- byte 0xfe: the byte that could not be decoded. In cp1254, 0xFE is ş.
- in position 6: the byte's offset, counting from 0, within the piece of text pandas was decoding at that moment, which is a read block or a single cell depending on the pandas version and engine. In our small sample it happens to equal the offset in the file and points at the ş in the header word "Sipariş" (order); in large files it does not.
- invalid start byte / invalid continuation byte: which UTF-8 rule the byte broke. For diagnosis, the byte value is what matters.
Byte values of the Turkish letters, checked in Python:
| Letter | cp1254 and ISO-8859-9 | UTF-8 | In cp1252 and Latin-1 |
|---|---|---|---|
| ı / İ | FD / DD | C4 B1 / C4 B0 | Missing; the same bytes are ý / Ý |
| ş / Ş | FE / DE | C5 9F / C5 9E | Missing; the same bytes are þ / Þ |
| ğ / Ğ | F0 / D0 | C4 9F / C4 9E | Missing; the same bytes are ð / Ð |
| ü / Ü | FC / DC | C3 BC / C3 9C | Same byte, same letter |
| ö / Ö | F6 / D6 | C3 B6 / C3 96 | Same byte, same letter |
| ç / Ç | E7 / C7 | C3 A7 / C3 87 | Same byte, same letter |
So if the message names 0xfd, 0xfe, 0xdd, 0xde, 0xf0 or 0xd0, the file is almost certainly in a Turkish Windows encoding: those bytes are ı, ş, İ, Ş, ğ and Ğ. A byte such as 0xfc (ü), 0xf6 (ö) or 0xe7 (ç) proves less, because it stands for the same letter in the Western European code pages cp1252 and Latin-1. In fact cp1252 and cp1254 differ in only eight byte positions: the six Turkish letters above, plus 0x8E and 0x9E, which are Ž and ž in cp1252 and unassigned in cp1254.
Common messages and what to try first:
| Message | Likely cause | Try first |
|---|---|---|
| 'utf-8' codec can't decode byte 0xfd (0xfe, 0xdd, 0xde, 0xf0, 0xd0) | Turkish Windows file (cp1254) | encoding='cp1254' |
| Same error with 0xe9, 0xf1, 0xfc, 0xf6 or 0xe7 | A single-byte Windows or ISO file | cp1252 for Western European data, cp1254 for Turkish data |
| Same error with 0x80, 0x92, 0x93, 0x94 or 0x96 | Euro sign, Windows smart quotes or dashes | cp1252 or cp1254 |
| byte 0xff in position 0 | UTF-16 file (Excel's "Unicode Text") | encoding='utf-16', sep='\t' |
| byte 0xd0 in position 0 | Legacy .xls workbook renamed to .csv | pd.read_excel() |
| Error at a small position, file starts with "PK" | .xlsx workbook renamed to .csv | pd.read_excel() |
| 'charmap' codec can't decode byte 0x9e | You forced cp1254 on a UTF-8 file (the UTF-8 bytes of Ş and Ğ end in 0x9E) | encoding='utf-8-sig' |
Diagnose before you guess
Instead of trying encodings at random, look at the raw bytes. Do not take the position in the error message at face value, though. In our test, the ğ in a "Muğla" cell on the last line of a 2.7 MB file sat at byte offset 2,688,910, yet pandas 3.1 reported "position 2" (the offset inside the cell) and pandas 2.3 reported "position 67470" (the offset inside a 256 KB read block). Let Python find the real offset:
Python:
with open("orders.csv", "rb") as f:
raw = f.read()
print(raw[:4]) # file signature or BOM
try:
raw.decode("utf-8")
print("The whole file is valid UTF-8")
except UnicodeDecodeError as e: # e.start: the byte's real offset in the file
print(e.start, raw[max(0, e.start - 15):e.start + 15])
Code:
b'Sipa'
6 b'Sipari\xfe No;Tarih;\xdeehi'
The 6 is the real file offset of the offending byte. \xfe is ş and \xde is Ş, so the file is cp1254. The first bytes also tell you what kind of file you actually have:
- EF BB BF: UTF-8 byte order mark (BOM). Excel's "CSV UTF-8" save starts like this.
- FF FE: UTF-16 LE, which is Excel's "Unicode Text" save; columns are tab-separated.
- 50 4B 03 04 ("PK"): a zip archive, in other words an .xlsx. Use pd.read_excel() (needs openpyxl).
- D0 CF 11 E0: the legacy .xls format. pd.read_excel() reads it with the xlrd package.
- <html or <table: the "export to Excel" button of some systems produces an HTML table. read_csv() may turn such a file into a meaningless table without raising anything; in our test the whole HTML document became a single column name. Use pd.read_html(), which returns a list of DataFrames.
The same checks from a terminal:
Bash:
xxd -l 48 orders.csv # or: hexdump -C orders.csv | head -3
file orders.csv
Code:
00000000: 5369 7061 7269 fe20 4e6f 3b54 6172 6968 Sipari. No;Tarih
00000010: 3bde 6568 6972 3bdc 72fc 6e3b 4164 6574 ;.ehir;.r.n;Adet
00000020: 3b54 7574 6172 0d0a 3030 3132 353b 3033 ;Tutar..00125;03
orders.csv: ISO-8859 text, with CRLF line terminators
To look around a specific offset in a large file, give xxd a start position: xxd -s 2688900 -l 32 large.csv.
"ISO-8859 text" only means the file is not UTF-8 and uses some single-byte encoding; file cannot tell single-byte code pages apart. For another cp1254 sample of ours that contained smart quotes, it answered "Non-ISO extended-ASCII text". The MIME output (file -I on macOS, file -i on Linux) calls the same file "charset=iso-8859-1" and does not show a BOM at all. Trust file only to separate "UTF-8", "UTF-8 (with BOM)", "UTF-16" and "not UTF-8". "with CRLF line terminators" (0d 0a in the xxd dump) is an extra hint that the file came from Windows.
The charset-normalizer and chardet libraries give hints for files from unknown sources; they do not give answers. In our test, charset-normalizer labelled small cp1254 samples as iso8859_10, cp1250 and cp1257 and identified cp1254 only for our large 15 MB test file. chardet said Windows-1254, but with a confidence below 0.06. Treat their output as a candidate and check the decoded text with your own eyes.
Reading the file with the right encoding
For Turkish data, two encodings cover almost every case:
Python:
import pandas as pd
# Plain "CSV" saved by Excel on Turkish Windows, older ERP and bank exports
df = pd.read_csv("orders.csv", sep=";", encoding="cp1254")
# Excel's "CSV UTF-8" save and most web systems (with or without a BOM)
df = pd.read_csv("orders_utf8.csv", sep=";", encoding="utf-8-sig")
Things to keep in mind:
- cp1254 or ISO-8859-9? The Turkish letters sit on the same bytes in both. The difference is the 0x80-0x9F range: cp1254 puts €, “ ”, ‘ ’, –, — and … there, while in ISO-8859-9 those bytes are invisible control characters. In our test, a row containing “Özel” seri – 2026 and € 12,50 silently had its quotes, dash and euro sign turned into invisible control characters when read as ISO-8859-9. Use cp1254 for anything from Windows or Excel. The same logic applies to Western European data: cp1252 rather than Latin-1. Python also accepts the name windows-1254, and latin5 is an alias of ISO-8859-9.
- utf-8-sig handles both cases. It drops a BOM when there is one and reads plain UTF-8 when there is not. In our tests pandas stripped the BOM by itself even with encoding='utf-8', but Python's csv module and open() do not: csv.DictReader read the first column name as '\ufeffSipariş No', and row["Sipariş No"] raised KeyError.
- If the first column name starts with , a UTF-8 file with a BOM was read as cp1252, cp1254 or Latin-1. Switch to utf-8-sig.
- encoding_errors is for exploration only. Added in pandas 1.3, it controls what happens to undecodable bytes. 'replace' substitutes � (U+FFFD) for each one and 'ignore' silently drops it. In our test the column names became "Sipari� No" and "�ehir" with 'replace', and "Sipari No" and "ehir" with 'ignore'. Fine for a quick look; in production code it means lost data and merge keys that no longer match.
- encoding='latin-1' silences the error without fixing it. Latin-1 maps every byte to some character, so it never fails. On a Turkish file the column "Şehir" comes back as "Þehir" and "İstanbul" as "Ýstanbul".
A small helper that tries candidates in a sensible order for files of unknown origin:
Python:
import codecs
def sniff_encoding(path, candidates=("utf-8", "cp1254"), chunk_size=1 << 20):
"""Check the BOM and file signature first, then return the first
candidate encoding that decodes the whole file without errors."""
with open(path, "rb") as f:
head = f.read(4)
if head.startswith(codecs.BOM_UTF8):
return "utf-8-sig"
if head.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
return "utf-16"
if head.startswith((b"PK\x03\x04", b"\xd0\xcf\x11\xe0")):
raise ValueError(f"{path}: this is an Excel workbook, use pd.read_excel()")
for enc in candidates:
decoder = codecs.getincrementaldecoder(enc)()
try:
with open(path, "rb") as f:
while block := f.read(chunk_size):
decoder.decode(block)
decoder.decode(b"", final=True)
return enc
except UnicodeDecodeError:
continue
raise ValueError(f"{path}: none of {candidates} decodes this file")
print(sniff_encoding("orders.csv")) # cp1254
The order is deliberate. UTF-8 is strict, and a single-byte Turkish file practically never passes UTF-8 validation, so UTF-8 goes first. cp1254 decodes almost any byte sequence, so it goes last, and "decoded without errors" does not prove it is the right answer: a Polish cp1250 file usually passes too. That is why the decoded text still needs the mojibake check further down. The helper decodes the entire file in 1 MB blocks, because a single Turkish letter on line two million is enough to fool a test that only looks at the first few kilobytes. For Western European data, use ("utf-8", "cp1252") as the candidates.
The European Excel trio: separator, decimal and dates
On Windows, Excel takes its CSV separators from the regional settings. Locales that write numbers with a decimal comma, such as Turkish, German or French, use a semicolon as the list separator. With the Turkish format you get ";" between fields, "," as the decimal separator and "." for thousands. The plain "CSV (Comma delimited)" save is written in the system's ANSI code page (cp1254 on Turkish Windows, cp1252 on most Western European systems). "CSV UTF-8 (Comma delimited)" adds a BOM but keeps the semicolon. Because all of this comes from Windows settings, the same workbook can produce two different files on two different PCs. We covered the Tableau side of the same problem in setting up separators, date and decimal symbols correctly in Tableau.Our complete sample file (order number, date, city, product, quantity, amount):
Code:
Sipariş No;Tarih;Şehir;Ürün;Adet;Tutar
00125;03.01.2026;İstanbul;Çelik rulman;12;15.006,00
00126;15.01.2026;Iğdır;Şerit LED;3;1.049,70
00127;28.02.2026;Çanakkale;Ölçüm probu;1;12.480,00
00128;02.03.2026;Şanlıurfa;Güç kaynağı;5;4.495,00
Reading it correctly:
Python:
df = pd.read_csv(
"orders.csv",
sep=";",
encoding="cp1254",
decimal=",",
thousands=".",
dtype={"Sipariş No": str},
parse_dates=["Tarih"],
date_format="%d.%m.%Y",
)
print(df.dtypes)
Code:
Sipariş No str
Tarih datetime64[us]
Şehir str
Ürün str
Adet int64
Tutar float64
dtype: object
That output is from pandas 3.1. pandas 3.0 made a dedicated str dtype the default for text columns and switched dates parsed from text to microsecond resolution (datetime64[us]); pandas 2.x shows the same values as object and datetime64[ns]. date_format arrived in pandas 2.0; on older versions use dayfirst=True.
Traps we hit in testing:
- thousands='.' applies to every column. If you do not parse the date column, "03.01.2026" turns into the integer 3012026. A product code written as "1.234" became 1234 and a version string "2.1.0" became 210. Read such columns with dtype=str and parse dates explicitly.
- parse_dates without a format can produce wrong dates silently. In a file where every day was 12 or lower, "03.01.2026" was read as 1 March 2026, month first. In a file that also had days above 12, the column simply stayed text, with no warning. date_format="%d.%m.%Y" fixes both.
- One invalid date leaves the whole column as text. A file containing "31.02.2026" raised no error; the date column just came back as strings. Find the offending rows with pd.to_datetime(df["Tarih"], format="%d.%m.%Y", errors="coerce").isna().
- Values with a currency, such as "1.250,50 TL", stay text. Strip the unit and the separators, then convert:
Python:
df = pd.read_csv("amounts.csv", sep=";", encoding="cp1254", dtype={"Tutar": str})
amount = (df["Tutar"].str.replace("TL", "", regex=False).str.strip()
.str.replace(".", "", regex=False)
.str.replace(",", ".", regex=False))
df["Tutar"] = pd.to_numeric(amount) # "1.250,50 TL" -> 1250.5
With sep=None, pandas guesses the separator with Python's csv.Sniffer. The C engine does not support this, so pandas switches to the Python engine and emits a ParserWarning unless you pass engine="python". Sniffing works on clean files, but it failed twice in our tests. On a file that started with a title line such as "Rapor: Ocak 2026" (report: January 2026), it picked the space as the separator. On a single-column list of cities, it chose the letter "r" and split "Bursa" into "Bu" and "sa". Use sep=None for exploration; in production, set the separator explicitly or restrict the sniffer to real candidates: csv.Sniffer().sniff(sample, delimiters=";,\t|").delimiter. On our file with the title line, the restricted call raised "Could not determine delimiter" instead of picking the space, and an error is better than a quietly wrong separator.
Signs of a wrong separator:
- All the data lands in one column whose name looks like "Şehir;Adet".
- A silent shift. When we read our semicolon file with the default comma, every row contained exactly one decimal comma, so pandas raised nothing: it used the text left of the comma as the row index and the decimals as the value.
- A trailing semicolon on the data rows but not on the header also shifts the first column into the index without a word; index_col=False fixes it. If the header has the trailing semicolon too, you get an empty "Unnamed: 2" column instead.
- If the number of separators varies between rows, you get the "Expected N fields" error described below.
Writing files that Excel opens correctly
For a file that is going to Excel:
Python:
df.to_csv(
"report_excel.csv",
sep=";",
decimal=",",
encoding="utf-8-sig",
index=False,
date_format="%d.%m.%Y",
float_format="%.2f",
)
Code:
Sipariş No;Tarih;Şehir;Ürün;Adet;Tutar
00125;03.01.2026;İstanbul;Çelik rulman;12;15006,00
00126;15.01.2026;Iğdır;Şerit LED;3;1049,70
- utf-8-sig writes the BOM. Those three bytes are usually how Excel recognises a double-clicked CSV as UTF-8. Without them, Excel generally assumes the ANSI code page and shows "ı" or "Ü" in place of Turkish letters. Newer builds may behave differently depending on version and regional settings, but the BOM works everywhere. To open a BOM-less file you already have, use Data > From Text/CSV in Excel and pick 65001: Unicode (UTF-8) as the file origin.
- A string float_format works together with decimal="," and produces 15006,00. A callable float_format ignores decimal; in our test the output was 15006.00.
- index=False keeps an extra, unnamed first column out of the spreadsheet.
- If the file goes to a database, another Python script or a Linux tool rather than Excel, write plain UTF-8 without a BOM, with a comma or tab separator; the BOM and the semicolon are Excel-specific needs. The same rules apply to MATLAB exports, see how to export workspace data to CSV in MATLAB.
- Careful if a legacy system insists on cp1254: Python's cp1254 codec has no Turkish lira sign ₺ (U+20BA). In our test to_csv(encoding="cp1254") failed with "'charmap' codec can't encode character '\u20ba'" and left a half-written file containing only the header row. errors="replace" writes ? instead. The cleanest fix is to replace ₺ with "TL" before writing.
Where you can, skip CSV and write .xlsx directly:
Python:
df.to_excel("report.xlsx", index=False, sheet_name="Orders") # needs openpyxl
back = pd.read_excel("report.xlsx", dtype={"Sipariş No": str})
An .xlsx file stores text as Unicode and numbers as numbers, so encoding, separator and decimal problems disappear, and Excel displays numbers in each user's regional format. Without openpyxl installed you get "No module named 'openpyxl'". The string "00125" stayed a text cell in Excel, but reading it back without dtype turned it into 125 again, so pass dtype when reading as well. Dates are written as cells formatted "YYYY-MM-DD HH:MM:SS". In our test with pandas 3.1 and openpyxl, neither the date_format nor the datetime_format argument of ExcelWriter changed that format. If you need a day-first display, change the cell format with openpyxl after writing:
Python:
from openpyxl import load_workbook
wb = load_workbook("report.xlsx")
ws = wb["Orders"]
for (cell,) in ws.iter_rows(min_row=2, min_col=2, max_col=2): # column B: Tarih
cell.number_format = "DD.MM.YYYY"
wb.save("report.xlsx")
Repairing mojibake
The best repair is to re-read the source file with the correct encoding. If the source is gone, for example because the garbled text is already in a database, first identify which mistake was made:| You see | Should be | What happened | Repair |
|---|---|---|---|
| ü ö ç ı İ ÅŸ ÄŸ | ü ö ç ı İ ş ğ | A UTF-8 file was read as cp1252, cp1254 or Latin-1 | encode("cp1252").decode("utf-8") or ftfy |
| ý Ý þ Þ ð Ð (with ü, ö, ç intact) | ı İ ş Ş ğ Ğ | A cp1254 file was read as Latin-1 or cp1252 | encode("latin-1").decode("cp1254") or a six-letter table |
| � or missing letters | - | encoding_errors='replace' or 'ignore' was used | Not repairable; re-read the source |
Python:
def undo_utf8_as_cp1252(text):
"""'İstanbul' -> 'İstanbul'. Leaves correct text untouched."""
if not isinstance(text, str):
return text
for wrong in ("cp1252", "latin-1"):
try:
return text.encode(wrong).decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
continue
return text
LATIN1_TO_TR = str.maketrans("ÝýÞþÐð", "İıŞşĞğ")
# Pattern 1 (ü, ı, ş):
df["Şehir"] = df["Şehir"].map(undo_utf8_as_cp1252)
# Pattern 2 (Ý, ý, Þ):
df["Şehir"] = df["Şehir"].str.translate(LATIN1_TO_TR)
The try/except is what protects good data. Correctly written Turkish text cannot make the round trip: "Muğla" cannot be encoded in cp1252 (it has no ğ), and "Çanakkale" encoded that way is not valid UTF-8. In both cases the function returns the value unchanged, and in our test column that mixed broken and correct values, only the broken ones changed. Apply the six-letter table only if you are sure the column contains no genuine Icelandic letters (ý, þ, ð); in Turkish data that is nearly always the case.
The ftfy library (pip install ftfy) recognises pattern 1 on its own: ftfy.fix_text("IÄŸdır") returns "Iğdır", and ftfy.fix_and_explain() shows which steps it applied. In our test (ftfy 6.3.1) it left pattern 2 alone, though. "Ýstanbul" and "Þanlýurfa" are made of valid Latin letters, so ftfy has no way of knowing they were meant to be Turkish; use the table above for that pattern.
Turkish text traps after import
Upper and lower case
Python's str.upper() and str.lower() apply language-independent Unicode rules and ignore the operating system's locale:
Python:
>>> "izmir".upper()
'IZMIR'
>>> "IĞDIR".lower()
'iğdir'
>>> len("İ".lower()) # 'i' + combining dot above (U+0307)
2
Turkish has two i's: dotted i/İ and dotless ı/I, so the upper case of i is İ and the lower case of I is ı, while Python maps i to I and I to i. The fix is to map those four letters explicitly before changing case:
Python:
TR_UPPER = str.maketrans({"i": "İ", "ı": "I"})
TR_LOWER = str.maketrans({"I": "ı", "İ": "i"})
def tr_upper(s):
return s.translate(TR_UPPER).upper()
def tr_lower(s):
return s.translate(TR_LOWER).lower()
df["city_upper"] = df["Şehir"].str.translate(TR_UPPER).str.upper() # İSTANBUL, IĞDIR
df["city_lower"] = df["Şehir"].str.translate(TR_LOWER).str.lower() # istanbul, ığdır
A pandas 3 detail: with pyarrow installed, the default str dtype is backed by pyarrow. In our test its str.lower() turned "İzmir" into the 5-character "izmir", while the same call on an object column produced the 6-character "i̇zmir", exactly like Python. The same line of code can give different results depending on the dtype; the explicit mapping gives the same result on both.
casefold() is not Turkish-aware either: "İSTANBUL".casefold() == "istanbul".casefold() returns False, and "IĞDIR".casefold() gives "iğdir". For case-insensitive comparisons and merge keys, apply tr_lower() to both sides.
Sorting in Turkish alphabetical order
Default sorting follows Unicode code points, which pushes values starting with Ç, Ö, Ş, Ü and İ behind Z. In our test the city list ended with "... Zonguldak, Çanakkale, Ödemiş, Ünye, İzmir, Şanlıurfa". A simple, portable fix:
Python:
ALPHABET = " 0123456789abcçdefgğhıijklmnoöpqrsştuüvwxyz"
ORDER = {letter: i for i, letter in enumerate(ALPHABET)}
NO_CIRCUMFLEX = str.maketrans("âîû", "aiu")
def tr_sort_key(s):
return [ORDER.get(ch, 100 + ord(ch)) for ch in tr_lower(s).translate(NO_CIRCUMFLEX)]
df = df.sort_values("Şehir", key=lambda col: col.map(tr_sort_key))
The key sorts spaces and digits before letters (so "Ali Rıza" comes before "Alican") and treats the circumflex letters in names such as Kâhta or Kâzım like their plain forms. Characters missing from the list (punctuation, foreign letters) go to the end.
locale.setlocale(locale.LC_COLLATE, "tr_TR.UTF-8") combined with key=lambda col: col.map(locale.strxfrm) gave the same correct order in our macOS test. On Linux the Turkish locale has to be installed, Windows uses different locale names, and setlocale changes state for the whole process. If you need complete collation rules, the Collator class of the PyICU library is the most thorough option.
Unicode normalisation: NFC and NFD
Two strings that look identical can be made of different code points: "ş" can be one character (NFC) or "s" plus a combining cedilla (NFD). In our test, "Şişli Güneşli" was 13 characters in NFC and 17 in NFD. The two values looked the same on screen, compared unequal with ==, and nunique() counted 2. Normalise text that comes from file names, PDFs or other systems before you join on it:
Python:
df["Şehir"] = df["Şehir"].str.normalize("NFC").str.strip()
Dotless ı has no decomposed form, while İ decomposes into I plus U+0307, the combining dot above.
Other common read_csv errors
ParserError: Expected N fields in line M, saw K
The full message reads "Error tokenizing data. C error: Expected 2 fields in line 3, saw 3" and means a row has more fields than the header. The usual causes are a wrong separator, an unquoted decimal comma in a comma-separated file ("Defter,24,5"), or a quoting problem. The on_bad_lines parameter, added in pandas 1.3 (the old error_bad_lines and warn_bad_lines were removed in 2.0), offers three options: 'warn' skips the row with "Skipping line 3: expected 2 fields, saw 3", 'skip' drops it silently, and a function passed together with the Python engine can repair it:
Python:
def repair(row): # ['Defter', '24', '5'] -> ['Defter', '24.5']
return [row[0], row[1] + "." + row[2]]
df = pd.read_csv("prices.csv", on_bad_lines=repair, engine="python")
The C engine rejects a function with "on_bad_line can only be a callable function if engine='python' or 'pyarrow'". If you skip rows, count them: a report built silently on incomplete data costs more than an error.
ParserError: EOF inside string
When a field starts with a quote character that never closes (for example a cell holding "3/4 with no closing quote), pandas treats everything up to the end of the file as quoted text and fails with "EOF inside string starting at row 1". A quote in the middle or at the end of a field (3/4") caused no problem in our test. If you cannot fix the source, read with quoting=csv.QUOTE_NONE (or 3) so quotes become ordinary characters; they stay in the data.DtypeWarning: Columns (...) have mixed types
By default (low_memory=True) the C engine processes a large file in chunks and infers each chunk's types separately. In our 400,000-row test file, the Kod (code) column held numbers in the first 300,000 rows and values like "A300000" after that; 262,144 values came back as int and 137,856 as str. Numeric-looking codes were numbers in the first block and strings in the second, so the filter df["Kod"] == "10005" found no rows at all (10005 had been read as a number in the first block). If the same value occurs in both blocks, you get only some of its matches. Since pandas 3.0 the warning names the column ("Columns (0: Kod)"), while 2.x only gives its position ("Columns (0)"). The fix is dtype={"Kod": str}; low_memory=False is the second option.Lost leading zeros
Values like "00125" are taken for numbers and become 125. Always read order, customer, tax and national ID numbers, postcodes, phone numbers and IBANs with dtype=str. Excel has the same problem: with default settings, it drops leading zeros when you double-click a CSV and replaces digits beyond the 15th with zeros in long numbers. Opening the file in Excel and saving it again makes that loss permanent. In Microsoft 365 and Excel 2024 you can switch both conversions off under File > Options > Data > Automatic Data Conversion.Large files
Python:
total = 0.0
for chunk in pd.read_csv("large.csv", sep=";", encoding="cp1254",
decimal=",", thousands=".",
usecols=["Kod", "Şehir", "Tutar"],
dtype={"Kod": str},
chunksize=100_000):
total += chunk["Tutar"].sum()
- usecols and dtype cut the data you read and the memory you use. For columns with few distinct values, use dtype="category". In our 400,000-row file, making the city column categorical reduced memory use from 26.5 MB to 20.7 MB on pandas 3.1 and from 114.8 MB to 79.5 MB on pandas 2.3. The big gap between the versions comes from the pyarrow-backed str dtype that pandas 3 uses when pyarrow is installed.
- engine="pyarrow" (added in pandas 1.4) is multithreaded but does not support every option. In our test thousands, dayfirst and chunksize raised ValueError, and encoding_errors="replace" did not prevent the UnicodeDecodeError. date_format worked fine.
- pandas 3.1 (22 October 2026) reads local, uncompressed files with at least 5 MB of data across several CPU cores with the default C engine; the mode.max_threads option sets the number of threads. The speed-up does not apply to non-UTF-8 encodings, or when options such as parse_dates, usecols or chunksize are passed. If you read the same cp1254 file often, convert it to UTF-8 once:
Bash:
iconv -f CP1254 -t UTF-8 large.csv > large_utf8.csv
A robust import function
Here is everything above in one function. It determines the encoding, reads with Turkish Excel defaults, confirms that the date columns really became dates and that the text carries no mojibake markers, and raises an error instead of carrying on quietly when something is off.
Python:
SUSPECT = "ÃÄÅÝýÞþÐð\ufffd"
def mojibake_report(df, rows=1000):
text = " ".join(map(str, df.columns))
obj = df.select_dtypes(include=["object", "string"]).head(rows)
if not obj.empty:
text += " " + " ".join(obj.astype(str).to_numpy().ravel())
return {ch: text.count(ch) for ch in SUSPECT if ch in text}
def read_tr_csv(path, *, text_cols=(), date_cols=(), date_format="%d.%m.%Y", **kwargs):
enc = sniff_encoding(path)
options = dict(sep=";", decimal=",", thousands=".", encoding=enc)
options.update(kwargs)
df = pd.read_csv(
path,
dtype={c: str for c in text_cols} or None,
parse_dates=list(date_cols) or None,
date_format=date_format if date_cols else None,
**options,
)
df.columns = [str(c).strip() for c in df.columns]
for c in date_cols:
if not pd.api.types.is_datetime64_any_dtype(df[c]):
raise ValueError(f"{path}: column {c!r} does not match {date_format}")
suspects = mojibake_report(df)
if suspects:
raise ValueError(f"{path}: read as {enc}, suspicious characters {suspects}")
return df
df = read_tr_csv("orders.csv", text_cols=["Sipariş No"], date_cols=["Tarih"])
For a comma-separated UTF-8 file, call read_tr_csv("data.csv", sep=",", decimal=".", thousands=None); your arguments override the defaults. If a date column does not match the format, or the text shows markers such as Ã, Ä, ý, Þ or �, the function stops and names the file and the encoding it used. Ä and Å are legitimate in German or Scandinavian names, so narrow the SUSPECT string if your data contains them.
Checklist:
- Look at the first bytes: EF BB BF, FF FE, PK, D0 CF or <html.
- Determine the encoding (utf-8-sig first, then cp1254 or cp1252) and eyeball the decoded text.
- Pass sep, decimal and thousands explicitly for each source; do not rely on sep=None in production.
- Read code and number columns with dtype=str.
- Parse dates with date_format and check that the column really is datetime.
- Compare the row count with the source; if you used on_bad_lines, count the skipped rows.
- Search for mojibake markers: Ã, Ä, Å, ý, Þ, ð, �.
- Normalise key columns: strip(), NFC, Turkish-aware lower case.
- For files going to Excel use sep=";", decimal=",", encoding="utf-8-sig", index=False; send other systems UTF-8 without a BOM.
FAQ
encoding='latin-1' makes the error go away. Why not just use it?Latin-1 maps every byte to a character, so it never fails. On a Turkish file it reads ı, ş, ğ, İ, Ş and Ğ as ý, þ, ð, Ý, Þ and Ð. The error disappears, the data is damaged, and the damage usually surfaces weeks later in a report.
Should I use cp1254 or ISO-8859-9 (cp1252 or Latin-1)?
For anything from Windows or Excel, the Windows code page: cp1254 for Turkish, cp1252 for Western European data. The letters are identical, and the Windows variants also decode the euro sign, smart quotes, dashes and the ellipsis correctly.
Is it enough to open the file in Excel and save it as "CSV UTF-8"?
It fixes the encoding, but the separator and decimal still follow the regional settings, so you may still need sep=";" and decimal="," in pandas. More importantly, with default settings Excel drops leading zeros and truncates long numbers when it opens the file, so the re-saved file no longer matches the source.
pandas already strips the BOM. Why bother with utf-8-sig?
Because the same file may also be read by the csv module, open() or another tool, and they do not strip it. utf-8-sig reads BOM-less UTF-8 without trouble, which makes it a safe default for any file that might be UTF-8.
My old CSV script broke after upgrading to Python 3.15. Why?
Python 3.15 made UTF-8 mode the default (PEP 686): open() without an encoding argument now uses UTF-8 on every operating system. Before that, the default on Turkish Windows was cp1254, which is why scripts that read Excel CSVs without an encoding used to work. read_csv() already assumes UTF-8 regardless of the locale, so pandas is not affected. The temporary workaround is the PYTHONUTF8=0 environment variable; the lasting fix is to pass encoding explicitly in every open() and read_csv() call.
Conclusion and next steps
UnicodeDecodeError is annoying but harmless: it stops the job and tells you plainly that pandas read the file with the wrong encoding. The dangerous cases are the ones that raise nothing: columns shifted by a wrong separator, dates with day and month swapped, codes turned into numbers and letters quietly garbled. Look at the bytes first, state the encoding and separators explicitly, then validate the result.- Today: add an explicit encoding to every read_csv() and open() call in your project.
- This week: route imports through one validating function like read_tr_csv() and log the row counts.
- Next: ask your data providers for UTF-8 with a BOM or for .xlsx, and write everything that goes to Excel with utf-8-sig.