Databases
Text Encoding and Unicode
Course 1 · Chapter 8 · Databases
A database is another boundary where text meets bytes, and it adds questions of its own. How is the text stored? What does "length" count? Is "É" equal to "é"? In what order do names sort? Is "Zoë" a duplicate of "Zoë"? Each database answers these through settings called the character set and the collation, and a wrong answer silently corrupts data or loses search results. This chapter uses SQLite (which ships with Python, so every example can be run) to show the behaviours, and the MySQL documentation to explain a famous trap.
How Text Is Stored and Counted
SQLite's documentation says a text value is "stored using the database encoding (UTF-8, UTF-16BE or UTF-16LE)". Its length() function returns, for a string, "the number of Unicode codepoints (not bytes)" (up to the first NUL character), and for a blob the number of bytes.
Casting to a blob therefore gives the byte count, and length() of the text matches Python's len():
The database's "length" is the code point count from Text Encoding and Unicode 1: the decomposed é is 2, the family emoji is 5, and neither matches what a reader sees (Text Encoding and Unicode 6). Column definitions such as "up to 255 characters" in other products usually count code points too, but storage and index limits are often in bytes (check for your product). The difference matters when a character can take up to four bytes:
The last line is the arithmetic of a 255-character name: 255 bytes if all ASCII, 1,020 bytes if every character is outside the BMP. Space planning must allow for the worst case (check how your product measures index key length).
The "utf8" Trap in MySQL
The MySQL documentation describes two different Unicode character sets built on UTF-8. utf8mb4 "requires a maximum of four bytes per multibyte character" and supports the BMP and supplementary characters. utf8mb3 "supports only BMP characters and uses a maximum of three bytes per character"; for a supplementary character, "utf8mb4 requires four bytes to store it, whereas utf8mb3 cannot store the character at all."
The function above is exactly that test: an emoji or a rare script fails in a three-byte set. MySQL's name utf8 is documented as a deprecated alias for utf8mb3, and the documentation says utf8mb3 is deprecated, to be removed in a future major release, and that utf8mb4 is recommended for all new applications.
So a column declared as plain "utf8" in a MySQL database may not be UTF-8 in the full sense. Whether it errors, truncates or substitutes when you insert an emoji depends on the server's strict-mode settings (check; I have not run MySQL here). Moving from utf8mb3 to utf8mb4 is described as safe for existing data, since the old columns contain no supplementary characters.
Case, Equality and the ASCII-Only Default
SQLite is blunt about case. Its documentation says lower() "works for ASCII characters only" by default, that LIKE is case-insensitive only for ASCII ('a' LIKE 'A' is true, 'æ' LIKE 'Æ' is false), and that the NOCASE collation folds only the 26 ASCII capitals. Non-ASCII case handling needs the ICU extension.
These behaviours, confirmed here:
Reading it: ASCII letters compare case-insensitively (1) but é and É do not (0); lower() lowered the ASCII letters and left the É upper case (the True line asserts that); the same ASCII-only rule applies to NOCASE; Python's casefold(), by contrast, handles all of it.
Other products ship full Unicode collations (check which and how they are named). The general lesson is to find out, for your database, what its case-insensitive comparison really covers.
Equal to the Eye, Different to the Database
A database compares what it is given. The two spellings of "Zoë" from Text Encoding and Unicode 5 are different values, so a uniqueness constraint accepts both, and a lookup by one does not find the other, unless the application normalizes on the way in:
The table holds two "Zoë" rows, and a search for one finds only one. With NFC applied at the door the second insert is correctly rejected. Normalize where text enters, not only where it is compared.
Sorting: Binary Is Not Alphabetical
SQLite's default collation, BINARY, "compares string data using memcmp(), regardless of text encoding": it orders by bytes, not by what a dictionary does. That puts every capital before every lowercase letter and every accented letter after "z". You can register your own collation, here a rough one based on the folding from Text Encoding and Unicode 5:
The first line is the binary order; the second sorts "Éclair" and "école" with the E words. Even so, this key is crude: real languages differ (some sort accented letters as separate letters, and the right order for "ä" differs between languages, check). Serious applications use the database's own locale-aware collation or the ICU library rather than a hand-made key,
and the collation also decides what = and UNIQUE mean.
Searching: Folded Shadow Columns
When you need "ecole" to find "École", a common pattern is to store a second, folded copy of the text for searching and match against that. This reuses the accent-stripping function from Text Encoding and Unicode 5:
The plain search misses most of what a user would expect: "école" finds nothing because the stored capital É is not folded to é, and "ZOE" misses "Zoë" because LIKE ignores case only for ASCII. The folded column finds them all, including "strasse" for "straße" (casefold expands ß to "ss"). The price: a second column to maintain, and you must apply the same fold to the search term.
Whether folding accents is right depends on the language and the audience: in some languages an accent changes the word.
Exercises
For five strings (ASCII, accented, euros, emoji, CJK), use SQL to get the character count and the byte count, and test each against a limit of 5 characters and a limit of 5 bytes. Which strings pass one limit but fail the other, and why does it matter when a product measures a column in bytes?
📄 View solutionMake two tables: one with a UNIQUE name column using COLLATE NOCASE, one with a separate key column filled by NFKC plus casefold(). Insert "Zoe", "ZOE", "Zoë", "ZOË", the decomposed Zoë and a fullwidth-letter Zoë into both. Which inserts does each table reject, and which duplicates does only the key column catch?
Build the folded search column from this chapter for six titles (including accented, German sharp-s and capital-accent cases). Compare a plain LIKE search with a folded search for five terms, and explain each place where the plain search fails.
Chapter 8 Quick Reference
- SQLite stores text as UTF-8 or UTF-16;
length()counts code points for text and bytes for blobs; cast to a blob for the byte count - MySQL:
utf8mb4is up to 4 bytes and covers everything;utf8mb3(the deprecated "utf8" alias) is up to 3 bytes and cannot store characters above U+FFFF - SQLite default
lower(), LIKE andNOCASEfold ASCII only; the ICU extension adds full Unicode case folding - The default
BINARYcollation compares bytes: capitals before lowercase, accented letters after z - Databases do not normalize: normalize (NFC) on input, or the same text can be stored twice
- Use a folded shadow column (or a proper collation) for accent- and case-insensitive search; apply the same folding to the query
- Check character set, connection character set and collation for every database you use
- Limits may be in bytes even when lengths are in characters: plan for 4 bytes per character