Character encoding — how characters map to bytes
You entered a name containing "𩸽" in a registration form; it was saved truncated. Two users with the same name registered, slipping past the unique constraint. Some words don't hit in search.
None of these are explainable while thinking of characters as "things countable one by one". Character-to-byte mapping isn't 1:1, and there are multiple ways to count and compare. This chapter covers that mapping.
What you'll learn in this chapter
- Character set and encoding scheme are different things
- Four ways of counting characters: byte, code unit, code point, grapheme
- Same-looking characters becoming different bytes, and how to align them
- Sort order and comparison are decided by collation, not character code
- Storage limits use bytes, not character counts
- Multiple interpretation boundaries produce inputs that slip past one side
No specific prerequisite. The last section deepens "escaping gaps and character encoding traps" from Common web attacks and defenses; reading that chapter ties the discussion together.
What this chapter does not cover
| Topic | See |
|---|---|
| SQL injection defense itself | Common web attacks and defenses |
| Column length design decisions | Database Design Guide — The Importance of Database Design |
| Index effectiveness conditions and key length design | Indexes / Database Design Guide — Introduction to index design |
| Datetime representation shifts | Datetimes and time zones |
Character set and encoding scheme are separate
Split two easily confused things.
| Decides | Example | |
|---|---|---|
| Character set | Which character gets which number (code point) | Unicode |
| Encoding scheme | How to represent that number as bytes | UTF-8, UTF-16 |
"Stored in Unicode" alone doesn't decide bytes. Same "あ" is 3 bytes in UTF-8, 2 bytes in UTF-16. What goes on files or networks are bytes, so mismatched encoding schemes between reader and writer produce mojibake.
WHATWG's Encoding Standard clearly defines what to pick:
New protocols and formats, as well as existing formats deployed in new contexts, must use the UTF-8 encoding exclusively.
The standard also notes other encoding schemes remain only for reading existing assets, and using UTF-8 alone eliminates the problems here. New places have no reason to pick anything else.
Four ways of counting
When you say "character count", there are actually at least 4 different ways to count.
| Way | What's counted | Where it matters |
|---|---|---|
| Byte | Length after encoding | Storage limits, transfer volume, index key length |
| Code unit | Encoding scheme's smallest unit. JavaScript's .length is this | String slicing, slice positions |
| Code point | Number of numbers assigned by the character set | Regex, per-character processing |
| Grapheme cluster | What people recognize as "one character" | Display width, cursor movement, character-remaining display |
const utf8 = (s: string) => new TextEncoder().encode(s).length;
const graphemes = (s: string) =>
[...new Intl.Segmenter('en', {granularity: 'grapheme'}).segment(s)].length;
for (const s of ['abc', '日本語', '𩸽', '👨👩👧']) {
console.log(
`${s} → UTF-8 ${utf8(s)} bytes / .length ${s.length} / code points ${[...s].length} / graphemes ${graphemes(s)}`,
);
}
// Slicing by .length cuts inside a character
const fish = '𩸽の刺身';
console.log('Sliced first "character": ', JSON.stringify(fish.slice(0, 1)));
console.log('Sliced by code point: ', JSON.stringify([...fish].slice(0, 1).join('')));
abc → UTF-8 3 bytes / .length 3 / code points 3 / graphemes 3
日本語 → UTF-8 9 bytes / .length 3 / code points 3 / graphemes 3
𩸽 → UTF-8 4 bytes / .length 2 / code points 1 / graphemes 1
👨👩👧 → UTF-8 18 bytes / .length 8 / code points 5 / graphemes 1
With only ASCII all four match, so testing with alphanumeric alone doesn't reveal differences.
𩸽's .length being 2 is because JavaScript strings are sequences of UTF-16 code units. Characters not fitting in 2 UTF-16 bytes are represented as 2 code units (a surrogate pair). Cutting midway leaves a value that isn't a character alone.
Sliced first "character": "\ud867"
Sliced by code point: "𩸽"
The family emoji is even more extreme, joining 5 code points with invisible joiners into 1 appearance. Judging "input up to 10 chars" by .length reaches the limit with 2 emojis. To match the human counting unit, count graphemes.
Same-looking, different bytes
"が" (ga) has 2 ways to represent. One code point, or "か" plus dakuten (voicing mark) as two.
Unicode aligns this state via normalization.
| Form | What |
|---|---|
| NFC | Decompose, then compose. Shorter combined form |
| NFD | Decompose. Split into base and combining |
| NFKC / NFKD | With compatibility decomposition. Treats visually different characters like full-width and half-width as same |
const composed = 'が'; // 1 code point
const decomposed = 'が'.normalize('NFD'); // "か" + dakuten, 2 code points
console.log('Appearance ', composed, decomposed);
console.log('length ', composed.length, decomposed.length);
console.log('=== comparison ', composed === decomposed);
console.log('NFC align + == ', composed === decomposed.normalize('NFC'));
Appearance が が
length 1 2
=== comparison false
NFC align + == true
Same appearance, different when compared. This is the truth of "same name registered twice" and "search doesn't hit".
Handling is one: normalize at the entrance and handle only the aligned form afterward. Storage-time and search-time in different forms neither enforce unique constraints nor let indexes work.
Which form to align to depends on use. NFC as default for storage and comparison works well, and NFKC fits search indexes that want to treat "株式会社" (kabushiki-gaisha, "corporation") and "㍿" as the same — NFKC decomposes the latter into the former. NFKC drops information, so don't use where the original representation should be preserved.
The input path can produce different forms from the same keyboard operation. macOS filenames tend to be decomposed — a well-known example. Not reproducing locally isn't proof that form doesn't arrive. Normalizing at the entrance guards against its arrival (Unicode Standard Annex #15 — Unicode Normalization Forms).
Collation decides sort and comparison
Japanese kana order and "alphabetical order" alike are not character-code numerical order. Sort and comparison rules are decided separately by collation.
const words = ['Zebra', 'apple', 'Ähre'];
console.log('Code unit order ', [...words].sort());
console.log('German collation ', [...words].sort(new Intl.Collator('de').compare));
console.log('Case-insensitive collation', new Intl.Collator('en', {sensitivity: 'accent'}).compare('ADMIN', 'admin'));
Code unit order [ 'Zebra', 'apple', 'Ähre' ]
German collation [ 'Ähre', 'apple', 'Zebra' ]
Case-insensitive collation 0
In code unit order, all ASCII uppercase (A-Z) come before ASCII lowercase, so Zebra leads, and Ä outside ASCII falls to the end. Not what humans expect.
Row 3 is dangerous in practice. compare returning 0 means "equal", and this collation treats ADMIN and admin as the same string. Database collations follow the same idea, causing:
- Unique constraints on case-insensitive collation columns judge
adminandADMINas duplicates - Conversely, case-sensitive collation lets both register
Which is right depends on requirements. Email and login names are safer case-insensitive; display names can often be case-sensitive. What matters is deciding — not defaulting without thought — the failure mode.
Collation also affects sort and index. Indexes are ordered structures (Indexes), so changing collation rebuilds the index order.
Storage limits use bytes
Column definitions are written in character counts, but actual effective limits include byte-based ones.
MySQL example. utf8mb3 uses up to 3 bytes and can't store supplementary characters.
The
utf8mb3character set is deprecated. ... The recommended character set for MySQL isutf8mb4. All new applications should useutf8mb4.
Putting emoji or 𩸽 in a utf8mb3 column errors or truncates depending on settings. The typical cause of "name saved truncated" is this.
Index key length also has byte limits. This value changes with row format.
The index key prefix length limit is 3072 bytes for
InnoDBtables that useDYNAMICorCOMPRESSEDrow format.The index key prefix length limit is 767 bytes for
InnoDBtables that use theREDUNDANTorCOMPACTrow format. For example, you might hit this limit with a column prefix index of more than 191 characters on aTEXTorVARCHARcolumn, assuming autf8mb4character set and the maximum of 4 bytes for each character.
utf8mb4 is up to 4 bytes per character, so in old row formats, columns over 191 characters can't be indexed. "Trying to index VARCHAR(255) gets rejected" comes from here. Limits and default row formats change by version; verify with your environment's documentation.
Multiple boundaries, one slips past
Common web attacks and defenses positioned escaping as "second-best. Has escaping gaps and character encoding traps". The trap's substance is here.
There are 2 places interpreting byte sequences as characters, and if the 2 follow different rules, inputs that slip past only one side can be created. The position where escaping read "an independent 1 byte" is read on the database side as "part of one character" — that mismatch. The character inserted for escaping gets absorbed into that character, and the quote it was meant to guard stays as it is.
MySQL's client API explicitly notes this dependency:
The
mysqlargument must be a valid, open connection because character escaping depends on the character set in use by the server.— MySQL 8.4 C API Developer Guide — mysql_real_escape_string()
Further: changing the connection charset with SET NAMES doesn't change the character set the escape function uses. That 2 being misaligned is exactly the "multiple boundaries" state.
So chapter 17's conclusion becomes first choice. Placeholders remove this path itself, because values don't get mixed as byte sequences into syntax interpretation. For escape, confirm the connection charset and the escape processing charset are the same, per environment.
Same shape of mismatch appears in other layers. Validating filename before normalization and storing after normalization lets a path slip past validation. Validate and execute on the same form — the shared principle.
Common misconceptions
"Unicode prevents mojibake." — Deciding character set doesn't decide bytes. Reader and writer with different encoding schemes still mojibake. What to decide is encoding scheme like UTF-8.
"1 char = 1 byte, or 1 char = 2 bytes." — UTF-8 is variable from 1 to 4 bytes. Most Japanese is 3 bytes; supplementary characters are 4.
".length gives character count." — Gives UTF-16 code unit count. Surrogate-pair characters are 2, joined emoji even more. To match human counting, count graphemes.
"Same-looking strings are equal by ===." — Different normalization forms are different values. Not aligning at entrance breaks unique constraints and search.
"Sort order is decided by character code numbers." — Decided by collation. In the code unit order .sort() uses, Zebra comes before apple (ASCII uppercase before lowercase), but Ähre falls after both. Case-insensitive collation treats admin and ADMIN as the same.
Exercises
Q1. "Same display name can't register" is the spec, but the listing shows 2 with the same name. Database has unique constraint, no error. What do you suspect?
Answer and explanation
Suspect the stored byte sequences differ.
Unique constraints compare byte sequences under the collation's rules, so bytes that the collation does not treat as the same value don't count as duplicates. Two candidates:
- Different normalization form. One stored composed, other decomposed — different values, both pass. Input path varies (web vs. mobile app registration, etc.)
- Similar-looking but different characters. Full-width vs. half-width, or similar glyphs from different character sets
Verify by outputting length and code points of the two rows. Same display but different length or code points → case 1; same length with different numbers → case 2.
Fix by normalizing at the entrance and storing that. Existing data needs migration to the same form. Changing collation alone isn't enough — whether normalization differences are absorbed depends on the collation's implementation, and the stored value itself doesn't change.
Q2. "Profile up to 100 characters" implemented as text.length <= 100. Users report both "shorter but doesn't fit" and "passed the input but truncated on save". Why both?
Answer and explanation
Because .length counts something other than the human-counted "character".
.length is UTF-16 code unit count. Two mismatches arise simultaneously:
- "Shorter but doesn't fit" — emoji and supplementary characters count 2+ per character. Family emoji is 8, so around 13 hits 100
- "Passed the input but truncated on save" — the storage side limits in bytes. Japanese is 3 UTF-8 bytes per character, so
.lengthof 100 becomes 300 bytes and exceeds the column limit
The judgment and storage running on different units is the root cause. Align:
- Show limits to users in graphemes. Matches "one character" recognition
- Verify storage-side limits in bytes. Estimate worst-case bytes from column charset and definition
Either alone is insufficient. Passing the display limit but getting truncated at storage remains as a combination.
Q3. Search using SET NAMES to switch connection charset with values processed by escape function. "Should switch to placeholders" — how to explain?
Answer and explanation
Because the premise for escape working is broken.
Escape needs to know the charset used by the connection to judge "which byte is a quote". But SET NAMES only changes server-side charset, and the character set the escape function uses doesn't change. The 2 misaligning creates a situation where escape reads "independent 1 byte" and the database reads "part of one character". Quotes sneak into the value and get interpreted as syntax.
Placeholders remove this path itself. Values pass through a different lane from syntax, so byte sequence interpretation mismatch doesn't become syntax (Common web attacks and defenses).
To continue with escape, set the connection charset from the API side and confirm escape processing sees the same charset. Adds verification points without reaching the safety of placeholders.
Summary
- Character set assigns numbers; encoding scheme makes bytes. In new places, use only UTF-8
- Character counting has 4 ways: byte, code unit, code point, grapheme. Alphanumeric matches, so tests don't reveal differences
- JavaScript's
.lengthis code unit count. Slicing there cuts inside a character - Same appearance, different bytes exist. Normalize at the entrance and handle only aligned forms
- Sort and comparison are decided by collation. Case-insensitive treats
adminandADMINas the same, changing unique constraint meaning - Storage limits use bytes.
utf8mb4is up to 4 bytes per character; index key length limits change by row format - Two places interpret bytes as characters, mismatch rules and one slips past. Placeholders remove this path
- WHATWG Encoding Standard — Use UTF-8 exclusively; reasons other encodings remain
- Unicode Standard Annex #15 — Unicode Normalization Forms — Definitions of the 4 normalization forms
- MySQL 8.4 Reference Manual — The utf8mb3 Character Set — 3-byte limit and deprecation notice
- MySQL 8.4 Reference Manual — InnoDB Limits — Index key length limits and row format conditions
- MySQL 8.4 C API Developer Guide — mysql_real_escape_string() — Escape depends on connection charset
What to read next
This concludes Part 14.
- Common web attacks and defenses — Revisit attacks exploiting interpretation mismatch from the defense side
- Indexes — Where collation affects order and key length
- Datetimes and time zones — Another territory where representation shifts