Unicode and UTF-8: Why Your Emoji Breaks the Database

A character, a code point, and an encoding are three different things, and almost every text bug you have ever shipped comes from a layer that confused two of them. Here is the whole model, with the actual bytes.

Tech Talk News Editorial9 min read
ShareXLinkedInRedditEmail
Unicode and UTF-8: Why Your Emoji Breaks the Database

Key takeaways

  • Unicode 17.0, released September 9, 2025, encodes 159,801 characters and reserves 1,114,112 code points in the range U+0000 to U+10FFFF, but a code point is only a number and says nothing about how many bytes it takes to store.
  • UTF-8 is variable length: 1 byte for U+0000 to U+007F, 2 bytes to U+07FF, 3 bytes to U+FFFF, and 4 bytes to U+10FFFF, with leading bytes 0xxxxxxx, 110xxxxx, 1110xxxx and 11110xxx and every continuation byte starting 10, which is what makes the format self-synchronizing.
  • MySQL's utf8 character set is a deprecated alias for utf8mb3, which stores a maximum of three bytes per character and cannot store any character outside the Basic Multilingual Plane, so the four-byte thumbs-up emoji U+1F44D fails with ERROR 1366 under the default strict SQL mode and truncates the row silently without it.
  • The question "how long is this string" has at least four correct answers: the thumbs-up emoji is 4 bytes in UTF-8, 1 code point, 2 UTF-16 code units to JavaScript and Java, and 1 grapheme cluster to a human reader.
  • Two visually identical strings can compare unequal because e-acute has two encodings: the single code point U+00E9 in NFC and the pair U+0065 U+0301 in NFD, which is why text should be normalized to one form at the system boundary rather than at comparison time.

The first time this bug bit me, a product review in the database just stopped. The user had typed β€œworks great πŸ‘β€ and the stored row ended after the space. No exception, no alert, nothing in the logs. The browser was fine. The application code was fine. The string had lost its last character somewhere between the form and the disk, and it took me an embarrassing afternoon to work out where.

That bug and about six of its cousins all come from the same confusion. Three different things get called β€œtext” and get treated as one thing. A character is an abstract idea, the notion of a thumbs up or a lowercase e with an accent on it. A code point is the number Unicode assigns to that idea. An encoding is the rule that turns that number into bytes. Separate those three in your head and this whole class of bug becomes boring, which is the highest compliment you can pay a bug.

159,801
+4,803 this version
Characters encoded in Unicode 17.0, released September 9, 2025
1,114,112
Code points the standard reserves, U+0000 through U+10FFFF
1 to 4
Bytes UTF-8 spends per character
3
Deprecated
Bytes MySQL's utf8 alias has ever been able to store

Three things that are not the same thing

Unicode is a catalog. It says: there is a character called THUMBS UP SIGN, and its number is 1F44D in hexadecimal, written U+1F44D. Version 17.0 of the standard, published on September 9, 2025, catalogs 159,801 characters, 4,803 of them new in that release.[2] The catalog reserves room for 1,114,112 code points in total, from U+0000 up to U+10FFFF.[1]

Notice what the catalog does not say. It does not say how big the number is on disk. That is the encoder's job, and there is more than one encoder.

The model

One character, one number, several possible byte sequences

The character you typed

  • πŸ‘Thumbs up sign
  • Γ©Latin small letter e with acute
  • ALatin capital letter A

Unicode assigns one number

U+1F44D, U+00E9, U+0041. The number is the identity of the character and nothing else. It carries no information about storage.

What an encoder writes

  • UTF-8F0 9F 91 8D, C3 A9, 41. One to four bytes each.
  • UTF-16D83D DC4D, 00E9, 0041. Two-byte units, sometimes two of them.
  • Latin-1Can write Γ© and A. Cannot represent πŸ‘ at all.
Never trueA byte count you can infer from a character countIt holds only for the 128 ASCII characters. Every layer that assumed otherwise is a bug with a date on it.

Character, code point, encoding. Three layers, and the bugs live on the seams.

The reason this matters practically is that different parts of your stack pick different encoders and none of them tell you. The browser sends UTF-8. Your JavaScript runtime holds UTF-16 in memory. Your database column has its own character set. Your log aggregator has an opinion too. Text survives that trip only if every hop agrees, which is also the argument for pinning the encoding explicitly at every API boundary you own rather than hoping the default is the one you wanted.

How UTF-8 turns a number into bytes

UTF-8 was specified in its current form by RFC 3629 in November 2003. It is variable length: one to four octets per character, covering the whole U+0000 to U+10FFFF range.[1] The scheme is worth knowing at the bit level, because once you have seen it you can read a corrupted byte stream by eye.

