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' 
<?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'); } 

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 

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) 

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 

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" 

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" 

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" } ] } 

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 

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Ş" 

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] 

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 

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" } 

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 } 

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"} 

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 

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.