Türkçe Veride Kodlama ve Normalizasyon Tuzakları
Türkçe veride Windows-1254/UTF-8 karmaşası, BOM'lu CSV, ondalık virgül, gg.aa.yyyy tarihleri ve İ/ı tuzağı için çalışan Python kodlarıyla kalıcı çözümler.
Türkçe veri ile çalışan herkesin başına gelmiştir: müşteriden gelen CSV açılır, "Ş" harfleri "Þ" olmuş, "ı" harfleri soru işaretine dönmüş, tutar sütunundaki "1.250,00" değeri 1250000 diye okunmuş, tarih sütununda ise gün ile ay yer değiştirmiş. Bunların her biri tek başına küçük görünen ama birleşince koca bir raporu çöpe çeviren kodlama ve normalizasyon hatalarıdır. Kurumsal veri hatlarında en çok zaman kaybettiren konunun bu olduğunu düşünüyorum; çünkü hata sessizdir, betik çalışır, çıktı üretilir ve sorun ancak birisi rapora bakıp "bu isim böyle yazılmaz" dediğinde fark edilir.
Bu yazıda Türkçe veriye özgü tuzakları tek tek ele alacağım: Windows-1254, ISO-8859-9 ve UTF-8 karmaşası, BOM'lu dosyalar, ondalık ayracı olarak virgül, gg.aa.yyyy biçimindeki tarihler ve elbette meşhur İ/ı büyük-küçük harf sorunu. Her başlıkta önce sorunun neden çıktığını, sonra kalıcı çözümünü ve nerede sınırlara takılacağınızı anlatacağım.
Amacım kopyalayıp kullanabileceğiniz, standart kütüphane ve yaygın paketlerle çalışan kısa betikler vermek. Bu parçaları bir temizleme katmanı olarak veri hattının en başına koymak, sonraki her adımı rahatlatıyor.
Kodlama tespiti: tahmin değil, doğrulama
Türkiye'de üretilen eski dosyaların büyük kısmı Windows-1254 ile kaydedilmiştir. ISO-8859-9 (Latin-5) ile Windows-1254 neredeyse aynı tablodur; fark yalnızca 0x80–0x9F aralığındaki birkaç bayttadır ve gerçek metinde bu baytlar nadiren geçer. Bu yüzden hiçbir tespit aracı ikisini kesin biçimde ayıramaz; chardet veya charset-normalizer size bir olasılık verir, kesin bir cevap değil.
Benim yaklaşımım tespit ile doğrulamayı birlikte kullanmak. Önce dosyayı sıkı kipte UTF-8 olarak çözmeyi denerim; geçerli UTF-8 rastlantı değildir, çözme başarılıysa dosya UTF-8'dir. Başarısızsa cp1254 denerim (tanımsız baytlar varsa o da hata verir), o da olmazsa tahmin kütüphanesine başvururum. Sonuçta her zaman metindeki şüpheli karakterleri raporlarım: "Ã" ile başlayan ikili diziler ve U+FFFD yerine koyma karakteri, dosyanın daha önce yanlış dönüştürüldüğünün en net izidir.
import sys
from pathlib import Path
try:
from charset_normalizer import from_bytes
except ImportError:
from_bytes = None
ADAYLAR = ["utf-8-sig", "utf-8", "cp1254"]
SUPHELI = {
"\ufffd": "yerine koyma karakteri (onceki donusum bozuk)",
"Ã": "mojibake: UTF-8 metin Latin-1 gibi okunmus",
"ı": "mojibake: 'ı' harfi iki bayta bolunmus",
"ÅŸ": "mojibake: 'ş' harfi iki bayta bolunmus",
}
def coz(ham):
"""Metni ve kullanilan kodlamayi dondurur; once dogrulama, sonra tahmin."""
for ad in ADAYLAR:
try:
return ham.decode(ad), ad
except UnicodeDecodeError:
continue
if from_bytes is not None:
en_iyi = from_bytes(ham).best()
if en_iyi is not None:
return str(en_iyi), en_iyi.encoding + " (tahmin)"
return ham.decode("cp1254", errors="replace"), "cp1254 (zorla)"
def rapor(metin):
for no, satir in enumerate(metin.splitlines(), 1):
for kar, aciklama in SUPHELI.items():
if kar in satir:
print(f"satir {no}: {aciklama} | {satir[:60]}")
break
def main(kaynak, hedef):
ham = Path(kaynak).read_bytes()
metin, kodlama = coz(ham)
print(f"tespit: {kodlama}, {len(metin)} karakter")
rapor(metin)
Path(hedef).write_text(metin, encoding="utf-8")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
Betik iki şey yapar: kodlamayı bulup dosyayı UTF-8 olarak yazar ve şüpheli satırları numarasıyla basar. Raporda mojibake görüyorsanız kaynağı kim ürettiyse ondan orijinal dosyayı istemek, düzeltmeye çalışmaktan daha sağlıklıdır; iki kez dönüştürülmüş metni geri çevirmek her zaman mümkün olmaz.
BOM'lu CSV ve noktalı virgül ayracı
Excel "CSV UTF-8" seçeneğiyle kaydettiğinde dosyanın başına üç baytlık bir BOM işareti koyar. Bunu görmezden gelirseniz ilk sütunun adı \ufeffMusteriNo olur ve df["MusteriNo"] KeyError verir. Çözüm basit: dosyayı encoding="utf-8-sig" ile açın; BOM varsa atılır, yoksa normal UTF-8 gibi davranır. Tersine, çıktıyı Excel'de açacak bir kullanıcıya veriyorsanız BOM'u bilerek koymanız gerekir; aksi hâlde Excel Türkçe karakterleri bozuk gösterir. Bunun için df.to_csv(yol, sep=";", decimal=",", encoding="utf-8-sig") yeterlidir.
İkinci tuzak ayraç: Türkçe bölge ayarlı Windows'ta ondalık ayracı virgül olduğu için Excel CSV'yi noktalı virgülle ayırır. pandas tarafında bunların hepsini tek çağrıda halledebilirsiniz:
pd.read_csv(yol, sep=";", decimal=",", thousands=".", encoding="utf-8-sig")dört tuzağı birden kapatır.- Ayraçtan emin değilseniz
csv.Sniffer().sniff(ilk_satirlar)ile koklatın, ama sonucu yine de gözle doğrulayın. - Hücre içinde satır sonu varsa dosyayı
newline=""ile açın; aksi hâldecsvmodülü kayıtları yanlış böler.
Bu adımları bir kalite kapısı olarak otomatikleştirmek isterseniz, Excel kalite kontrol otomasyonu yazısında anlattığım kontrol listesi doğrudan uygulanabilir.
İ/ı tuzağı: str.lower() Türkçe bilmez
Python'un str.lower() ve str.upper() metotları Unicode'un genel kurallarını uygular ve bu kurallar Türkçe için yanlıştır. "I".lower() sonucu "i" olur; oysa Türkçe'de "I" harfinin küçüğü "ı"dır. Tersi de aynı: "i".upper() size "I" verir, "İ" değil. Daha sinsi olanı, "İ".lower() çağrısının tek harf değil iki kod noktası ("i" artı birleşik nokta) döndürmesidir; bu değer ekranda "i" gibi görünür ama "i" == "İ".lower() karşılaştırması False verir ve eşleştirme sessizce kaçar.
Doğru çözüm, dönüşümden önce iki harfi elle eşlemektir: küçültürken I→ı ve İ→i, büyütürken i→İ ve ı→I. Sonra standart metodu çağırın; kalan harfler için Unicode kuralları zaten doğrudur.
def tr_kucult(s):
return s.replace("I", "ı").replace("İ", "i").lower()
def tr_buyult(s):
return s.replace("i", "İ").replace("ı", "I").upper()
print("ISPARTA".lower()) # isparta (yanlis: I -> i)
print(tr_kucult("ISPARTA")) # ısparta
print("izmir".upper()) # IZMIR (yanlis: i -> I)
print(tr_buyult("izmir")) # İZMİR
print(len("İ".lower())) # 2 (i + birlesik nokta)
print(tr_kucult("İstanbul") == "istanbul") # True
casefold() kullanacaksanız aynı ön değişimi yapmanız gerekir; casefold da Türkçe'ye özel davranmaz. Kural şu: metni asla ham hâliyle karşılaştırmayın, her iki tarafı da aynı normalizasyon fonksiyonundan geçirin. Aynı mantığı Unicode birleştirme için de uygulayın; unicodedata.normalize("NFC", s), "ş" harfinin iki ayrı kod noktasıyla yazılmış hâlini tek harfe indirir. Şirket adı gibi alanlarda bu normalizasyon, bulanık eşleştirme yapmadan önce atılması gereken ilk adımdır.
Türkçe alfabetik sıralama
Varsayılan sorted() kod noktası sırasına göre sıralar; "Ç", "Ş", "Ö" gibi harfler Z'den sonra gelir ve "Çanakkale" listenin sonuna düşer. Standart kütüphanedeki yol locale modülüdür: yerel ayarı Türkçe'ye çekip locale.strxfrm fonksiyonunu sıralama anahtarı olarak kullanırsınız. Bunun sınırlarını dürüstçe söylemem gerekir:
- Yerel ayar adı platforma bağlıdır: Linux ve macOS'ta
"tr_TR.UTF-8", Windows'ta"Turkish_Turkey.1254". Yanlış adlocale.Errorfırlatır. - Sunucuda o yerel ayar kurulu olmayabilir; küçültülmüş Docker imajlarında çoğu zaman yoktur.
setlocalesüreç genelinde etki eder ve iş parçacığı güvenli değildir.
Bu yüzden üretim hatlarında harf sırası tablosundan anahtar üreten, hiçbir ortama bağımlı olmayan çözümü tercih ediyorum. Bir önceki bölümdeki tr_kucult ile birleşince büyük-küçük harf farkı da ortadan kalkar:
import locale
# Ortama bagimli yol: yerel ayar kurulu degilse locale.Error firlatir.
for ad in ("tr_TR.UTF-8", "Turkish_Turkey.1254"):
try:
locale.setlocale(locale.LC_COLLATE, ad)
break
except locale.Error:
continue
# Ortamdan bagimsiz yol: harf sirasi tablosundan anahtar uret.
ALFABE = "abcçdefgğhıijklmnoöprsştuüvyz"
SIRA = {harf: i for i, harf in enumerate(ALFABE)}
def tr_anahtar(s):
return [SIRA.get(h, len(ALFABE) + ord(h)) for h in tr_kucult(s)]
iller = ["Şanlıurfa", "İzmir", "Isparta", "Çanakkale", "Adana", "Zonguldak"]
print(sorted(iller)) # Ç, İ, Ş sona duser
print(sorted(iller, key=tr_anahtar)) # Adana, Çanakkale, Isparta, İzmir, ...
Tablo yaklaşımının iki artısı var: aynı anahtar fonksiyonu Linux'ta, Windows'ta ve konteynerde birebir aynı sonucu verir, ve alfabede bulunmayan bir karakter (rakam, tire, yabancı harf) hata fırlatmak yerine listenin sonuna düşer. Eksisi, strxfrm'in yaptığı gibi aksan ve noktalama inceliklerini bilmemesidir; il adı, müşteri adı gibi alanlar için bu yeterli, sözlük düzeyinde bir sıralama gerekiyorsa locale yolunu tercih edin.
Sayı ve tarih ayrıştırma
"1.234,50" değerini float() ile çevirmeye çalışırsanız hata alırsınız; daha kötüsü "1.234" değerini çevirirseniz hata almazsınız ve 1234 yerine 1.234 elde edersiniz. Bu, muhasebe verisinde bin kat sapmaya yol açan klasik tuzaktır. Kural: önce biçimi bir düzenli ifadeyle doğrula, sonra binlik noktaları sil, virgülü noktaya çevir. Tarihlerde ise "01.02.2026" değerini pandas.to_datetime ile biçim vermeden okursanız Ocak mı Şubat mı olduğuna kütüphane karar verir; format ve dayfirst=True vererek bu kararı kendiniz alın. Saf Python'da karşılığı datetime.strptime(s, "%d.%m.%Y") çağrısıdır.
import re
from datetime import datetime
import pandas as pd
BINLIK = re.compile(r"-?\d{1,3}(\.\d{3})+(,\d+)?")
DUZ = re.compile(r"-?\d+(,\d+)?")
def tr_sayi(s):
s = s.strip().replace(" ", "")
if BINLIK.fullmatch(s):
return float(s.replace(".", "").replace(",", "."))
if DUZ.fullmatch(s):
return float(s.replace(",", "."))
raise ValueError(f"Turkce sayi bicimi degil: {s!r}")
print(tr_sayi("1.234.567,89")) # 1234567.89
print(tr_sayi("12,5")) # 12.5
print(datetime.strptime("07.09.2026", "%d.%m.%Y").date()) # 2026-09-07
df = pd.DataFrame({"tutar": ["1.234,50", "987,00"],
"tarih": ["01.02.2026", "15.03.2026"]})
df["tutar"] = df["tutar"].map(tr_sayi)
df["tarih"] = pd.to_datetime(df["tarih"], format="%d.%m.%Y", dayfirst=True)
print(df.dtypes)
Buradaki en önemli tasarım kararı, tr_sayi fonksiyonunun tanımadığı biçimde hata fırlatmasıdır. Sessizce 0 veya None döndüren dönüştürücüler hatalı satırı raporun içine gömer; fırlatan fonksiyon ise sorunu hattın en başında görünür kılar.
Türkçe veri ile ilgili sorunların hiçbiri tek başına zor değil; zor olan, hepsinin aynı dosyada ve sessizce ortaya çıkması. Kodlamayı doğrulayarak tespit eden, BOM ve ayraçları bilinçli ele alan, İ/ı dönüşümünü elle eşleyen, sıralamayı ortamdan bağımsız yapan ve sayı/tarih biçimini varsaymak yerine doğrulayan bir temizleme katmanı, bu tuzakların tamamını hattın girişinde yakalar. Bir kez yazıp her projede yeniden kullandığım bu parçalar, sonraki analizlerin güvenilir olmasının ön koşulu hâline geldi.
Kendi veri hattınızda benzer sorunlarla uğraşıyorsanız ana sayfada sunduğum hizmetlere göz atabilir, /#contact adresinden bana yazabilirsiniz; hangi adımda takıldığınızı birlikte netleştirelim.