DEV Community

serbay
serbay

Posted on

12 silent bugs in software built for Turkish users (İ/ı, ID numbers, holidays)

Most bugs in software for Turkish users don't throw. The code runs, the tests pass, and then a customer sees
"ISTANBUL" instead of "İSTANBUL", a city slug becomes igdr, or a perfectly valid national ID number is rejected by the
checkout form. I build VeriApi.Pro, a data API for Turkey, and these twelve come up
again and again. Every snippet below was run in Node 22, PHP 8.3 or Python 3.12; for each one there is a fix you can
paste into your own code, and where it saves real work, an API endpoint that does it for you.

1. Upper and lower case: "istanbul" → "ISTANBUL"

Turkish has two distinct letters where English has one: dotted i / İ and dotless ı / I. The default case
mapping in every language follows the English rule:

Code Result Correct
JS "istanbul".toUpperCase() ISTANBUL İSTANBUL
JS "IĞDIR".toLowerCase() iğdir ığdır
PHP mb_strtoupper("istanbul") ISTANBUL İSTANBUL
PHP strtoupper("ığdır") ığDıR IĞDIR
Python "İ".lower() i̇ (2 code points: i + combining dot) i

The Python one is the nastiest: it looks right on screen, but len("İ".lower()) is 2, so database lookups and
comparisons quietly fail.

Fix. In JavaScript, pass the locale: toLocaleUpperCase("tr-TR") / toLocaleLowerCase("tr-TR"). In PHP and Python,
map the two pairs yourself before calling the built-in function:

def tr_upper(s: str) -> str:
    return s.replace("i", "İ").replace("ı", "I").upper()

def tr_lower(s: str) -> str:
    return s.replace("I", "ı").replace("İ", "i").lower()

tr_upper("istanbul ığdır")   # 'İSTANBUL IĞDIR'
tr_lower("İSTANBUL IĞDIR")   # 'istanbul ığdır'
Enter fullscreen mode Exit fullscreen mode
<?php
function tr_upper(string $s): string {
    return mb_strtoupper(str_replace(['i', 'ı'], ['İ', 'I'], $s), 'UTF-8');
}
function tr_lower(string $s): string {
    return mb_strtolower(str_replace(['I', 'İ'], ['ı', 'i'], $s), 'UTF-8');
}
Enter fullscreen mode Exit fullscreen mode

Only apply these to Turkish text: on English input they would turn I into ı.

2. Sorting: Ç, Ö, Ş and Ü end up after Z

A plain sort() orders by code point, and the Turkish letters live outside basic Latin:

["Zeynep", "Çağla", "Ceren", "Ömer", "Oya", "Şule", "İlker", "Irmak"].sort();
// Ceren, Irmak, Oya, Zeynep, Çağla, Ömer, İlker, Şule   ← wrong
Enter fullscreen mode Exit fullscreen mode

