Abra um arquivo salvo como UTF-8 em uma ferramenta que assume outra codificação e café se transforma em café. Os bytes no disco não mudaram. O que mudou foi a regra usada para transformá-los de volta em caracteres, e as duas regras discordam sobre o que o byte 0xC3 significa.

A maioria dos bugs de “caracteres estranhos” sai da mesma raiz: nomes de arquivo corrompidos, formulários ilegíveis, um emoji que faz um insert no banco falhar, duas strings que parecem idênticas mas falham na comparação. Por trás de cada um está a confusão entre duas coisas distintas: Unicode e UTF-8.

Unicode vs UTF-8: code points não são bytes

Unicode é uma única tabela que atribui a cada caractere um número, chamado code point, escrito como U+ seguido de hexadecimal: A é U+0041, é é U+00E9, 😀 é U+1F600. A tabela vai de U+0000 a U+10FFFF. Isso cobre toda escrita em uso atual ou histórico, além de símbolos, emojis e intervalos reservados para uso privado.

Unicode não diz nada sobre bytes. Ele responde a uma única pergunta: qual número corresponde a esse caractere? Transformar esse número em bytes que você pode salvar em um arquivo ou enviar por um socket é trabalho de uma codificação, não do próprio Unicode. UTF-8, UTF-16 e UTF-32 são três codificações da mesma tabela. Elas escrevem os mesmos code points como sequências de bytes diferentes.

É o mesmo tipo de brecha que faz 0.1 + 0.2 retornar algo diferente de 0.3: o valor abstrato e os bits na memória são duas camadas diferentes, e o bug aparece quando você os trata como intercambiáveis.

Como o UTF-8 transforma um code point em bytes

O UTF-8 tem largura variável. Cada code point se transforma em 1 a 4 bytes, usando só os que precisa.

Intervalo de code pointBytes usadosPadrão de bytes
U+0000–U+007F10xxxxxxx
U+0080–U+07FF2110xxxxx 10xxxxxx
U+0800–U+FFFF31110xxxx 10xxxxxx 10xxxxxx
U+10000–U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Todo byte que começa com 10 é um byte de continuação: um pedaço de um caractere multibyte, nunca um caractere sozinho. Isso torna a codificação autossincronizável. Entre no meio de um fluxo UTF-8, volte passando por qualquer byte de continuação, e você cai no início de um caractere.

A primeira linha explica por que o UTF-8 venceu. Os code points U+0000–U+007F são exatamente ASCII, armazenados com o mesmo byte único que o ASCII já usava, então qualquer arquivo ASCII puro já é UTF-8 válido, byte a byte. Tudo que fica fora desse intervalo custa bytes extras. é é U+00E9, na segunda linha, então ocupa dois:

1
2
>>> "é".encode("utf-8")
b'\xc3\xa9'

😀 é U+1F600, na quarta linha, então ocupa quatro:

1
2
>>> "😀".encode("utf-8")
b'\xf0\x9f\x98\x80'

Esse par 0xC3 0xA9 é o culpado do exemplo de abertura. Decodifique como UTF-8 e você obtém é. Decodifique como Latin-1, que mapeia cada byte para um caractere, e 0xC3 se torna à enquanto 0xA9 se torna ©. Nada em um fluxo bruto de bytes registra qual codificação o produziu. Os bytes não estão errados. O que está errado é a suposição de quem os lê.

Por que .length não conta caracteres

“Qual o tamanho dessa string” tem três respostas diferentes, porque “caractere” pode significar três unidades diferentes.

O len() do Python conta code points:

1
2
3
4
>>> len("😀")
1
>>> len("😀".encode("utf-8"))
4

Um code point na str, quatro bytes no bytes codificado. Duas perguntas diferentes, duas respostas diferentes.

As strings em JavaScript são UTF-16 internamente, e .length conta unidades de código de 16 bits, não code points. Code points acima de U+FFFF, o que inclui todo emoji, precisam de duas unidades UTF-16 que juntas formam um par substituto:

1
2
"😀".length;              // 2  (um par substituto, não um caractere)
[..."😀"].length;         // 1  (o spread itera por code point)

O emoji de família "👨‍👩‍👧‍👦" piora as coisas. São 7 code points: 4 emojis de pessoa unidos por 3 zero-width joiners. Cada um dos 4 emojis é um par substituto, então .length reporta 11. O que um leitor vê como um caractere é um grapheme cluster, e ele pode se estender por qualquer quantidade de code points. Cortar ou inverter uma string por unidade de código ou por code point pode partir esse emoji pela metade. O Intl.Segmenter conta como uma pessoa faria:

1
2
const graphemes = [...new Intl.Segmenter().segment("👨‍👩‍👧‍👦")];
graphemes.length;  // 1

A largura variável também tem um custo. Em um buffer UTF-8, encontrar o 50º code point significa percorrer desde o início contando, então indexar por posição de caractere é O(n), não a busca em tempo constante que você obtém de um array de largura fixa. As linguagens tratam isso de formas diferentes. Rust e Go armazenam strings como UTF-8 e as indexam por byte, então você itera quando quer caracteres. O CPython armazena cada str em uma largura fixa por string (1, 2 ou 4 bytes por code point, dependendo do caractere mais largo que ela contém), o que mantém s[i] em tempo constante ao custo de memória extra assim que um único emoji aparece.

