← Back to list

Understanding UTF-8 and UTF-16

How They Help Avoid Encoding Bugs

Andy · 2025-08-20 00:33 · 0 claps · 5.2 min read
#encoding #utf-8 #utf-16 #unicode #software-engineering
Open on Medium ↗
Wiki topics: 💻 · Programming

Understanding UTF-8 and UTF-16

How They Help Avoid Encoding Bugs

Character encoding is one of the most important foundations for modern software, especially when sending text between systems. A misunderstanding of encoding can lead to subtle bugs, from garbled text to silent message truncation. To prevent such issues, it’s important to understand how UTF-8 and UTF-16 represent Unicode characters.

The Scene of the Bug: An SMS Byte Miscalculation

Our service already had a feature for sending SMS messages from the web. When it came time for me to build this same functionality into the mobile app, I had to adhere to the existing conventions.

The feature allows users to configure messages in advance and requires the UI to display a preview of the message’s byte count. Users can also use dynamic tags (e.g., {shopName}), which are replaced with real data only when the message is sent. This makes it impossible to calculate the exact byte length during configuration.

Following an agreed-upon rule for estimating the byte length, I implemented the UI. However, an issue soon arose from internal feedback:

“The byte count is being calculated differently than I expected!”

I had followed the rules(such as UTF-8 encoding) correctly, so what was happening? This led me to investigate the root cause: a fundamental assumption about character encoding.

How UTF-8 Works

UTF-8 is a variable-length encoding system, meaning that the number of bytes used depends on the Unicode code point:

By interpreting the bit patterns of UTF-8, you can determine how many bytes a character uses — even if you start reading from the middle of a byte stream. If you’d like more details, feel free to leave a comment on this article.

Notice that the prefix bits (the leading 1s in the first byte and the 10 in continuation bytes) reduce the effective number of bits used for the code point. That’s why even though 2 bytes = 16 bits physically, only 11 bits are used for the code point in the 2-byte UTF-8 pattern. For example, Korean syllables (U+AC00–U+D7AF) fall in the U+0800–U+FFFF range, so they always require 3 bytes in UTF-8.

How UTF-16 Works

UTF-16 uses 16-bit code units. A single 16-bit unit can only cover code points from U+0000 to U+FFFF, which is known as the Basic Multilingual Plane (BMP). Crucially for our story, the entire modern Korean syllable block (U+AC00–U+D7AF) falls within this plane. This means every common Korean character is represented by a single 16-bit unit, which is exactly 2 bytes.

However, the full Unicode standard goes all the way up to U+10FFFF. So, how does UTF-16 encode characters that are above U+FFFF?

The solution is a clever design called a surrogate pair. When a character is outside the BMP, UTF-16 uses two 16-bit units to represent it. The design idea is that if each unit contributes 10 bits of payload, two units give us 20 bits of address space. This is perfect for covering the supplementary range (U+10000–U+10FFFF), which contains exactly 220 (1,048,576) values.

Reserved Ranges and Bit Prefixes

To ensure these pairs aren’t confused with regular BMP characters, UTF-16 reserves a special block of 2,048 values that never represent standalone characters. The magic is in the unique binary prefix of the values within these ranges:

  • High Surrogates (0xD800–0xDBFF): These 1,024 values all start with the 6-bit binary prefix 110110. They are responsible for carrying the top 10 bits of the code point's address.
  • Low Surrogates (0xDC00–0xDFFF): These 1,024 values start with the 6-bit binary prefix 110111. They carry the bottom 10 bits.

Because these bit patterns are unique and reserved, a decoder can instantly recognize a surrogate and know what to expect next. This makes validation simple: a high surrogate must be followed by a low surrogate, and a low surrogate must always be preceded by a high one. A lone surrogate is invalid UTF-16.

The Formulas in Action

First, let’s calculate the valid range for the high surrogate.

Input range:
0x10000 ≤ U ≤ 0x10FFFF

Subtract 0x10000:
0 ≤ U − 0x10000 ≤ 0xFFFFF

Take top 10 bits (>>10):
0 ≤ (U − 0x10000) >> 10 ≤ 0x3FF