Fix.

  • JavaScript: list.sort(new Intl.Collator("tr").compare) → Ceren, Çağla, Irmak, İlker, Oya, Ömer, Şule, Zeynep.
  • MySQL / MariaDB: ORDER BY name COLLATE utf8mb4_turkish_ci (the default general_ci / unicode_ci don't follow the Turkish alphabet).
  • PHP: with the intl extension, (new Collator('tr_TR'))->sort($list).
  • Python: there is no Turkish collation in the standard library; for small lists a key function is enough:
ALPHABET = "abcçdefgğhıijklmnoöprsştuüvyz"

def tr_key(s: str):
    s = s.replace("I", "ı").replace("İ", "i").lower()
    return [ALPHABET.index(c) if c in ALPHABET else 100 + ord(c) for c in s]

sorted(names, key=tr_key)
Enter fullscreen mode Exit fullscreen mode

3. Slugs and ASCII: "Iğdır" → "Igdr"

The popular "strip the accents" trick doesn't know ı, because ı is not an accented i; it's a separate letter with
no decomposition:

import unicodedata
unicodedata.normalize("NFKD", "Iğdır Şişli").encode("ascii", "ignore").decode()
# 'Igdr Sisli'   ← the ı is gone
Enter fullscreen mode Exit fullscreen mode

The JavaScript equivalent (normalize("NFKD").replace(/[̀-ͯ]/g, "")) leaves Igdır, so a non-ASCII ı
ends up in your URL.

Fix. Map the six letters explicitly, then lower-case:

const TR = { ç: "c", ğ: "g", ı: "i", İ: "i", ö: "o", ş: "s", ü: "u", Ç: "c", Ğ: "g", Ö: "o", Ş: "s", Ü: "u" };
const slug = (s) => s.replace(/[çğıİöşüÇĞÖŞÜ]/g, (ch) => TR[ch]).toLowerCase()
  .replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");

slug("Iğdır Şişli"); // "igdir-sisli"
Enter fullscreen mode Exit fullscreen mode

If you'd rather not maintain this, the Turkish text endpoint
returns upper, lower, title case, ASCII and slug in one call (free plan):

curl -H "X-API-Key: $VERIAPI_KEY" \
  "https://api.veriapi.pro/v1/araclar/metin?metin=istanbul%20%C4%B1%C4%9Fd%C4%B1r"
# "buyuk": "İSTANBUL IĞDIR", "baslik": "İstanbul Iğdır", "ascii": "istanbul igdir", "slug": "istanbul-igdir"
Enter fullscreen mode Exit fullscreen mode

4. Text typed without Turkish letters: "Bugun cok guzel"

Old keyboards, SMS imports and some forms produce Turkish written in plain ASCII. A word list won't fix it: cok is
always çok, but many words depend on context. A context-aware "deasciifier" works much better. Our
/v1/araclar/metin/turkce-duzelt endpoint (paid
plans) returns the corrected text plus a list of every change, so you can review it:

{
  "metin": "Düzce'de bugün hava çok güzel, öğle yemeğinde köfte yedik",
  "degisen_kelime": 7,
  "degisiklikler": [
    { "girdi": "bugun", "cikti": "bugün" },
    { "girdi": "yemeginde", "cikti": "yemeğinde" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

On our 138-sentence test set, 98.96 % of words come out right (48 % if you leave the text untouched). E-mail addresses,
URLs and words with digits are left alone.

5. Parsing numbers: "1.250,50" → 1.25

In Turkish the thousands separator is a dot and the decimal separator is a comma. Parse user input naively and amounts
silently shrink a thousand times:

Code Result
JS parseFloat("1.250,50") 1.25
JS Number("1.250,50") NaN
PHP (float) "1.250,50" 1.25

Fix. Whichever separator comes last is the decimal one:

function parseAmount(s) {
  s = s.trim();
  if (s.lastIndexOf(",") > s.lastIndexOf(".")) {
    s = s.replace(/\./g, "").replace(",", ".");   // 1.250,50
  } else {
    s = s.replace(/,/g, "");                       // 1,250.50
  }
  return Number(s);
}

parseAmount("1.250,50"); // 1250.5
parseAmount("1,250.50"); // 1250.5
Enter fullscreen mode Exit fullscreen mode

A single separator ("1.250") is genuinely ambiguous; take kuruş in a separate field or force one format with
inputmode="decimal". For display, don't format by hand:
new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY" }).format(1250.5) → ₺1.250,50.

6. Amounts in words on invoices

Turkish invoices, cheques and receipts need the amount written out ("YALNIZ: …"). Typical mistakes: "bir bin" instead
of "bin", rounding kuruş wrong, inconsistent spacing. The
amount-in-words endpoint follows official spelling and
also accepts all three number formats from bug 5:

curl -H "X-API-Key: $VERIAPI_KEY" \
  "https://api.veriapi.pro/v1/cevir/yaziyla?tutar=1250.50&para=TRY"
# "metin":  "bin iki yüz elli Türk lirası elli kuruş"
# "fatura": "YALNIZ: BİN İKİ YÜZ ELLİ TÜRK LİRASI ELLİ KURUŞ"
Enter fullscreen mode Exit fullscreen mode

7. Turkish ID numbers (TCKN): the check that rejects valid numbers

The 10th digit of an 11-digit TCKN is (7 × sum of digits 1,3,5,7,9 − sum of digits 2,4,6,8) mod 10. The snippet copied
around the internet forgets that the difference can be negative, and in JavaScript, PHP, Java and C# -29 % 10 is
-9, not 1:

// common code: 19090909018 is valid, but this returns false
(odd * 7 - even) % 10 === d[9]
Enter fullscreen mode Exit fullscreen mode

Python's % always returns a non-negative result, so the same logic works there, which is exactly why teams with a
Python backend and a JS frontend miss it.

Fix. Pull the remainder back into range:

function isValidTckn(no) {
  if (!/^[1-9]\d{10}$/.test(no)) return false;
  const d = [...no].map(Number);
  const odd = d[0] + d[2] + d[4] + d[6] + d[8];
  const even = d[1] + d[3] + d[5] + d[7];
  const c10 = (((odd * 7 - even) % 10) + 10) % 10;
  const c11 = d.slice(0, 10).reduce((a, b) => a + b, 0) % 10;
  return c10 === d[9] && c11 === d[10];
}

isValidTckn("19090909018"); // true
Enter fullscreen mode Exit fullscreen mode

In PHP: ((($odd * 7 - $even) % 10) + 10) % 10; in Java: Math.floorMod(odd * 7 - even, 10).

Second misconception: passing the checksum doesn't mean the number belongs to anyone. The same goes for the 10-digit
company tax number (VKN): 1234567890 passes. Only the official identity / tax services can tell you whether a person
or taxpayer exists, and those need authorised integration.

8. IBAN: a regex is not enough

A Turkish IBAN has 26 characters: TR, 2 check digits, a 5-digit bank code, 1 reserved digit and a 16-digit account
number. A form that only checks ^TR\d{24}$ happily accepts a typo, and the money goes to the wrong account or bounces.

Fix. Do the MOD-97 check after removing spaces and upper-casing. The bank code is iban.slice(4, 9); turning it into
a bank name needs the list of payment-system participants, which our
IBAN endpoint includes:

{
  "iban": "TR950006400000001234567890",
  "gecerli": true,
  "bicimli": "TR95 0006 4000 0000 1234 5678 90",
  "banka": { "kod": "00064", "ad": "T. İŞ BANKASI A.Ş." },
  "hesap_no": "0000001234567890"
}
Enter fullscreen mode Exit fullscreen mode

9. Phone numbers: 0, +90, 0850 and the "operator" trap

The same number arrives as 05321234567, 5321234567, +90 532 123 45 67 or 0090…. Store them as typed and one
customer signs up three times. Two more traps:

  • 0850 numbers are neither mobile nor landline (they're VoIP / corporate lines); 444 numbers are dialled without an area code.
  • You can't tell the current operator from the number. With number portability, a 0532 line may have moved to another carrier, so an "operator" label shown to users is often wrong.

Fix. Store E.164 (+905321234567), display the national format. libphonenumber handles both; the
phone endpoint wraps it with Turkish labels:

{
  "e164": "+908501234567",
  "ulusal": "0850 123 4567",
  "tur": "VOIP",
  "guncel_operator": null
}
Enter fullscreen mode Exit fullscreen mode

10. License plates: Ç, Ş, Q and W

Turkish plates never use Ç, Ğ, İ, Ö, Ş, Ü or Q, W, X. When a user types 34 çab 123, converting is friendlier than
rejecting. And don't validate the province code as "any two digits": only 01–81 exist.

curl -H "X-API-Key: $VERIAPI_KEY" \
  "https://api.veriapi.pro/v1/arac/plaka/dogrula?plaka=34%20%C3%A7ab%20123"
# "plaka": "34 CAB 123", "duzeltme": ["3. karakter Ç → C"], "il": {"plaka": 34, "ad": "İstanbul"}
Enter fullscreen mode Exit fullscreen mode

11. Province and district lists: stale copies

The province/district JSON copied from project to project is usually out of date. Turkey has 81 provinces and 973
districts
. Newer districts (e.g. Tuşba in Van, Derecik in Hakkari) are missing from old lists, metropolitan provinces
no longer have a "Merkez" district, and spellings drift (Afyon / Afyonkarahisar).

Fix. Take the list from one maintained source and store an ID or slug, not the display name. Our
province / district endpoints return all 81 / 973 with
coordinates, and /v1/konumlar/bul?enlem=…&boylam=… resolves a coordinate to the nearest district.

12. Holidays and business days: moving feasts and half days

"Delivered within 3 business days" or "5 business days to object" go wrong in two ways:

  1. Religious holidays move about 11 days earlier every year. A hard-coded date list is wrong the next year. In 2027 Ramazan Bayramı is 9–11 March and Kurban Bayramı 16–19 May.
  2. The eve (arife) is a half day. Government offices close at noon, many companies work the full day. Pick the rule that matches your business. 28 October is also a half day.

The same calculation gives two different dates depending on how you count the eve. Three business days after Friday
5 March 2027:

curl -H "X-API-Key: $VERIAPI_KEY" \
  "https://api.veriapi.pro/v1/takvim/is-gunu/ekle?tarih=2027-03-05&gun=3"
# "sonuc": "2027-03-15" (Monday) — eve counted as a working day

curl -H "X-API-Key: $VERIAPI_KEY" \
  "https://api.veriapi.pro/v1/takvim/is-gunu/ekle?tarih=2027-03-05&gun=3&arife=tatil"
# "sonuc": "2027-03-16" (Tuesday) — eve counted as a holiday
Enter fullscreen mode Exit fullscreen mode

The response lists every skipped day and why, which you can show the customer ("delayed by the holiday"). Details on
the business day API page.

One more: Turkey has stayed on UTC+3 all year since 2016 (no daylight saving). Use the IANA zone Europe/Istanbul
instead of adding 3 hours by hand.

Checklist

# Bug Quick fix
1 toUpperCase() → ISTANBUL toLocaleUpperCase("tr-TR"); map i/ı by hand in PHP / Python
2 Ç, Ö, Ş sorted after Z Intl.Collator("tr"), utf8mb4_turkish_ci
3 ı disappears from slugs Map the six letters explicitly
4 Text without Turkish letters Context-aware deasciifier
5 parseFloat("1.250,50") = 1.25 The last separator is the decimal one
6 Amount in words Follow official spelling, test kuruş rounding
7 Negative modulo in TCKN check ((x % 10) + 10) % 10 / Math.floorMod
8 IBAN checked by regex only MOD-97, bank code = chars 5–9
9 Phone stored three ways Store E.164, don't show an operator
10 Ç or Q in plates Convert, province code 01–81
11 Stale province list 81 provinces, 973 districts, one source
12 Fixed holiday dates, full-day eves Compute per year, choose the eve rule

Most of the endpoints above are on the free plan (500 request units a day, no card); the deasciifier is on paid plans.
Docs: veriapi.pro/dokumantasyon. If you hit a Turkish-specific bug
that isn't on this list, I'd like to hear about it in the comments.

Top comments (0)