Bugs comuns de codificação UTF-8 em produção

Truncar por contagem de bytes. Cortar uma string UTF-8 em exatamente 100 bytes às vezes cai no meio de um caractere multibyte, deixando uma sequência parcial que falha ao decodificar. Trunque por contagem de code points, ou trunque os bytes e decodifique com errors="ignore", aceitando que o último caractere parcial seja descartado. Nenhuma das duas abordagens respeita grapheme clusters: um corte por code point ainda pode separar um e do seu acento combinante ou partir um emoji de família. Para texto visível ao usuário, corte nos limites dos graphemes.

Deixar a codificação implícita na E/S de arquivos. open("data.txt") sem o argumento encoding= usa a codificação local do sistema operacional. No Linux e macOS isso costuma ser UTF-8. No Windows historicamente foi uma code page como cp1252. Ler um arquivo UTF-8 com o padrão errado gera um UnicodeDecodeError ou mojibake silencioso, dependendo dos bytes. A PEP 686 torna o UTF-8 o padrão em todo lugar a partir do Python 3.15, mas muito código ainda roda em versões anteriores ou define PYTHONUTF8=0. Passe a codificação explicitamente e a questão deixa de existir:

1
2
with open("data.txt", encoding="utf-8") as f:
    text = f.read()

Uma coluna do MySQL que é UTF-8 só no nome. O character set utf8 do MySQL (hoje um alias para utf8mb3) armazena no máximo 3 bytes por caractere. Ele não consegue conter code points acima de U+FFFF, então nada de emoji e nada fora do Basic Multilingual Plane. Insira 😀 em uma coluna utf8 e você recebe Incorrect string value. A solução é utf8mb4, a implementação UTF-8 completa do MySQL e o padrão desde o MySQL 8.0. Converter uma tabela é ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4, executado por tabela, e o charset da conexão também precisa ser utf8mb4, ou o cliente corrompe os emojis antes de chegarem à coluna. Em row formats InnoDB mais antigos, atenção ao limite de 767 bytes do prefixo de índice: um VARCHAR(255) indexado cabe em utf8mb3 (765 bytes) mas não em utf8mb4 (1.020 bytes).

Duas sequências de bytes para o mesmo caractere visível. é pode ser o code point precomposto único U+00E9 (forma NFC), ou a letra e (U+0065) seguida de um acento agudo combinante U+0301 (forma NFD). Eles são exibidos de forma idêntica mas diferem byte a byte, então duas strings café podem comparar como diferentes dependendo de onde cada uma foi digitada ou colada. Nomes de arquivo no macOS e texto copiado de alguns PDFs costumam chegar em NFD. Um dicionário indexado com uma forma pode falhar silenciosamente uma busca com a outra. Normalize antes de comparar ou salvar:

1
2
3
4
5
6
7
>>> import unicodedata
>>> a = "café"      # é precomposto (NFC)
>>> b = "café"     # e + acento agudo combinante (NFD)
>>> a == b
False
>>> unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b)
True

Um byte-order mark intruso. Um arquivo UTF-8 pode começar com os 3 bytes EF BB BF, um BOM escrito por alguns editores (entre eles o antigo Notepad do Windows) para sinalizar a codificação. UTF-8 não tem ordem de bytes para sinalizar, então esse BOM não carrega nenhuma informação. Ele é invisível na maioria dos editores, mas decodificá-lo com utf-8 simples deixa um caractere  no início da string, o que quebra parsers JSON estritos e impede que um shebang #! seja reconhecido. Decodifique com o codec utf-8-sig do Python quando esperar um, e não escreva um você mesmo.

Como parar bugs de codificação na fronteira

Escolha UTF-8 e declare isso explicitamente em todo lugar onde o texto entra ou sai do seu programa: leituras de arquivo, conexões de banco de dados, corpos HTTP, pipes para subprocessos. Decodifique para string assim que os bytes chegarem, trabalhe internamente com strings decodificadas, e codifique de volta para bytes só na próxima fronteira. Mantenha errors="strict" (o padrão do Python) para que uma sequência de bytes ruim gere uma exceção em vez de silenciosamente virar U+FFFD ou desaparecer.

Depois, teste com entradas que não sejam ASCII. Fixtures que só contêm U+0000–U+007F deixam bugs de codificação chegarem à produção, porque todo caminho de código funciona por acaso a um byte por caractere até o nome de um usuário real quebrar essa suposição. Adicione dois casos a qualquer nível de teste que você já escreve para cada campo de texto, caixa de busca, manipulador de nomes de arquivo ou exportação: uma string com uma letra acentuada, escrita tanto em NFC quanto em NFD, e uma com o emoji de família. Se sobreviverem a um round trip pelo seu armazenamento e voltarem iguais, as fronteiras estão fazendo seu trabalho.

Artigos relacionados