انتقل إلى المحتوى
أمين أبوزيد
منشورBEG-034-L04

ترميز الأحرف (UTF-8)

  • الدرس 4 من 6
  • 12 دقيقة قراءة
  • مبتدئ
في هذا الدرس 11 موضوعات

هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.

Lesson Overview

A file never contains letters. It contains bytes, which are just numbers. When your program writes the word "café" to a file, something has to decide which numbers stand for c, a, f, and é. That agreement is called a character encoding. When the program that writes the file and the program that reads it use different encodings, the text comes out wrong: letters turn into odd symbols, or the read fails.

In filing-cabinet terms, the encoding is the language the clerk writes the pages in. If one clerk files a page in one language and another clerk reads it assuming a different language, the page looks like nonsense even though nothing was damaged.

Why this matters for a backend: servers exchange text with many systems and many people, and names with accents, currency symbols, and non-Latin scripts are normal input. Garbled names in a database are among the most common and most visible bugs in real systems. The fix is a simple rule: use UTF-8 everywhere, and say so explicitly in your code.

This builds on the previous lesson, where character streams decoded bytes into characters. The next lesson, "A simple CSV format", uses text files to store records.

Learning Objectives

By the end of this lesson you will be able to:

  • Explain what a character encoding is, and the difference between a character and the bytes that represent it.
  • Explain why UTF-8 is the recommended encoding.
  • Show that the same text produces different bytes in UTF-8 and ISO-8859-1.
  • Describe what goes wrong when the writing and reading encodings differ: garbled text or a read error.
  • Pass StandardCharsets.UTF_8 explicitly to Files methods and to classes such as FileReader.
  • Explain why relying on the platform default encoding is risky.

Written Explanation

Characters versus bytes

A String in Java holds characters. A file holds bytes. Encoding turns characters into bytes when writing; decoding turns bytes into characters when reading. Both directions must use the same encoding.

For plain English letters, nearly every encoding produces the same single byte per letter, so mismatches go unnoticed. Trouble starts with anything outside that basic set: accented letters, the euro sign, scripts such as Greek or Cyrillic, and emoji.

Two encodings side by side

Java gives the common charsets as constants in StandardCharsets. Two matter for this lesson:

  • UTF-8 is a variable-width encoding of Unicode. According to its specification (RFC 3629), it uses 1 to 4 bytes per character, it can represent every Unicode character, and the plain ASCII characters keep their one-byte values, so ASCII-only text is already valid UTF-8.
  • ISO-8859-1 ("Latin-1") is an older eight-bit encoding that covers Western European characters only, one byte each.

The word café shows the difference (the é is the Unicode character U+00E9):

EncodingBytes (hex)Count
UTF-863 61 66 c3 a95
ISO-8859-163 61 66 e94

The first three letters are identical. Only the é differs: two bytes in UTF-8, one in Latin-1. So the number of characters (4) is not the number of bytes (5 or 4). Keep these two ideas apart: String.length() counts characters, Files.size counts bytes.

What goes wrong when they differ

There are two typical symptoms:

  1. Garbled text. Bytes written as UTF-8 and read as Latin-1: the two bytes c3 a9 become two separate characters, à and ©, and the text shows the well-known "mojibake" like café. Nothing crashes; the data is silently wrong.
  2. A read error. Bytes written as Latin-1 and read as UTF-8: the single byte e9 is not a valid UTF-8 sequence. The Files.readAllLines and readString methods report this as an IOException, because the documentation says malformed input is reported that way. By contrast, new String(bytes, UTF_8) does not throw. It silently replaces bad input with the replacement character U+FFFD, which hides the damage.

The garbled kind is worse, because nobody gets an error message.

The rule: UTF-8, and say so

Modern Java makes this easier. Since JDK 18 (JEP 400), UTF-8 is the default charset of the standard APIs, so FileReader, FileWriter, Scanner, and the like use UTF-8 unless the environment is configured otherwise. The Files methods you already know (readAllLines, readString, writeString, write, newBufferedReader, newBufferedWriter) are documented to use UTF-8 when you pass no charset, independent of the platform.

Even so, make your intention visible, for three reasons. Older Java versions used the operating system's default for classes like FileReader, and code is often run on a version you did not choose. A reader seeing StandardCharsets.UTF_8 knows the choice was deliberate. And the files you read may come from another system. So:

java
Files.writeString(path, text, StandardCharsets.UTF_8);
String text = Files.readString(path, StandardCharsets.UTF_8);
BufferedReader r = new BufferedReader(new FileReader(file, StandardCharsets.UTF_8));

The two-argument FileReader constructor takes a charset. Each Files text method also has a version that takes a charset as an extra argument.

A practical note: the text you see in a terminal or IDE console is a second decoding step, which depends on your console settings and may look wrong even when your file is perfect. If something looks garbled, check the bytes in the file first. Console behaviour is system-specific, so this lesson's examples print byte counts and character codes instead of accented text.

Also remember that your .java source files are text too. If you type an accented letter in a string literal, save the file as UTF-8 and compile with UTF-8 source encoding (many modern setups do this by default; check your build tool or IDE settings). An alternative is a Unicode escape such as é, which is what the examples below use.

Examples

Example 1: the same text, different bytes (simple)

java
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;

public class Ex1 {
    public static void main(String[] args) {
        String s = "café";                       // café
        byte[] utf8 = s.getBytes(StandardCharsets.UTF_8);
        byte[] latin1 = s.getBytes(StandardCharsets.ISO_8859_1);
        System.out.println("chars: " + s.length());
        System.out.println("UTF-8 bytes: " + utf8.length + " " + HexFormat.of().formatHex(utf8));
        System.out.println("Latin-1 bytes: " + latin1.length + " " + HexFormat.of().formatHex(latin1));
        String wrong = new String(utf8, StandardCharsets.ISO_8859_1);
        System.out.println("UTF-8 bytes read as Latin-1: length " + wrong.length());
        for (char c : wrong.toCharArray()) System.out.print(String.format("U+%04X ", (int) c));
        System.out.println();
        String bad = new String(latin1, StandardCharsets.UTF_8);
        System.out.println("Latin-1 bytes read as UTF-8: U+" + Integer.toHexString(bad.charAt(3)).toUpperCase());
    }
}

Output:

text
chars: 4
UTF-8 bytes: 5 636166c3a9
Latin-1 bytes: 4 636166e9
UTF-8 bytes read as Latin-1: length 5
U+0063 U+0061 U+0066 U+00C3 U+00A9 
Latin-1 bytes read as UTF-8: U+FFFD

The same four characters produce five bytes in UTF-8 and four in Latin-1. Reading the UTF-8 bytes as Latin-1 yields five characters, ending in U+00C3 and U+00A9 (Ã and ©). Reading the Latin-1 bytes as UTF-8 gives the replacement character U+FFFD for the bad byte, silently.

Example 2: a mismatch in a real file (practical)

java
import java.io.IOException;
import java.nio.charset.*;
import java.nio.file.*;

public class Ex2 {
    public static void main(String[] args) throws IOException {
        Path f = Path.of("data", "names.txt");
        Files.createDirectories(f.getParent());
        Files.writeString(f, "José Müller\n", StandardCharsets.UTF_8);
        System.out.println("UTF-8 size: " + Files.size(f));
        String right = Files.readString(f, StandardCharsets.UTF_8);
        String wrong = Files.readString(f, StandardCharsets.ISO_8859_1);
        System.out.println("read as UTF-8, length " + right.length());
        System.out.println("read as Latin-1, length " + wrong.length());
        Files.writeString(f, "José\n", StandardCharsets.ISO_8859_1);
        System.out.println("Latin-1 size: " + Files.size(f));
        try {
            Files.readAllLines(f);
        } catch (IOException e) {
            System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
        }
        System.out.println(Charset.defaultCharset());
    }
}

Output (JDK 21):

text
UTF-8 size: 14
read as UTF-8, length 12
read as Latin-1, length 14
Latin-1 size: 5
MalformedInputException: Input length = 1
UTF-8

José Müller plus a newline is 12 characters but 14 bytes in UTF-8, because é and ü take two bytes each. Reading those bytes as Latin-1 gives 14 characters: every byte became its own character, which is the garbled case with no error. In the second half the file is written as Latin-1 and read with the default readAllLines, which uses UTF-8: the stray byte is not valid, and a MalformedInputException (a kind of IOException) is thrown. The last line shows that on this JDK the default charset is UTF-8.

Example 3: explicit UTF-8 with FileReader (realistic)

java
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;

