Uzbek Latin–Cyrillic Transliteration in Code: Rules and Pitfalls
Practice5 minInnovialabs editorial team
In short
Uzbek Latin converts reliably to Cyrillic with a lookup table that checks two-character combinations first, once all apostrophes are normalized to one character; the reverse direction loses information (ц, ъ after ў), so store a normalized key for search.
In short: how transliteration works
Most Uzbek Latin and Cyrillic letters map one to one. The difficulties come from three things:
- apostrophes: users type up to five different characters in
o‘andg‘; - digraphs:
sh,ch,yo,yamust be recognized before single letters; - context:
е,эandцdepend on their position in the word, and some information is lost when converting from Latin.
A working approach: normalize apostrophes → scan the string checking longer combinations first → preserve case → keep an exceptions dictionary for ambiguous words.
Mapping table
Direct pairs: a–а, b–б, d–д, f–ф, g–г, i–и, j–ж, k–к, l–л, m–м, n–н, o–о, p–п, r–р, s–с, t–т, u–у, v–в, x–х, y–й, z–з.
| Latin | Cyrillic | Note |
|---|---|---|
oʻ, gʻ | ў, ғ | The apostrophe is part of the letter |
h | ҳ | Not to be confused with x = х |
q | қ | |
sh, ch | ш, ч | Digraphs, check before s, c |
ng | нг | Two letters in both scripts |
yo, yu, ya | ё, ю, я | |
ye / e | е / э | See rules below |
s / ts | ц | Only when converting from Cyrillic |
ʼ | ъ | Tutuq belgisi (separation mark) |
| — | ь | Dropped in Latin |
Apostrophes: o‘ and g‘
Real data contains ASCII ' (U+0027), the backtick, ‘ (U+2018), ’ (U+2019), ʻ (U+02BB) and ʼ (U+02BC). For oʻ and gʻ the usual recommendation is U+02BB; for the tutuq belgisi it is U+02BC.
The normalization rule is simple: turn any apostrophe between letters into U+02BC first, and after o and g into U+02BB. Leave apostrophes outside words, such as quotation marks, untouched.
Digraph and context pitfalls
yoʻl→ йўл, not ёл: ifyois followed by U+02BB, it isy+oʻ.mingʻirlamoq→ минғирламоқ: do not mergenandginto "ng" whengis followed by an apostrophe.Isʼhoq→ Исҳоқ: an apostrophe betweensandhseparates the letters; it does not mean ш or ъ.- е/э: from Latin,
eat the start of a word or after a vowel is э (ertak→ эртак), otherwise е;ye→ е (yer→ ер). From Cyrillic it is the reverse: е at the start or after a vowel becomesye(поезд→poyezd). - ц: from Cyrillic it becomes
sat the start of a word and after a consonant (цирк→sirk), andtsafter a vowel (лицей→litsey). Going back, ц cannot be restored automatically:ketsais кетса. You need a dictionary of loanwords. - ё in Cyrillic always becomes
yo. - Case:
Шbefore a lowercase letter isSh; inside an all-caps word it isSH.
Latin → Cyrillic function
const OKINA = "ʻ"; // ʻ in oʻ, gʻ
const TUTUQ = "ʼ"; // ʼ tutuq belgisi
function normalizeLatin(s) {
return s
.normalize("NFC")
.replace(/(?<=\p{L})['`´‘’ʻʼ](?=\p{L})/gu, TUTUQ)
.replace(/([oOgG])ʼ/g, "$1" + OKINA);
}
const L2C = [
["s" + TUTUQ + "h", "сҳ"], ["o" + OKINA, "ў"], ["g" + OKINA, "ғ"],
["sh", "ш"], ["ch", "ч"], ["yo", "ё"], ["yu", "ю"], ["ya", "я"], ["ye", "е"],
["a", "а"], ["b", "б"], ["d", "д"], ["f", "ф"], ["g", "г"], ["h", "ҳ"],
["i", "и"], ["j", "ж"], ["k", "к"], ["l", "л"], ["m", "м"], ["n", "н"],
["o", "о"], ["p", "п"], ["q", "қ"], ["r", "р"], ["s", "с"], ["t", "т"],
["u", "у"], ["v", "в"], ["x", "х"], ["y", "й"], ["z", "з"], [TUTUQ, "ъ"],
];
function latToCyr(input) {
const s = normalizeLatin(input);
let out = "";
let i = 0;
while (i < s.length) {
const prev = s[i - 1] || "";
let hit = null;
for (const [lat, cyr] of L2C) {
if (s.slice(i, i + lat.length).toLowerCase() !== lat) continue;
if (lat === "yo" && s[i + 2] === OKINA) continue; // yoʻl -> йўл
hit = [lat, cyr];
break;
}
if (!hit && s[i].toLowerCase() === "e") {
const startOrVowel = !/\p{L}/u.test(prev) || /[aeiou]/i.test(prev);
hit = ["e", startOrVowel ? "э" : "е"];
}
if (!hit) { out += s[i++]; continue; }
const [lat, cyr] = hit;
const src = s.slice(i, i + lat.length);
out += src[0] !== src[0].toLowerCase() ? cyr[0].toUpperCase() + cyr.slice(1) : cyr;
i += lat.length;
}
return out;
}
Minimal tests worth keeping in the project:
import assert from "node:assert/strict";
assert.equal(latToCyr("O'zbekiston"), "Ўзбекистон");
assert.equal(latToCyr("O’zbekiston"), "Ўзбекистон");
assert.equal(latToCyr("yo'l"), "йўл");
assert.equal(latToCyr("ma'no"), "маъно");
assert.equal(latToCyr("Is'hoq"), "Исҳоқ");
assert.equal(latToCyr("ertak"), "эртак");
assert.equal(latToCyr("SHAHAR"), "ШАҲАР");
Normalization for search
People search however is convenient: in Latin without apostrophes, or in Cyrillic on a Russian keyboard layout without ў, қ, ғ, ҳ. So compare a search key, not the raw strings:
const FOLD = { ў: "у", қ: "к", ғ: "г", ҳ: "х", ъ: "", ь: "", ё: "е", э: "е" };
function searchKey(text) {
return latToCyr(text)
.toLowerCase()
.replace(/[ўқғҳъьёэ]/g, (c) => FOLD[c])
.replace(/\s+/g, " ")
.trim();
}
// searchKey("Qo‘qon") === searchKey("Кукон")
Tips:
- Store the key in a separate field and index it rather than computing it on the fly.
- Do not change the original text: the key is only for comparison.
- If queries like
Ozbekistonwithout an apostrophe are common, add a second key where apostrophes are simply removed.