Skip to Content
Course content

326: Unicode Normalization for Text Comparison

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen some of the most frustrating bugs in production happen because a developer assumed that if two strings look exactly the same on a screen, they are the same string in Python. It seems intuitive, right? If you see "Café" and "Café", they should be equal. But in the world of Unicode, that's a dangerous assumption.

Visually Identical Doesn't Mean Digitally Equal

Here is the concrete proof. Let's look at the character 'é'. In Unicode, there are actually two different ways to represent this character: as a single precomposed character, or as a base 'e' followed by a "combining acute accent" character. To your eyes, and your terminal, they are indistinguishable.

import unicodedata

# This is the precomposed 'é' (U+00E9)
s1 = '\u00e9' 

# This is 'e' (U+0065) followed by the combining accent (U+0301)
s2 = '\u0065\u0301'

print(f"String 1: {s1}")
print(f"String 2: {s2}")
print(f"Are they equal? {s1 == s2}") 
# Result: Are they equal? False

If you were writing a login system or a search filter and a user pasted a name from a PDF (which often uses decomposed characters) while your database stored precomposed characters, your code would tell the user their account doesn't exist, even though the spelling is perfect. It's a nightmare to debug because when you print() the variables, they look identical.

Bringing Strings to a Common Baseline with Normalization

To fix this, we have to "normalize" the text. This essentially means picking one of the Unicode standards and forcing both strings to adhere to it before we compare them. In Python, we use the unicodedata module for this. You'll mostly encounter two forms: NFC (Canonical Composition) and NFD (Canonical Decomposition).

  • NFC: This takes decomposed characters and "composes" them into a single character if one exists. This is generally what you want for web forms and database storage.
  • NFD: This breaks everything down. It takes that single 'é' and turns it into 'e' + 'accent'. This is actually very useful if you want to strip accents entirely (by decomposing them and then filtering out the non-spacing mark characters).

Here is how I handle this in my own projects to ensure comparisons are reliable:

import unicodedata

def compare_strings(str1, str2):
    # I normalize both to NFC to ensure we are comparing apples to apples
    norm1 = unicodedata.normalize('NFC', str1)
    norm2 = unicodedata.normalize('NFC', str2)
    return norm1 == norm2

s1 = '\u00e9'
s2 = '\u0065\u0301'

if compare_strings(s1, s2):
    print("Now they match!")
else:
    print("Still not matching.")

My rule of thumb? Normalize your input as soon as it enters your system. Don't wait until the comparison step. If you normalize everything to NFC at the API boundary, you don't have to pepper unicodedata.normalize calls throughout your business logic, which keeps the code much cleaner.




📋 Practical Task

Building a Normalization-Aware User Search Filter

You are building a search feature for a member directory. The directory contains a list of names, some of which were entered via a MacOS system (which tends to use NFD/decomposed characters) and some via Windows (which tends to use NFC/precomposed characters). Your current search is failing to find matches for names with accents.

Your Task: Implement a function search_members(query, member_list) that returns a list of all members whose names match the query, regardless of how the Unicode characters are represented.

import unicodedata

def search_members(query, member_list):
    # Your code here: 
    # 1. Normalize the query string.
    # 2. Iterate through the member_list, normalizing each name.
    # 3. Return the list of matching names.
    pass

# Test Case
members = [
    "René",            # Precomposed (NFC)
    "Sébastien",       # Decomposed (NFD) - \u0053\u0065\u0301bastien
    "Alice",           # No accents
]

# The query uses a different normalization than some of the list items
query_1 = "Ren\u0065\u0301"   # Decomposed 'é'
query_2 = "S\u00e9bastien"    # Precomposed 'é'

print(f"Search 1: {search_members(query_1, members)}") # Should find ['René']
print(f"Search 2: {search_members(query_2, members)}") # Should find ['Sébastien']
Rating
0 0

There are no comments for now.

to be the first to leave a comment.