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.

The string 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 Part 2:D8 B9 Part 3:E0 A4 A8 Part 4:F0 9F 98 80

  • 1–44 code points, 10 UTF-8 bytes, 5 UTF-16 units
  1. 1Latin capital AU+0041: one byte, identical to its ASCII value1 byte
  2. 2Arabic letter ainU+0639: two bytes, like Greek, Cyrillic and Hebrew letters2 bytes
  3. 3Devanagari letter naU+0928: three bytes, like most other scripts3 bytes
  4. 4Grinning face emojiU+1F600: four bytes; above U+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 units

Try it: Hex Encoder Try it: Unicode Encoder

Mojibake: 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 character U+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.

Try it: Hex Encoder Try it: Text Diff

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             8

Try 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 \u0639 escapes: 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 Arabic

Try 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 utf8 holds three bytes at most; utf8mb4 holds 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

Your task

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.

Material
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)
Steps
  1. For each string, record graphemes, code points, UTF-16 units and UTF-8 bytes (the string length counter shows all four).
  2. Mark which strings each kind of limit would accept for the range 2 to 30.
  3. Say what the user would see if the CSV were read as Windows-1252.
Show the model answer
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

Practise with Mutqan tools

Measure string length in characters, code points, UTF-8 bytes and UTF-16 units.

Formatting

Convert text to hexadecimal bytes (UTF-8) with custom separators and casing.

Encoding

Convert text to Unicode escape sequences (\u0041), code points (U+0041) or HTML numeric entities.

Encoding

Decode \uXXXX escapes, U+ code points and numeric entities into readable text.

Encoding

Generate Unicode test strings across scripts (Arabic, Devanagari, CJK, Cyrillic), emoji, combining marks and normalisation forms.

Test Data

Compare two texts line by line or word by word and highlight differences.

Formatting

Go deeper

Your progress on this topic

Part of the QA Roadmap