RFC 3629, section 3plaintext
Code point range        UTF-8 bytes (x = payload bits)

U+0000  .. U+007F       0xxxxxxx
U+0080  .. U+07FF       110xxxxx  10xxxxxx
U+0800  .. U+FFFF       1110xxxx  10xxxxxx  10xxxxxx
U+10000 .. U+10FFFF     11110xxx  10xxxxxx  10xxxxxx  10xxxxxx
The whole encoding, in four lines.

Read the first byte and you know everything. A byte starting with 0 is a one-byte character, and the remaining seven bits are the code point, which is exactly ASCII. That is the trick that made UTF-8 win: every ASCII file ever written is already a valid UTF-8 file, byte for byte, no conversion. A byte starting 110 means β€œtwo bytes, I am the first.” 1110 means three. 11110 means four. And every continuation byte starts with 10, a pattern no leading byte ever uses.

That last detail is the elegant part. Because leading and continuation bytes are distinguishable on sight, you can drop into the middle of a UTF-8 stream, scan backwards past any byte starting with 10, and you have found a character boundary. The RFC calls this out directly: character boundaries are easily found from anywhere in an octet stream.[1] A truncated file loses one character, not the rest of the document.

U+1F44D  (THUMBS UP SIGN)  =  1 1111 0100 0100 1101

pad to 21 payload bits:      000  011111  010001  001101
                              |      |       |       |
                          11110000 10011111 10010001 10001101
                            0xF0     0x9F     0x91     0x8D
Four bytes, and none of them look like ASCII. That matters in a minute.
β€œUTF-8 didn't win because it was elegant. It won because every ASCII file on earth was already valid UTF-8 on the day it shipped, and nobody had to convert anything.”

How long is this string? Pick a number, any number

Here is the part that took me longest to internalize, and the reason these bugs keep finding new places to live. There is no single correct answer to β€œhow long is this string.” There are at least four answers, all correct, and the bug is always a layer that picked a different one than you did.

Bytes in UTF-8

Code points

  1. A (U+0041)
    1 byte
    1
  2. Γ© (U+00E9)
    2 bytes
    1
  3. € (U+20AC)
    3 bytes
    1
  4. πŸ‘ (U+1F44D)
    4 bytes
    1
  5. πŸ‡ΊπŸ‡Έ flag
    8 bytes
    2
  6. πŸ‘©β€πŸ‘©β€πŸ‘¦ family
    18 bytes
    5
Six strings, measured two ways. The family emoji is five code points joined by invisible zero-width joiners.

Takeaway

A VARCHAR(20) that counts bytes, a validator that counts code points and a UI that counts what the user sees will disagree about the same name. Pick the unit deliberately at each layer, and write down which one you picked.

Add the other two units and it gets worse. JavaScript strings are sequences of 16-bit UTF-16 code units, so anything above U+FFFF is stored as a surrogate pair and counted twice.[5] Java made the same choice in the 1990s and is stuck with it. Python 3 counts code points, which is one of the quieter reasons text handling feels different when you are coming to Python from Java.

Β JavaScript
"πŸ‘".length            // 2   <- UTF-16 code units
[..."πŸ‘"].length       // 1   <- code points
"πŸ‘".charCodeAt(0)     // 55357 = 0xD83D, a lone high surrogate

"πŸ‘©β€πŸ‘©β€πŸ‘¦".length          // 8
[..."πŸ‘©β€πŸ‘©β€πŸ‘¦"].length     // 5   <- three people, two zero-width joiners
Slicing a string at index 1 here hands you half a character.

And the fourth answer, the one your user actually means: the grapheme cluster, which Unicode Standard Annex #29 defines as the unit a reader thinks of as a single character.[4] The family emoji is one of those. So is a Devanagari syllable with its vowel sign. If you are truncating a display name, that is the unit you want, and neither JavaScript nor Python gives it to you without a library.

Heads up

This is why naive truncation produces broken output. Cutting a UTF-8 byte string at byte 20 can split a four-byte character into two invalid fragments; cutting a JavaScript string at index 20 can split a surrogate pair and leave you a replacement glyph; cutting at code point 20 can strip the skin-tone modifier off an emoji and change what it depicts.

MySQL's utf8 was never UTF-8

Back to the truncated review. MySQL has a character set called utf8. It is not UTF-8. It is a deprecated alias for utf8mb3, which stores a maximum of three bytes per character and supports only the Basic Multilingual Plane, the first 65,536 code points.[3] Everything above U+FFFF, which is every emoji, a lot of historic scripts, and the newer CJK extension blocks, needs a fourth byte that the column simply does not have.

