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 point | Bytes usados | Padrão de bytes |
|---|---|---|
U+0000–U+007F | 1 | 0xxxxxxx |
U+0080–U+07FF | 2 | 110xxxxx 10xxxxxx |
U+0800–U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000–U+10FFFF | 4 | 11110xxx 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:
| |
😀 é U+1F600, na quarta linha, então ocupa quatro:
| |
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:
| |
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:
| |
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:
| |
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:
| |
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:
| |
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.