Add 0xD800 to get high surrogate:
H = 0xD800 + ((U − 0x10000) >> 10)
0xD800 + 0 ≤ H ≤ 0xD800 + 0x3FF
0xD800 ≤ H ≤ 0xDBFF

From the proof above, we can see that the high surrogate always falls within 0xD800–0xDBFF. By a similar argument, the low surrogate is guaranteed to fall within 0xDC00–0xDFFF.

With these ranges established, the encoding and decoding process becomes straightforward. Let U be a code point where 0x10000 ≤ U ≤ 0x10FFFF:

Remove the offset:
U′ = U − 0x10000

Split into high and low parts:
High surrogate H = 0xD800 + (U′ >> 10)
Low  surrogate L = 0xDC00 + (U′ & 0x3FF)

Back to U:
U = (H − 0xD800) × 0x400 + (L − 0xDC00) + 0x10000

This compact formula captures the entire surrogate-pair mechanism: two 16-bit units carrying 10 bits each, reserved ranges ensuring unambiguous decoding, and a reversible process that covers the full Unicode range up to U+10FFFF.

Uncovering the Real-World Discrepancy

The source of the conflict was the “agreed-upon rule” itself. The existing convention, which I was told to follow, was to estimate 2 bytes per Korean character for the UI preview. This is a common shortcut, likely stemming from a UTF-16 mindset, where many common East Asian characters fit neatly into a single 2-byte unit.

However, the entire system — both the existing web service and app — actually sends the final SMS encoded in UTF-8. When I implemented the byte calculation in the app, I did it correctly according to the UTF-8 standard, which allocates 3 bytes for each Korean character.

This created a major discrepancy. For a 10-character Korean shop name, my app correctly calculated 30 bytes. But the existing system’s estimate was only 20 bytes. This is what prompted the confusion from other developers: my accurate calculation was flagged as “strange” simply because it didn’t match the existing, but incorrect, estimation.

Establishing the Correct Method

The solution wasn’t to change my code — it was already correct. The real challenge was to demonstrate why the 2-byte assumption was flawed for a UTF-8 system and to align all platforms on the right calculation method.

By using a simple code snippet, I could provide definitive proof of the actual byte length of a UTF-8 encoded string.

Here’s how to do it in Dart / Flutter:

// Correctly calculate the byte length using UTF-8 encoding.
String shopName = "열글자한글샵이름데모";

int byteLength = utf8.encode(shopName).length;

// 30 bytes (10 characters * 3 bytes)`
print(byteLength);

This programmatic proof made it clear that the legacy 2-byte rule needed to be retired. Adopting the actual utf8.encode(string).length method ensures that our byte counts are accurate across all platforms, preventing user confusion and the original risk of message truncation.

Key Takeaways

The key takeaways from this experience remain incredibly relevant:

  1. Understand your encoding: Know the difference in behavior between UTF-8 and UTF-16. Your system’s true encoding standard is what matters, not assumptions.
  2. Challenge legacy assumptions: Just because a rule is “agreed-upon” doesn’t mean it’s correct. A “UTF-16 thinking” shortcut is a bug in a UTF-8 world.
  3. Calculate the actual UTF-8 byte length: For dynamic content, programmatic calculation is the only way to guarantee accuracy and prevent truncation.
  4. Use code as your proof: When there’s a discrepancy, a simple, runnable example is the best way to demonstrate the correct behavior and align the team.

By clearly understanding both UTF-8 and UTF-16, we as developers can design robust systems that safely handle international characters and confidently correct subtle but critical bugs in our systems.

Reference

https://www.unicode.org/faq/utf_bom.html


메타데이터
post_id
264cbc55dca3
slug
understanding-utf-8-and-utf-16-how-they-help-avoid-encoding-bugs-264cbc55dca3
url
https://medium.com/@andygineer/understanding-utf-8-and-utf-16-how-they-help-avoid-encoding-bugs-264cbc55dca3
canonical_url
https://medium.com/@andygineer/understanding-utf-8-and-utf-16-how-they-help-avoid-encoding-bugs-264cbc55dca3
author_url
https://medium.com/@andygineer
status
ok
fetched_at
2026-07-18 03:02:36