public class Ex3 {
    public static void main(String[] args) throws IOException {
        Path f = Path.of("data", "reader.txt");
        Files.createDirectories(f.getParent());
        try (BufferedWriter w = new BufferedWriter(
                new OutputStreamWriter(Files.newOutputStream(f), StandardCharsets.UTF_8))) {
            w.write("naïve €5");
            w.newLine();
        }
        try (BufferedReader r = new BufferedReader(new FileReader(f.toFile(), StandardCharsets.UTF_8))) {
            String line = r.readLine();
            System.out.println(line.length() + " chars, euro at index " + line.indexOf('€'));
        }
        System.out.println("bytes on disk: " + Files.size(f));
    }
}

Output (on Windows, where a line break is two bytes):

text
8 chars, euro at index 6
bytes on disk: 13

The text naïve €5 has 8 characters. On disk it takes 11 bytes: the ï needs 2, the euro sign needs 3, and the rest need 1 each. The line separator adds the rest (2 bytes on Windows, 1 on Linux and macOS). This is the long form of what Files.newBufferedWriter does for you: bytes are turned into text with a named charset on both sides. Use this form when a library hands you a FileReader or an OutputStream, and always include the charset.

Backend Insight

Encoding bugs in backends usually appear at a boundary: a CSV exported by one tool and imported by another, a log read on a different server, text sent between services. The professional habit is to fix the encoding at every boundary, in code, and to document it: "all files are UTF-8". Databases, HTTP responses, and file formats you meet later in the course all declare an encoding in some way, and UTF-8 is the usual choice because it can represent every Unicode character.

When a customer reports "my name shows with strange symbols", experienced engineers ask: where was it written, where was it read, and which encoding was used at each step? Counting characters versus bytes (as in the examples) is a quick way to prove which side is wrong.

Common Mistakes

Mistake 1: Relying on the platform default

  • Incorrect: new FileReader(file) in code that must also run on older Java or in unusual setups.
  • Correct: new FileReader(file, StandardCharsets.UTF_8).

The default charset depended on the operating system before JDK 18. Code that only works because of your machine's settings is a bug waiting for another machine.

Mistake 2: Writing and reading with different encodings

Writing with Latin-1 and reading with UTF-8 (or the reverse) gives garbled text or a MalformedInputException. Pick one encoding for a file format and use it on both sides.

Mistake 3: Confusing characters with bytes

  • Incorrect: "The file has 4 characters, so it is 4 bytes."
  • Correct: "café is 4 characters, which is 5 bytes in UTF-8."

Use String.length() for characters and Files.size for bytes.

Mistake 4: Trusting the console

A wrong-looking letter on screen can come from the terminal's own settings. Check the file bytes or lengths before blaming your code.

Mistake 5: Treating new String(bytes, charset) as a validator

It never throws for bad input. It replaces it with U+FFFD. If you need to detect invalid data, read with Files.readString or a reader and catch IOException.

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. In UTF-8, how many bytes does the text ab take? How many does ü (U+00FC) take?

أظهر الحل A1

A1. ab takes 2 bytes; ü takes 2 bytes in UTF-8.

A2. True or false: Files.readAllLines(path) needs a charset argument to decode UTF-8.

أظهر الحل A2

A2. False. The one-argument version uses UTF-8 by default.

A3. A user's name looks like José after loading. Which of the two symptoms from the lesson is this, and which encoding pair probably caused it?

أظهر الحل A3

A3. It is the garbled-text symptom, with no error. The bytes were written as UTF-8 and read as a one-byte-per-character encoding such as Latin-1: the two bytes of é became à and ©.

Level B: Application

B1. Write static long saveUtf8(Path f, String text) throws IOException that writes the text as UTF-8 and returns the file size in bytes. What does it return for "ab", "ü", "€" (euro), and "😀" (a smiley emoji)?

أظهر الحل B1

B1.

java
static long saveUtf8(Path f, String text) throws IOException {
    Files.writeString(f, text, StandardCharsets.UTF_8);
    return Files.size(f);
}

The results were 2, 2, 3, and 4 bytes. This matches RFC 3629's range of 1 to 4 bytes per character. (Verified by running the code.)

B2. A file was written with Files.writeString(f, "café", StandardCharsets.UTF_8). What is Files.readString(f, StandardCharsets.ISO_8859_1).length()?

أظهر الحل B2

B2. 5. The bytes are 63 61 66 c3 a9, and Latin-1 turns each byte into one character.