Receipt

The MySQL 8.4 manual is blunt about it: β€œFor a supplementary character, utf8mb4 requires four bytes to store it, whereas utf8mb3 cannot store the character at all.” The same page says utf8mb3 is deprecated and that utf8mb4 is the recommended set for all new applications.[3]
Β SQL
-- column is CHARACTER SET utf8mb3, strict mode on (the default)
INSERT INTO reviews (body) VALUES ('works great πŸ‘');
ERROR 1366 (HY000): Incorrect string value: '\xF0\x9F\x91\x8D'
                    for column 'body' at row 1

-- same insert with strict mode off: no error, and the row reads
-- 'works great ' with the emoji and everything after it gone
F0 9F 91 8D is exactly the four bytes we encoded two sections ago.

The failure mode depends entirely on your SQL mode, and this is the detail that decides whether you find the bug in ten minutes or ten months. Strict mode is in MySQL's default set today, and it turns the bad insert into an error you cannot miss.[6] Without strict mode, MySQL inserts an adjusted value and produces a warning instead,[6] which in practice means the row is written, truncated at the offending character, and your application never asks about warnings. That is the version I hit. Silent data loss, one column at a time.

Fixing it is four changes, not one: the column, the table default, the database default, and the client connection charset. Miss the connection and a correctly configured utf8mb4 column still receives mangled bytes. Then read the migration page before you run the ALTER, because index limits move with the byte width. On InnoDB tables with COMPACT or REDUNDANT row format the index prefix limit is 767 bytes, so an indexed column drops from 255 characters under utf8mb3 to 191 under utf8mb4; COMPRESSED and DYNAMIC formats allow 3,072 bytes, which is 1,024 characters against 768.[7] The number 191 in a legacy schema is a fossil of exactly this migration.

None of this is a reason to run from relational databases, and if you are weighing that trade there are better axes to argue about in the SQL versus NoSQL comparison. A document store with a bad connection charset corrupts text just as cheerfully. The lesson is narrower: a field named utf8 lied for over a decade, and the only defense is checking what a name actually means instead of what it sounds like.

Mojibake and normalization, the two that survive the migration

Two more failures, and neither is fixed by switching to utf8mb4.

The first is mojibake, the Japanese term for text turned to garbage by being decoded with the wrong character set. UTF-8 bytes read as Latin-1 is the classic. The word cafΓ© ends in the bytes C3 A9, and Latin-1 sees two separate one-byte characters there, so you get café. The encouraging part is that the bytes are usually intact and the mistake is only in the decode step, so it is reversible if you catch it before something re-encodes and saves the mangled version.

The second is normalization, and it is sneakier because nothing looks broken. Unicode gives you two ways to write Γ©. One is the single code point U+00E9. The other is a plain e, U+0065, followed by a combining acute accent, U+0301. They render identically. They are not equal. Unicode Standard Annex #15 defines the four normalization forms that resolve this, and the one you want almost always is NFC, canonical decomposition followed by canonical composition, which collapses the pair back into the single code point.[8]

Β Python
>>> from unicodedata import normalize
>>> nfc, nfd = normalize("NFC", "cafΓ©"), normalize("NFD", "cafΓ©")
>>> nfc == nfd
False
>>> len(nfc), len(nfd)
(4, 5)
>>> nfc[-1].encode().hex(" ")      # composed
'c3 a9'
>>> nfd[-2:].encode().hex(" ")     # e + combining acute
'65 cc 81'
Two strings that print the same, hash differently, and fail an equality check.

Takeaway

Normalize once, at the boundary, before the text is stored, compared, indexed or hashed. Normalizing at comparison time means every future comparison has to remember, and one of them will not.

Where this bites: a user pastes a name from a Mac, which hands you the decomposed form, and your uniqueness check on it passes because the stored copy is composed. Now you have two accounts with visually identical names. Or a search box that never matches a document because one side was normalized and the other was not. The bug report says β€œsearch is broken for accented names” and nothing in your query looks wrong.

Side note

There is a whole genre of published statistic that is really an encoding bug wearing a suit, where a percentage about languages or names got computed on strings that were counted in the wrong unit. If you enjoy watching a number survive years of repetition without anyone checking it, I wrote about how a badly sourced language statistic spreads.

The four rules that end this class of bug

