Character Encoding and Unicode
Unicode is the catalogue of the world's characters: every letter, digit, mark and emoji has a number called a code point, written like U+0639 for the Arabic letter ع. A character encoding is the rule that turns those numbers into bytes for storage and transmission, and back again. UTF-8 is the encoding of the web: it writes each code point in 1 to 4 bytes, so A is one byte, ع two, न three and 😀 four.
Text defects have three common causes: two layers disagree about the encoding, two code point sequences look identical but compare as different, or a length is counted in bytes by one component and in characters by another. This lesson shows what each problem looks like in the bytes and how one fixed multilingual dataset tests for all three.
Why it matters
A product used in Saudi Arabia or India depends on its text handling: a customer name saved as علي, an invoice PDF with question marks in the address, a search that cannot find a user because her name was typed with a different accent form, a 30-character limit that rejects a 30-letter Arabic name because it was measured in bytes. Each is a defect the user can meet on the first day, often in the field that carries their own name.
For the tester, encoding defects are cheap to find and expensive to miss. They hide behind English test data, they appear only at a seam between systems (browser to API, API to database, database to export), and once garbled text is saved back it is stored as if it were correct, so every later reader repeats the damage. A small dataset and a clear picture of where bytes become characters finds them before release.
Code points, UTF-8 and the other encodings
Unicode defines code points from U+0000 to U+10FFFF, and the text an application handles is a sequence of them. An encoding maps each code point to bytes, which are written in hexadecimal, two digits per byte from 00 to FF. UTF-8, defined in RFC 3629, uses one byte for the 128 ASCII characters, so a file that contains only ASCII characters is already valid UTF-8; two bytes for most accented Latin letters and for Greek, Cyrillic, Hebrew and Arabic; three for Devanagari, Chinese, Japanese and most other scripts in everyday use; and four for most emoji and everything else above U+FFFF.
UTF-16 stores most characters in one 16-bit unit and characters above U+FFFF in two units called a surrogate pair. JavaScript strings, Java and the Windows APIs count in these units, which is why '😀'.length is 2. Older single-byte encodings, such as Windows-1252 for Western European languages and Windows-1256 or ISO 8859-6 for Arabic, hold at most 256 characters each, so they cannot hold, for example, Arabic and Devanagari in the same text; they survive in legacy databases, CSV exports and e-mail systems, and they are where most mojibake starts.
Aعन😀 is four code points but ten UTF-8 bytes: A takes one byte, ع two, न three and the emoji four.The string as characters: four code points
Part 1:APart 2:عPart 3:नPart 4:í ½í¸€
The same string as UTF-8 bytes, in hex: ten bytes
Part 1:41
- 1–44 code points, 10 UTF-8 bytes, 5 UTF-16 units
- 1Latin capital A
U+0041: one byte, identical to its ASCII value1 byte - 2Arabic letter ain
U+0639: two bytes, like Greek, Cyrillic and Hebrew letters2 bytes - 3Devanagari letter na
U+0928: three bytes, like most other scripts3 bytes - 4Grinning face emoji
U+1F600: four bytes; aboveU+FFFF, so also two UTF-16 units4 bytes
Text version
String: A ع न 😀 4 code points
Char Code point UTF-8 bytes UTF-16 units
A U+0041 41 1
ع U+0639 D8 B9 1
न U+0928 E0 A4 A8 1
😀 U+1F600 F0 9F 98 80 2 (surrogate pair)
Total: 4 characters, 10 UTF-8 bytes, 5 UTF-16 unitsMojibake: right bytes, wrong decoder
Mojibake is the garbled text that appears when bytes written in one encoding are read as another. The bytes are usually intact; the reader guessed wrong. café stored as UTF-8 is 63 61 66 C3 A9; a component that reads those bytes as Windows-1252 shows café, because in that table C3 is à and A9 is ©. The damage spreads when the garbled text is saved back: café is then re-encoded as valid UTF-8, every later reader shows it faithfully, and the original can be recovered only by reversing the wrong conversion record by record, which does not always succeed.
Every layer declares or assumes an encoding: the Content-Type: text/html; charset=utf-8 header and <meta charset="utf-8"> in HTML; JSON, which RFC 8259 requires to be UTF-8 when it is exchanged outside a closed ecosystem, and whose application/json media type defines no charset parameter; URLs, where the UTF-8 bytes are percent-encoded (ع travels as %D8%B9); database columns and connections; CSV files with or without a byte order mark (EF BB BF); e-mail templates and log files. Mojibake lives at the seam where two of them disagree.
The shape of the garbage tells you which family of defect you are looking at:
éand€in Latin text, Arabic as runs ofØandÙ(سلامbecomesسلام), Devanagari as runs ofà¤andà¥: UTF-8 bytes read as a Western single-byte encoding.?in place of letters: the text was converted into an encoding that has no slot for those characters, and they are lost for good.�, the replacement characterU+FFFD: the decoder met a byte sequence that is invalid in UTF-8, often Windows-1256 text read as UTF-8 or a string cut in the middle of a multi-byte character.- Empty boxes, or a vowel sign drawn on a dotted circle: the bytes decode correctly, but the font has no glyph for the character, or the shaping engine (the part of the renderer that joins letters and places marks on them) met a mark with no letter before it, for example after the text was cut between the two. Look at rendering and truncation, not at the encoding.
Normalisation and counting characters
The same visible text can be stored as different code points. é is one code point (U+00E9) or two (e followed by the combining acute accent U+0301); an Arabic letter with a short-vowel mark such as fatha is always two, and أ is one (U+0623) or two (ا plus hamza above U+0654). Unicode normalisation, defined in UAX #15, converts text to one agreed form: NFC composes such sequences and NFD decomposes them. The compatibility forms NFKC and NFKD go further and also fold the ligature fi to fi, the full-width A to A and the Arabic presentation form ﻻ to ل plus ا; that is right for search and wrong for storing a name as typed. Normalisation does not convert one digit system into another: the Arabic-Indic ٣ stays ٣ even under NFKC, a locale matter for the next lesson.
Unless the product normalises at its boundaries, two users can register José twice, a search for a name typed on one keyboard misses the same name typed on another, and a password set in one form fails in the other. Ask which form the product uses and where it applies it; W3C internationalisation guidance recommends NFC for text on the web.
Length has four answers. A user counts grapheme clusters, the user-perceived characters defined in UAX #29; a database column may count bytes; JavaScript's length counts UTF-16 units; Python counts code points, while Go and Rust count bytes. The four agree only for plain ASCII:
String Graphemes Code points UTF-16 units UTF-8 bytes
Ali 3 3 3 3
علي 3 3 3 6
مُحَمَّد 4 8 8 16
José (NFC) 4 4 4 5
José (NFD) 4 5 5 6
हिंदी 2 5 5 15
👍🏽 1 2 4 8Try it: String Length Counter Try it: Unicode Dataset Generator
Worked example: testing a name field end to end
Requirement: "Display name, 2 to 30 characters, shown on the profile, in e-mails and in the CSV export of the user list." Encoding is tested as a round trip: enter a fixed dataset, save, reload, read it back through the API, export it, and compare every stop with the original, by bytes rather than by eye.
- Build the dataset once and keep it: Latin with accents, Arabic with and without diacritics, Devanagari with vowel signs, an emoji with a skin tone, and the same name in NFC and NFD. The Unicode dataset generator produces strings of each kind with their counts, and the string length counter gives the four counts for any string you add yourself.
- Enter each string, save, reload the profile and compare the displayed text with the input in a diff tool; then fetch the same user from the API and compare the hex bytes of the JSON value with the hex of the original.
- Probe the limit:
عليrepeated ten times is 30 letters and 60 bytes. If it is rejected while 30 Latin letters are accepted, the limit counts bytes; if👍🏽counts as 4, it counts UTF-16 units. Record which, and ask whether the requirement meant user-perceived characters. - Search for the NFD
Joséafter saving the NFC one, and the reverse; then open the CSV export in a spreadsheet and in a plain text editor and look forÃ,Ø,?and�. - Trigger one e-mail and one PDF that print the name, and read the server log, where Arabic often appears as
\u0639escapes: correct, as long as the log viewer decodes them, and the Unicode decoder turns them back into text.
POST /api/v1/profile HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"displayName":"علي"}
GET /api/v1/profile HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
{"displayName":"علي"} OK: value bytes D8 B9 D9 84 D9 8A
{"displayName":"علي"} mojibake: read once as Windows-1252, saved again
{"displayName":"???"} lost: converted to an encoding without ArabicTry it: Unicode Dataset Generator Try it: String Length Counter Try it: Text Diff Try it: Unicode Decoder
Common mistakes
- Testing only with English or ASCII data, which every encoding gets right.
- Treating characters and bytes as the same thing, so a 30-character requirement, a 30-byte column and a 30-unit JavaScript check are all called "30".
- Blaming the data or the font when a decoder is wrong:
عليmeans the bytes were fine until one layer read them as Windows-1252. - Forgetting four-byte characters: a column that stores Arabic and Hindi may still reject emoji (MySQL's
utf8holds three bytes at most;utf8mb4holds all of Unicode). - Comparing or searching strings without an agreed normalisation form, then reporting "search is broken" without the code points that prove it.
- Trusting the screen: the profile page can show the name correctly while the e-mail, the PDF and the CSV export each garble it in their own way.
The QA perspective
Encoding is settled in design review, not after a bug. When a new field, import, export or integration appears, ask which encoding every stop uses, which normalisation form applies and how the length limit is counted. Ask the developer about column character sets and the database connection, and the DevOps engineer about the locale of servers and containers, because a server default that is not UTF-8 is a classic source of mojibake in logs and generated files.
In a defect report, give the exact input as code points and bytes (U+0639 U+0644 U+064A, D8 B9 D9 84 D9 8A), the layer where it broke, what came back, and the encoding you believe the bytes were read as. Keep the dataset in the regression suite: encoding defects return whenever a library, a driver or an export template changes.
Practice exercise
Measure a dataset and predict where a name field breaks
A profile form says: "Display name: 2 to 30 characters." The team has not said how it counts, and the name is also printed in a CSV export. Using the dataset below, write the four counts for every string, then predict which strings a byte-counting limit, a UTF-16-counting limit and a grapheme-counting limit would each accept.
Finally, write down what a mojibake version of the Arabic name would look like in the CSV export, so that you recognise it on sight.
1 Ali
2 علي
3 مُحَمَّد (with diacritics)
4 José (é as U+00E9)
5 José (e + U+0301)
6 हिंदी
7 👍🏽 (thumbs up + skin tone)
8 علي x 10 (repeated, no spaces)- For each string, record graphemes, code points, UTF-16 units and UTF-8 bytes (the string length counter shows all four).
- Mark which strings each kind of limit would accept for the range 2 to 30.
- Say what the user would see if the CSV were read as Windows-1252.
Show the model answer
Counts (graphemes / code points / UTF-16 / bytes): Ali 3/3/3/3; علي 3/3/3/6; مُحَمَّد 4/8/8/16; José NFC 4/4/4/5; José NFD 4/5/5/6; हिंदी 2/5/5/15; 👍🏽 1/2/4/8; علي × 10 30/30/30/60.
A byte-counting limit of 30 rejects string 8 (60 bytes) although it is 30 letters, and accepts everything else. A UTF-16 limit accepts all eight but counts the emoji as 4 and the name with diacritics as 8, so a user could enter at most 7 such emoji. A grapheme-counting limit matches what the user expects and accepts every string except 7: 👍🏽 is a single character and fails the minimum of 2, while a code point or UTF-16 count accepts it, and हिंदी sits exactly on the minimum. Ask the product owner which count is intended.
Strings 4 and 5 look identical; unless the product normalises, they are two different names for search and uniqueness, and the grapheme count hides the difference.
The CSV read as Windows-1252 would show علي instead of علي: the UTF-8 bytes D8 B9 D9 84 D9 8A shown one by one through the Western table. The bytes in the file are still correct, so reopening it as UTF-8 shows علي again; the damage spreads only if someone saves the garbled view back as UTF-8.
Frequently asked questions
What is the difference between Unicode and UTF-8?
Unicode is the character set: it gives every character a code point such as U+0639. UTF-8 is one encoding of that set: a rule that writes each code point as 1 to 4 bytes. UTF-16 and UTF-32 encode the same code points differently; Windows-1256 is a different, much smaller character set with its own byte values.
Why does the text look right on screen but wrong in the export or the e-mail?
Because each path declares its own encoding. The web page carries charset=utf-8 in its headers; the CSV file, the PDF generator or the e-mail template may assume another encoding or omit the byte order mark that some spreadsheet programs need. Test every output channel separately with the same dataset.
Should the application reject or normalise unusual input?
Normalise identifiers such as user names, e-mails and search terms to one form (usually NFC) at the boundary, then store and compare the normalised text; never silently alter free text such as a message body. Rejecting Arabic diacritics or emoji because a column cannot store them is a defect in the column, not in the input.
Is the byte order mark needed in UTF-8 files?
No. The Unicode Standard permits a byte order mark in UTF-8 but neither requires nor recommends it; it is the bytes EF BB BF at the start of a file. Some spreadsheet programs use it to recognise UTF-8 CSV, while some parsers treat it as part of the first field. Test files both ways and report what the consumer does with it.
References
- The Unicode StandardThe universal character set and its encoding forms (UTF-8, UTF-16, UTF-32), normalization and the properties of every code point.
- RFC 3629: UTF-8, a transformation format of ISO 10646How code points are encoded as one to four bytes in UTF-8, and which byte sequences are invalid.
- UAX #15: Unicode Normalization FormsThe NFC, NFD, NFKC and NFKD forms — the reason two visually identical strings can compare unequal until normalised.
- UAX #29: Unicode Text SegmentationHow text is divided into grapheme clusters, words and sentences — why a "character" count can differ from a code-point or byte count.
Practise with Mutqan tools
Measure string length in characters, code points, UTF-8 bytes and UTF-16 units.
Convert text to hexadecimal bytes (UTF-8) with custom separators and casing.
Convert text to Unicode escape sequences (\u0041), code points (U+0041) or HTML numeric entities.
Decode \uXXXX escapes, U+ code points and numeric entities into readable text.
Generate Unicode test strings across scripts (Arabic, Devanagari, CJK, Cyrillic), emoji, combining marks and normalisation forms.
Compare two texts line by line or word by word and highlight differences.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.