B3. Rewrite new FileWriter(file) and new FileReader(file) so the encoding is explicit.

أظهر الحل B3

B3. new FileWriter(file, StandardCharsets.UTF_8) and new FileReader(file, StandardCharsets.UTF_8). The charset-taking constructors exist on both classes.

Level C: Challenge

C1. Write static boolean isValidUtf8(Path f) that returns true if the file can be read as UTF-8 text and false if it contains invalid UTF-8. Test it on a Latin-1 file containing é and on a plain ASCII file.

أظهر الحل C1

C1.

java
static boolean isValidUtf8(Path f) {
    try {
        Files.readString(f, StandardCharsets.UTF_8);
        return true;
    } catch (IOException e) {
        return false;
    }
}

For a UTF-8 file it returned true; for the Latin-1 file with é it returned false; for plain ASCII it returned true, because ASCII bytes are valid UTF-8. Note that false may also mean another I/O problem such as a missing file; a real version would separate the two cases.

C2. Service A exports a customer list. Service B imports it and shows Müller. Describe how you would find the cause, and what you would change so it cannot happen again.

أظهر الحل C2

C2. Check the bytes of the exported file: if ü is stored as the two bytes c3 bc, A wrote UTF-8, and B probably read it as Latin-1 or another one-byte encoding. Compare how each service opens the file and look for a missing charset. Fix: pass StandardCharsets.UTF_8 explicitly on both the write side and the read side, and document that the file format is UTF-8.

Mini Challenge

Write a program that saves the text Zoë €10 (use the escapes) to data/price.txt in UTF-8, then reads the file back three times: as UTF-8, as ISO-8859-1, and with Files.readAllBytes. Print the character count for each text version and the byte count. Add a short comment explaining why the three numbers differ.

Check your work: the charset is named in every read and write, nothing depends on the console showing accented letters, and the UTF-8 character count (8) is smaller than the Latin-1 character count.

Summary & Next Lesson

Key takeaways

  • A file holds bytes. An encoding is the rule that turns characters into bytes and back, and both sides must use the same one.
  • UTF-8 can represent every Unicode character, uses 1 to 4 bytes each, and keeps ASCII text unchanged. Make it your default.
  • A mismatch gives garbled text (silently) or a MalformedInputException.
  • The Files text methods use UTF-8 by default. For FileReader, FileWriter, and similar classes, pass StandardCharsets.UTF_8 explicitly.
  • Character count and byte count are different numbers.

Next lesson

The next lesson, "A simple CSV format", uses everything so far to store records, one per line, in a text file that your program can write and read back.

Self-check

  1. What is a character encoding?
  2. Which encoding should be your default for text files?
  3. café has four characters. How many bytes is it in UTF-8?
  4. What happens if you read Latin-1 bytes containing é with Files.readAllLines(path)?
  5. Why is it good to write StandardCharsets.UTF_8 explicitly even when it is the default?
أظهر الإجابات
  1. The rule that turns characters into bytes (and back).
  2. UTF-8.
  3. Five bytes: the é takes two.
  4. The default decoding is UTF-8, and the stray byte is invalid, so a MalformedInputException (an IOException) is thrown.
  5. The code then works on older Java versions and in unusual setups, and it shows readers that the choice was deliberate.

References

  • Oracle, StandardCharsets (Java SE 21 API). It supports UTF_8 as a variable-width Unicode encoding and ISO_8859_1 as the Western European one-byte encoding, both available as constants.
  • IETF, RFC 3629: UTF-8, a transformation format of ISO 10646. It supports 1 to 4 bytes per character, ASCII compatibility, and the requirement that decoders reject invalid sequences.
  • OpenJDK, JEP 400: UTF-8 by Default. It supports UTF-8 as the default charset since JDK 18, the affected classes such as FileReader and FileWriter, and the recommendation to pass a charset explicitly.
  • Oracle, Files (Java SE 21 API), supports UTF-8 as the default of the Files text methods and IOException for malformed input. Oracle, String (Java SE 21 API), supports that new String(bytes, charset) replaces malformed input.
  • Not verified in a source: the "mojibake" name and the console remark are general knowledge; the exact exception text came from running JDK 21.

التطبيق

طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.

المختبر والتمارين الاختبار

تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.

اكتب للبحث في المستويات والمراحل والوحدات والدروس.