None of this is hard. It is just unforgiving, because every layer gets a vote and silence reads as agreement.

  1. UTF-8 everywhere, declared explicitly. Database, connection, HTTP headers, file reads, the lot. Never rely on a platform default, because the default differs by platform and moves between versions.
  2. utf8mb4 in MySQL, always. Column, table, database and client. Treat any surviving utf8 or utf8mb3 in a schema as a scheduled outage.
  3. Normalize to NFC at the boundary. One line, at the point text enters the system, before storage or comparison.
  4. Say which unit you are counting. Bytes for storage limits, code points for validation, grapheme clusters for anything a human sees. A length limit with no stated unit is a defect, not a decision.

Learn the four bytes of one emoji properly and you never lose an afternoon to this again. F0 9F 91 8D. Go check what your reviews table is set to.

Primary sources

  1. 1.PrimaryRFC 3629, UTF-8, a transformation format of ISO 10646. IETF Standards Track, November 2003. The encoding table, the 1 to 4 octet range, and the self-synchronization property.
  2. 2.PrimaryUnicode 17.0 release announcement, Unicode Consortium. September 9, 2025. 4,803 characters added, 159,801 encoded in total.
  3. 3.PrimaryMySQL 8.4 Reference Manual, the utf8mb3 character set. utf8 as a deprecated alias, three bytes maximum, Basic Multilingual Plane only.
  4. 4.PrimaryUnicode Standard Annex #29, Unicode Text Segmentation. Revision 47, Unicode 17.0.0, 2025-08-17. Extended grapheme clusters and user-perceived characters.
  5. 5.PrimaryECMA-262, section 6.1.4, the String type. JavaScript strings are sequences of 16-bit UTF-16 code units, which is why length counts units and not characters.
  6. 6.PrimaryMySQL 8.4 Reference Manual, server SQL modes. STRICT_TRANS_TABLES in the default mode set, and the adjusted-value-plus-warning behavior when strict mode is off.
  7. 7.PrimaryMySQL 8.4 Reference Manual, converting between 3-byte and 4-byte Unicode character sets. The 767-byte and 3,072-byte index prefix limits and the 255, 191, 1,024 and 768 character counts that follow from them.
  8. 8.PrimaryUnicode Standard Annex #15, Unicode Normalization Forms. Revision 57, Unicode 17.0.0, 2025-07-30. Definitions of NFC, NFD, NFKC and NFKD.

Frequently asked questions

What is the difference between Unicode and UTF-8?
Unicode is the catalog that assigns every character a number called a code point, and UTF-8 is one of several rules for turning that number into bytes. Unicode says the thumbs-up emoji is U+1F44D. UTF-8 says that number is written as the four bytes F0 9F 91 8D. UTF-16 writes the same character as two 16-bit units, D83D DC4D. Same character, same code point, different bytes on disk.
Why does my emoji break MySQL?
Because MySQL's utf8 character set is a deprecated alias for utf8mb3, which stores at most three bytes per character and supports only the Basic Multilingual Plane, while emoji need four bytes. Inserting one raises ERROR 1366, Incorrect string value, under the default strict SQL mode, and truncates the value at the offending character when strict mode is off. The fix is utf8mb4 on the column, the table, the database default and the client connection.
Why is "πŸ‘".length equal to 2 in JavaScript?
Because a JavaScript string is a sequence of UTF-16 code units, and any character above U+FFFF is stored as a surrogate pair of two units. The thumbs-up emoji U+1F44D becomes the pair D83D DC4D, so length reports 2. Spreading the string with [..."πŸ‘"] iterates code points instead and gives 1. Java behaves the same way; Python 3 counts code points and gives 1.
What is mojibake and what causes it?
Mojibake is text that displays as garbage because bytes encoded in one character set were decoded as another, most often UTF-8 bytes read as Latin-1. The word cafe with an acute e encodes to the bytes C3 A9 at the end, and Latin-1 reads those two bytes as two separate characters, so the word renders as café. The bytes are usually intact; only the decoding step is wrong, so it is often reversible if you catch it before re-saving.
What is Unicode normalization and why does NFC matter?
Normalization rewrites canonically equivalent text into one consistent form so equal-looking strings compare equal. The letter e with an acute accent has two valid encodings: the single code point U+00E9, and the pair U+0065 U+0301 where a combining accent follows a plain e. They render identically and are not equal byte for byte or code point for code point. NFC composes them into the single code point, which is the form to apply once at the system boundary, before storage, comparison or hashing.

Written by

Tech Talk News Editorial

Computer engineering background. Writes about software, AI, markets, and real estate, and the places where the three meet.

More about the author
ShareXLinkedInRedditEmail