UTF-8 vs UTF-16 vs Unicode — Guia completo de codificação
A resposta curta que quase toda busca por utf-8 unicode encoding quer ouvir: Unicode e UTF-8 não são a mesma coisa. Unicode é uma tabela numerada gigantesca que atribui um code point (um número como U+1F600) a cada caractere. UTF-8, UTF-16 e UTF-32 são codificações: formas diferentes de transformar esses code points em bytes. UTF-8 é a que você quase sempre vai querer. Ela é idêntica ao ASCII byte a byte para texto em inglês, escala para quatro bytes em cada emoji e é obrigatória em JSON, HTML5 e na maioria dos protocolos modernos.
Este guia é para quem já se queimou: o erro Incorrect string value do MySQL ao tentar gravar um 😀, a surpresa do JavaScript com "😀".length === 2, o CSV que abre perfeitamente no cat mas vira lixo no Excel. O texto vai dos code points até a mecânica de bytes do UTF-8, depois pares substitutos, BOMs, o comportamento padrão de nove linguagens e oito armadilhas de produção, fechando com uma matriz de decisão e FAQ.
Quer conferir uma sequência de bytes enquanto lê? Cole qualquer string no Codificador e Decodificador de Base64. O conteúdo decodificado é exatamente o fluxo de bytes UTF-8 que este artigo explica.
Por que codificação ainda morde você em 2026
Cenários reais extraídos de bug trackers nos últimos doze meses:
- MySQL recusa um emoji. Um usuário envia
Hello 😀e o servidor retornaIncorrect string value: '\xF0\x9F\x98\x80'. A tabela éutf8, o desenvolvedor pensa “isso é UTF-8, qual o problema?”, e a resposta está enterrada na história do MySQL (detalhado na seção 7). - Um contador de caracteres sobe quebrado. Um validador de tweet de 280 caracteres usa
text.length, aceita uma mensagem cheia de emojis, e a API rejeita. O inverso também acontece: um post válido é recusado pelo front-end. Sintoma diagnosticado na seção 4. - HTML local vira “䏿–‡”. Alguém salva um arquivo em Windows-1252, abre num navegador que adivinha UTF-8, e o Mojibake aparece. Essa é a história do BOM e da declaração de charset na seção 5, com paralelos no Codificação e Decodificação de URL: Guia Prático de Percent Encoding, onde o mesmo descompasso entre bytes e caracteres destrói query strings.
Até o fim você vai conseguir (a) distinguir Unicode de UTF-8 em uma frase, (b) escolher entre UTF-8, UTF-16 e UTF-32 para qualquer projeto novo, (c) escrever código que conte emojis corretamente nas principais linguagens e (d) depurar bugs de charset olhando só para o fluxo de bytes. O assunto é mais raso do que parece quando você sabe onde estão as fronteiras certas.
O que é Unicode? Code points vs caracteres vs glifos
Unicode é uma tabela de caracteres que atribui um número único (um code point, como U+1F600) a cada caractere. UTF-8, UTF-16 e UTF-32 são codificações que traduzem esses code points em bytes. O Unicode em si não armazena bytes; ele só define o mapeamento entre caractere abstrato e número inteiro.
Outros termos costumam embaralhar a conversa porque muitas vezes apontam para a mesma marca visível:
Camadas que você precisa separar
- Code point (
U+0041,U+1F600): o número inteiro atribuído pelo Unicode. O espaço vai deU+0000atéU+10FFFF, cerca de 1,1 milhão de slots, dos quais aproximadamente 150.000 estão atualmente atribuídos. - Caractere (ou caractere abstrato): a identidade semântica — letra A maiúscula latina, emoji de carinha sorridente.
- Glifo: a forma visual desenhada pela fonte. Um caractere tem muitos glifos: um A serifado, um A itálico, um A escrito à mão. O Unicode não se importa com glifos.
- Cluster de grafemas: o que o usuário percebe como um único “caractere”. Frequentemente um code point, às vezes vários. A letra á pode ser um único code point
U+00E1ou dois code pointsa + U+0301(acento agudo combinante). O Limites de caracteres e palavras 2026 — Twitter, SMS, SEO, Instagram mostra como Twitter, SMS e SEO traçam essa fronteira de formas diferentes.
Se você só conseguir lembrar de uma coisa, que seja esta: code point → codificação → bytes → renderização. Cada uma dessas setas pode quebrar de forma independente.
Notação de code point: U+XXXX e \uXXXX
Você vai ver code points escritos em vários formatos. U+0041 é a notação canônica do Unicode: quatro a seis dígitos hexadecimais, prefixados por U+. No código-fonte:
- JavaScript / JSON:
"A"(quatro dígitos hex, só BMP) e"\u{1F600}"(chaves do ES6, qualquer code point). - Python:
"A"(4 dígitos),"\U00000041"(8 dígitos, U maiúsculo),"\N{LATIN CAPITAL LETTER A}"(pelo nome). - Shell / git log / saída do sed: você vê com frequência bytes UTF-8 crus como
\xc3\xa9paraé. Isso não é um code point, é a forma codificada, o que nos leva à seção 3.
Os 17 planos: BMP e além
O Unicode divide seu espaço de code points em 17 planos de 65.536 code points cada (17 × 2^16 = 1.114.112).
- Plano 0, o Basic Multilingual Plane (BMP): de
U+0000aU+FFFF. Latino, ideogramas CJK, cirílico, árabe, grego. Quase todo script que você encontra em texto legado mora aqui. - Planos 1-16, os planos suplementares: de
U+10000aU+10FFFF. A maioria dos emojis (U+1F600e companhia), caracteres CJK raros, scripts históricos (hieróglifos egípcios, cuneiforme), notação musical.
A fronteira BMP / suplementar em U+FFFF é o número mais importante deste artigo. É onde o UTF-16 deixa de ser uma unidade de código por caractere, onde o UTF-8 salta de três para quatro bytes e onde o utf8 do MySQL (com nome mal escolhido) desiste.
Verificação rápida com emoji
"a" → 1 code point U+0061 → 1 grafema
"é" (NFC) → 1 code point U+00E9 → 1 grafema
"é" (NFD) → 2 code points U+0065 U+0301 → 1 grafema
"😀" → 1 code point U+1F600 (Plano 1) → 1 grafema
"👨👩👧" → 5 code points (3 pessoas + 2 ZWJ U+200D) → 1 grafema
A última linha é o ponto-chave. O emoji de família é um único caractere percebido pelo usuário, mas cinco code points unidos por Zero-Width Joiners. Cada camada da pilha pode contá-lo de forma diferente, e a armadilha 6 da seção 7 é exatamente o bug report que esse desacordo gera.
Mecânica da codificação UTF-8: como funcionam de 1 a 4 bytes
UTF-8 codifica code points Unicode usando de 1 a 4 bytes. ASCII (U+0000–U+007F) usa 1 byte e é byte-idêntico ao ASCII. Code points mais altos usam sequências multibyte onde o primeiro byte sinaliza o comprimento total e cada byte de continuação começa com o padrão de bits 10xxxxxx. Esse layout auto-descritivo é a razão pela qual o UTF-8 venceu as guerras de codificação.
A tabela de padrões de bytes: UTF-8 em um diagrama
| Faixa de code point | Bytes UTF-8 | Padrão de bytes |
|---|---|---|
U+0000 – U+007F | 1 byte | 0xxxxxxx |
U+0080 – U+07FF | 2 bytes | 110xxxxx 10xxxxxx |
U+0800 – U+FFFF | 3 bytes | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 – U+10FFFF | 4 bytes | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Cada x é um bit de dados retirado da representação binária do code point. O prefixo 0 / 110 / 1110 / 11110 informa ao decodificador quantos bytes existem no total; o prefixo 10 marca cada byte de continuação. Essa redundância é o que torna o UTF-8 auto-sincronizável: perca um byte e você consegue retomar no próximo byte inicial em vez de corromper tudo dali pra frente.
Exemplo prático: codificando 中 (U+4E2D)
O code point 0x4E2D cai em U+0800–U+FFFF, então usamos o template de 3 bytes.
- Binário:
0x4E2D=0100 1110 0010 1101(16 bits). - Divisão 4-6-6 para encaixar nos slots
x:0100 / 111000 / 101101. - Substituição em
1110xxxx 10xxxxxx 10xxxxxx:11100100 10111000 10101101. - Hex:
0xE4 0xB8 0xAD.
É exatamente por isso que 中 vira %E4%B8%AD após o URL encoding: o percent-encoding embrulha cada byte UTF-8 em %XX, ele não codifica o code point diretamente. A armadilha 3 da seção 7 detalha a cadeia.
Exemplo prático: codificando 😀 (U+1F600)
O code point 0x1F600 está acima do BMP, então usamos o template de 4 bytes.
- Binário:
0x1F600=0 0001 1111 0110 0000 0000(21 bits, com padding). - Divisão 3-6-6-6:
000 / 011111 / 011000 / 000000. - Substituição em
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx:11110000 10011111 10011000 10000000. - Hex:
0xF0 0x9F 0x98 0x80.
São esses quatro bytes em que o utf8 do MySQL engasga: ele aloca no máximo três bytes por caractere. A armadilha 1 da seção 7 traz a correção.
Por que o UTF-8 venceu
- Compatibilidade com ASCII. Um arquivo com texto puro ASCII é idêntico, byte a byte, à sua codificação UTF-8. Décadas de ferramentas anteriores ao Unicode (
grep,awk, pipes clássicos de shell) continuam funcionando nesse subconjunto. - Auto-sincronização. Bytes de continuação sempre começam com
10, que nunca colide com nenhum byte inicial. Perca um byte numa transferência de rede e você ressincroniza na próxima fronteira de caractere em vez de acumular lixo em cascata. - Sem byte order. UTF-8 é um fluxo de bytes, não de unidades de 16 ou 32 bits, então endianness não se aplica. UTF-16 e UTF-32 precisam de um Byte Order Mark para declarar qual lado vem primeiro; UTF-8 não precisa (e geralmente não deve, veja a seção 5).
UTF-8 inválido: o que a spec proíbe
Um decodificador estrito vai rejeitar estas sequências de bytes:
- Sequências de 5 ou 6 bytes. RFCs antigas permitiam; a RFC 3629 (2003) limitou o UTF-8 a 4 bytes para acompanhar o espaço Unicode de 21 bits.
- Codificações overlong. Codificar
/como três bytes0xE0 0x80 0xAFem vez de um byte0x2F. Já foi fonte fértil de exploits de directory traversal em validadores de caminho que decodificavam após sanitizar. - Code points substitutos isolados (
U+D800–U+DFFF). São reservados ao UTF-16 e nunca devem aparecer em UTF-8. - Sequências truncadas. Um byte inicial de 3 bytes seguido por apenas um byte de continuação. Comum quando a entrada do usuário é cortada numa fronteira de bytes no meio de um caractere multibyte.
Para ver qualquer um desses casos na prática, jogue uma string no Codificador e Decodificador de Base64, codifique e depois decodifique de volta como bytes; o array de bytes entre o codificador e o decodificador é o fluxo UTF-8 que esta seção descreve.
UTF-16 e pares substitutos: por que length do JavaScript mente
A busca mais comum em torno de utf-8 vs utf-16 é, na verdade, “por que "😀".length é igual a 2 no meu código?”. A resposta são os pares substitutos, uma decisão dos anos 1990 que JavaScript, Java, C# e Windows herdaram.
UTF-16 em um parágrafo
UTF-16 representa o Unicode usando unidades de código de 16 bits. Caracteres no BMP (U+0000–U+FFFF) ocupam exatamente uma unidade de código. Caracteres nos planos suplementares (U+10000–U+10FFFF) ocupam duas unidades de código, chamadas de par substituto: um substituto alto em U+D800–U+DBFF seguido por um substituto baixo em U+DC00–U+DFFF. Esse bloco U+D800–U+DFFF é permanentemente reservado no Unicode, então nenhum caractere real mora lá. UTF-16 é o formato interno de strings em JavaScript, Java, C# (.NET), APIs do kernel do Windows, NSString do Objective-C e Qt, todos projetados quando 65.536 caracteres pareciam muita coisa.
A armadilha do String.length
"a".length // 1 — BMP, unidade de código única
"é".length // 1 — BMP (U+00E9), unidade de código única
"中".length // 1 — BMP (U+4E2D), unidade de código única
"😀".length // 2 — plano suplementar (U+1F600), par substituto!
"a😀".length // 3 — uma do BMP + duas unidades substitutas
String.prototype.length informa o número de unidades de código UTF-16, não o número de caracteres. Qualquer coisa do plano suplementar é lida como 2. A mesma armadilha aparece em String.length() do Java e string.Length do C#.
Contando code points corretamente em JS
[..."😀"].length // 1 — spread iterator percorre code points
Array.from("😀").length // 1 — Array.from também percorre code points
"😀".match(/./gu).length // 1 — flag /u = regex unicode-aware
// "😀".charAt(0) retorna o substituto alto isolado (visualmente quebrado)
"😀".codePointAt(0) // 128512 — o code point completo U+1F600
O operador spread e Array.from usam o protocolo de iterator, que a especificação da linguagem define como percorrendo code points. Acesso por índice simples (str[0], charAt) ainda retorna unidades de código e vai entregar metade de um par substituto em emojis.
Python: len() já faz o certo (quase)
len("😀") # 1 — strings em Python 3 são indexadas por code point
len("👨👩👧") # 5 — code points (3 humanos + 2 ZWJ), não grafemas
# Python 2 era indexado por bytes por padrão — len("😀") retornava 4
Python 3 armazena strings em uma representação flexível de 1, 2 ou 4 bytes (PEP 393) e indexa por code point. len("😀") é 1, mas ainda não é a contagem de grafemas: o emoji de família continua sendo lido como 5. Para contar caracteres percebidos pelo usuário, você precisa de uma biblioteca de grafemas: Intl.Segmenter em JavaScript (Node 22+, todos os navegadores atuais), grapheme ou regex em Python, ou Swift, cuja String.count é a única linguagem mainstream que conta grafemas por padrão.
UTF-16 vs UCS-2: a migração silenciosa
Antes de 1996, o Unicode prometia caber em 16 bits e a codificação correspondente era UCS-2, um mapeamento fixo de 2 bytes. O Unicode 2.0 quebrou essa promessa ao adicionar os planos suplementares. UTF-16 é a versão remendada que usa pares substitutos. A especificação do JavaScript ainda cita o vocabulário antigo do UCS-2 em alguns trechos, o que explica por que a linguagem tolera substitutos isolados que deveriam ser ilegais (as piadas sobre “WTF-16” são reais). APIs da plataforma web (DOM, fetch, TextEncoder) rejeitam substitutos isolados porque não podem ser codificados como UTF-8 válido.
UTF-32, BOM e a questão da ordem de bytes
UTF-32: simples e gastador
UTF-32 usa 4 bytes fixos por code point. U+0041 é armazenado como 0x00000041, U+1F600 como 0x0001F600. A vantagem é o acesso aleatório em tempo constante: o n-ésimo code point fica no offset 4n. A desvantagem é o tamanho. Texto puro ASCII incha para quatro vezes a sua pegada em UTF-8, e mesmo texto CJK dobra. Quase nenhum sistema armazena UTF-32 em disco. Internamente, o Python 3 escolhe 1, 2 ou 4 bytes por string com base no code point mais alto; a pilha fontconfig do Linux usa UTF-32 para suas tabelas de glifos em memória.
Ordem de bytes: por que endianness importa para UTF-16 e UTF-32
UTF-8 é um fluxo de bytes únicos, então endianness não se aplica. UTF-16 e UTF-32 operam em unidades multibyte, e CPUs diferentes discordam sobre qual extremidade de um número vem primeiro.
U+0041 ('A') em UTF-16 BE → 00 41
U+0041 ('A') em UTF-16 LE → 41 00
CPUs x86 e ARM são little-endian; PowerPC mais antigos e “ordem de bytes de rede” são big-endian. Quando você grava um arquivo UTF-16, precisa escolher uma e informar ao leitor qual. É exatamente para isso que serve o BOM.
O BOM: o que é, quando usar
Um Byte Order Mark é o U+FEFF colocado no início de um arquivo. Codificado, ele anuncia tanto a codificação quanto (para UTF-16 e UTF-32) a ordem de bytes.
| Codificação | Bytes do BOM |
|---|---|
| UTF-8 | EF BB BF |
| UTF-16 BE | FE FF |
| UTF-16 LE | FF FE |
| UTF-32 BE | 00 00 FE FF |
| UTF-32 LE | FF FE 00 00 |
O BOM utf-8 existe, mas não carrega informação de ordem de bytes porque UTF-8 não tem ordem de bytes. Seu único trabalho é declarar “este arquivo é UTF-8”. Útil para ferramentas que não têm outro sinal, prejudicial para ferramentas que esperam que o arquivo comece com um magic number ou diretiva.
Matriz de decisão do BOM: devo adicionar?
| Formato | BOM UTF-8 | BOM UTF-16 | BOM UTF-32 |
|---|---|---|---|
| HTML | Não (quebra a detecção de <!doctype> em parsers antigos) | — | — |
| JSON | Não (RFC 8259 proíbe) | — | — |
| Código-fonte JavaScript / CSS | Evite (Node antigo e IE engasgam) | — | — |
| CSV aberto no Excel | Sim (Excel lê UTF-8 sem BOM como ANSI e estraga CJK) | — | — |
| XML | Opcional (a declaração XML já informa a codificação) | Obrigatório | Obrigatório |
Texto puro .txt | Opcional (o Bloco de Notas do Windows adiciona por padrão) | Obrigatório | Obrigatório |
A regra curta: retire o BOM UTF-8 de qualquer coisa servida na web; adicione em CSVs que você quer abrir no Excel; deixe o leitor decidir em todo o resto.
9 linguagens lado a lado: comportamento padrão de codificação
O trabalho entre linguagens é onde esse conhecimento se paga. A mesma string "a😀é" produz um comprimento diferente em cada runtime que você chama a partir do seu script Bash.
Tabela de comportamento entre linguagens
| Linguagem | Codificação do arquivo-fonte | Armazenamento de string | length / len conta | Codificação padrão de I/O | Emoji de 4 bytes seguro? |
|---|---|---|---|---|---|
| JavaScript (V8 / SpiderMonkey) | UTF-8 | UTF-16 | Unidades de código UTF-16 | UTF-8 (Node, Web) | Sim, mas .length === 2 |
| Python 3 | UTF-8 (PEP 3120) | dinâmico 1 / 2 / 4 bytes (PEP 393) | code points | UTF-8 (PEP 540 desde 3.7) | Sim, len === 1 |
| Java | UTF-8 (padrão do javac) | UTF-16 | Unidades de código UTF-16 | charset da plataforma → UTF-8 (JEP 400, JDK 18+) | Sim, mas .length() === 2 |
| Go | UTF-8 | bytes UTF-8 | bytes (utf8.RuneCountInString para code points) | UTF-8 | Sim, len(s) retorna bytes |
| Rust | UTF-8 | bytes UTF-8 (invariante de String) | .len() bytes, .chars().count() code points | UTF-8 | Sim, explícito |
| C# (.NET) | UTF-8 (padrão desde .NET Core 3.0) | UTF-16 | Unidades de código UTF-16 | UTF-8 (Encoding.Default desde .NET 5) | Sim, mas .Length === 2 |
| Ruby | UTF-8 (desde 2.0) | tag de codificação por string | code points (.length) | UTF-8 | Sim, length === 1 |
| PHP | (sem codificação de fonte) | string de bytes | bytes (strlen); mb_strlen para code points | depende de default_charset | Sim, com a família mb_* |
| MySQL | — | charset da coluna | bytes (LENGTH), caracteres (CHAR_LENGTH) | variáveis de sistema character_set_* | Apenas com utf8mb4 |
O que a tabela realmente está dizendo
Cada família tem seu conjunto de bugs:
- UTF-8 interno (Go, Rust, Ruby). A string nativa é bytes;
lengthé bem definido, mas conta o que conta. Converta para code points ou grafemas apenas quando cruzar uma fronteira de UI ou validação. - UTF-16 interno (JavaScript, Java, C#). Herdado das suposições dos anos 1990;
lengthé em unidades de código, par substituto conta como 2. Use iteração consciente de code points para qualquer contagem voltada ao usuário. - Indexação por code point (Python 3).
lendá code points, o que parece correto até você encontrar emojis com ZWJ. Aí você ainda precisa de uma biblioteca de grafemas.
PHP é o caso especial. Todas as funções built-in str* operam em bytes, tratando sequências UTF-8 como blobs opacos. Todo projeto não-ASCII precisa usar a família mb_* (multibyte), e os bug reports ano após ano mostram quantas vezes isso passa batido.
A orientação prática: mantenha UTF-8 como formato de transmissão em todo lugar (arquivos, corpos HTTP, colunas de banco) e converta para o tipo nativo de string do seu runtime na fronteira. Esse é o “sanduíche UTF-8” ao qual voltamos na seção 8.
8 armadilhas de engenharia do mundo real
Os padrões abaixo aparecem em quase toda revisão de código de uma base globalizada.
Armadilha 1: O utf8 do MySQL é uma mentira de 3 bytes; mude para utf8mb4
Sintoma. INSERT INTO users (bio) VALUES ('Hello 😀'); retorna Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'.
Causa raiz. O utf8 histórico do MySQL é um alias para utf8mb3: uma variante de UTF-8 limitada a três bytes por caractere. Qualquer code point acima de U+FFFF (todo emoji, vários milhares de caracteres CJK raros, todos os scripts históricos) exige quatro bytes UTF-8 e é rejeitado.
Solução.
ALTER DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4; -- client connection
# my.cnf
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
O MySQL 8.0 ainda mantém utf8 como alias de utf8mb3. utf8mb3 está depreciado, mas ainda não foi removido. Use utf8mb4 para toda coluna nova, todo banco novo, toda conexão nova. Não há benefício na variante legada.
Armadilha 2: Fallback para Windows-1252, o mistério do ponto de interrogação
Sintoma. Um .txt exportado do Bloco de Notas de um colega Windows lê "smart quotes" e um travessão na máquina dele. No seu servidor, vira ? ou U+FFFD (caractere de substituição).
Causa raiz. O Bloco de Notas mais antigo usa por padrão Windows-1252 (CP-1252), que codifica a aspa curva " como 0x93. Um decodificador UTF-8 vê 0x93 como um byte de continuação solto (bit alto 10) sem byte inicial precedente e substitui pelo caractere de substituição.
Solução. Detecte a codificação de origem (file no Unix, chardet ou charset-normalizer em Python, jschardet no Node), decodifique com o codec correto e depois recodifique como UTF-8 antes de salvar. Padronizar em UTF-8 já na ingestão elimina a recorrência.
Armadilha 3: Percent-encoding de URL ≠ UTF-8 (mas se apoia nele)
Sintoma. fetch("/search?q=中文") retorna 404 de um framework backend e funciona em outro.
Causa raiz. O percent-encoding opera sobre bytes, não sobre code points. 中 é um code point, mas três bytes UTF-8 (E4 B8 AD), cada um codificado separadamente como %E4%B8%AD (nove caracteres ASCII na URL). Um framework que decodifica a URL como Latin-1 em vez de UTF-8 vai entregar ao handler os três bytes embaralhados interpretados como três caracteres de byte único.
Solução. Use encodeURIComponent("中文") no cliente (navegadores fazem UTF-8 + percent-encoding em um único passo) e confirme que o framework do servidor decodifica URLs como UTF-8 (todos os frameworks modernos têm isso como padrão). Para confirmação visual, cole 中文 no Decodificador e Codificador de URL e veja virar %E4%B8%AD%E6%96%87. A cadeia completa está no Codificação e Decodificação de URL: Guia Prático de Percent Encoding.
Armadilha 4: A entrada do Base64 é bytes, mas você digitou uma string
Sintoma. btoa("你好") lança InvalidCharacterError: The string contains characters outside the Latin1 range.
Causa raiz. btoa foi projetado na era ASCII / Latin-1. Ele espera que cada caractere de entrada caiba em um único byte (code points 0-255). 你好 é UTF-16 dentro do engine JS com code points U+4F60 U+597D, ambos bem acima de 255.
Solução. Codifique primeiro para bytes UTF-8, depois faça Base64 desses bytes.
// Wrong:
btoa("你好"); // throws
// Correct:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"
A história completa está em O Que é Codificação Base64? Um Guia para Iniciantes e no Guia Avançado de Base64: MIME, Data URLs, Performance e Segurança. O Codificador e Decodificador de Base64 faz a conversão em uma única etapa e mostra o fluxo de bytes intermediário.
Armadilha 5: String.length para validação (limites do Twitter / SMS)
Sintoma. Um composer de 280 caracteres valida no lado do cliente, e a API retorna 422. Ou o contrário: um post perfeitamente válido é recusado pelo cliente.
Causa raiz. O .length do JavaScript conta unidades de código UTF-16; um único emoji conta como 2. O Twitter conta code points (emoji = 1). A contagem de caracteres está errada em direções opostas dependendo de qual API você confia.
Solução. Use [...text].length para contagem de code points, ou Intl.Segmenter para contagem real de grafemas (a abordagem do Bluesky e iMessage). Os números plataforma por plataforma e a fronteira SMS GSM-7 versus UCS-2 estão catalogados no Limites de caracteres e palavras 2026 — Twitter, SMS, SEO, Instagram.
Armadilha 6: Famílias de emojis com ZWJ contam como N code points, 1 grafema
Sintoma. "👨👩👧".length === 8. Contar code points dá 5. Para o usuário é uma única imagem.
Causa raiz. O Zero-Width Joiner (U+200D) cola múltiplos code points de emoji em um único cluster renderizado: três emojis de pessoa mais dois ZWJs dá cinco code points, oito unidades de código UTF-16, um grafema.
Solução.
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨👩👧")].length; // 1
Intl.Segmenter está no Node 22+ e em todos os navegadores atuais. Para runtimes mais antigos, o pacote grapheme-splitter implementa o UAX #29.
Armadilha 7: Escape \uXXXX em JSON, code points acima de U+FFFF precisam de um par substituto
Sintoma. Um payload JSON contém "😀" e o decodificador receptor renderiza corretamente como 😀 ou mostra dois quadradinhos, dependendo se ele entende pares substitutos em JSON.
Causa raiz. O escape \uXXXX do JSON aceita exatamente quatro dígitos hexadecimais, ou seja, uma unidade de código UTF-16. Codificar 😀 (U+1F600) exige o par substituto 😀. Não existe sintaxe \u{...} com chaves em JSON.
Solução. Aceite o par substituto (todo parser que segue a spec lida com isso) ou escreva o emoji literalmente. O JSON aceita qualquer caractere UTF-8 fora da sintaxe de escape, e a maioria dos parsers modernos prefere essa forma.
Armadilha 8: Os padrões de Content-Type: charset= do HTTP não são o que você imagina
Sintoma. Uma página HTML UTF-8 renderiza como Mojibake em um navegador e corretamente em outro.
Causa raiz. A RFC 2616 originalmente exigia ISO-8859-1 como padrão para respostas text/* sem charset explícito. A RFC 7231 (2014) removeu esse padrão, deixando cada navegador adivinhar. Alguns analisam o conteúdo, outros caem em UTF-8, outros usam o locale do sistema como padrão.
Solução. Sempre envie Content-Type: text/html; charset=utf-8 a partir do servidor e <meta charset="utf-8"> no head do documento. Cada um sozinho funciona; ambos juntos servem de cinto e suspensório para proxies legados que removem cabeçalhos.
Para observar qualquer uma dessas armadilhas ao vivo no nível dos bytes, o Codificador e Decodificador de Base64 é o microscópio mais rápido: cole uma string, codifique em Base64, e o payload decodificado é o fluxo UTF-8.
Escolhendo a codificação certa: matriz de decisão
Para a pergunta utf-8 vs utf-16, a resposta é quase sempre UTF-8. A tabela abaixo cobre os casos de borda.
Matriz de decisão
| Cenário | Escolha | Por quê |
|---|---|---|
| Páginas web, JSON de API, arquivos-fonte | UTF-8 (sem BOM) | Compatível com ASCII, sem ordem de bytes, menor para texto latino, RFC 8259 obriga UTF-8 em JSON |
| Armazenamento pesado de CJK (DB chinês, dados de jogos japoneses) | UTF-8 (utf8mb4) | UTF-8 usa 3 bytes por caractere CJK contra 2 do UTF-16, mas o overhead ASCII de markup e chaves JSON ainda deixa o UTF-8 à frente na prática, e o ecossistema ao redor é UTF-8 |
| API nativa do Windows, código legado Java / C# | UTF-16 | Padrão da plataforma; converter a cada chamada de API convida bugs |
| Processamento de texto em memória com muito acesso indexado | UTF-32 | Acesso a code point em tempo constante; só vale a pena para hot paths de parser |
| CSV aberto no Excel no Windows | UTF-8 com BOM | Excel lê UTF-8 sem BOM como ANSI e estraga cabeçalhos CJK |
| Projeto novo, sem restrições | UTF-8 (sem BOM) | As guerras de codificação acabaram de forma decisiva |
Duas regras de bolso
- Use UTF-8 como padrão em todo lugar, a menos que uma plataforma force o contrário. W3C, IETF e o Consórcio Unicode concordam.
- Converta na fronteira, não no meio. Decodifique bytes para o tipo nativo de string da sua linguagem na ingestão. Opere em strings, nunca em bytes, na lógica de negócio. Codifique de volta para UTF-8 na saída. Esse “sanduíche UTF-8” elimina uma classe inteira de bugs de mojibake no meio do pipeline.
Perguntas Frequentes
UTF-8 sempre é compatível com ASCII de forma retroativa?
Sim. Qualquer arquivo ASCII válido é idêntico bit a bit à sua representação UTF-8. Os primeiros 128 code points (U+0000–U+007F) são codificados como um único byte com o bit alto zerado. Ferramentas legadas exclusivamente ASCII (grep antigo, sed, pipes clássicos de shell) processam arquivos UTF-8 puro-ASCII sem modificação. Os problemas começam quando bytes não-ASCII (bit alto setado) entram no fluxo.
Devo usar BOM UTF-8 nos meus arquivos?
Por padrão, não. Arquivos HTML, JSON, JavaScript e CSS quebram ou geram avisos em alguns parsers quando aparece um BOM no início. A exceção padrão é CSV destinado ao Excel no Windows: sem o BOM, o Excel adivinha ANSI e estraga cabeçalhos em chinês, japonês ou coreano. Veja a matriz de decisão do BOM na seção 5.
Por que "😀".length === 2 em JavaScript?
Strings em JavaScript são armazenadas como UTF-16, e .length retorna o número de unidades de código, não de caracteres. 😀 (U+1F600) mora no plano suplementar e exige um par substituto (duas unidades de código de 16 bits), então .length é 2. Use [..."😀"].length, Array.from("😀").length ou Intl.Segmenter para uma contagem correta.
Qual a diferença entre Unicode e UTF-8?
Unicode é a tabela de caracteres que atribui um code point (um número como U+1F600) a cada caractere. UTF-8 é uma de várias codificações que traduz esses code points em bytes (1 a 4 bytes por code point). Unicode define o que é um caractere; UTF-8 define como ele trafega por um arquivo ou rede. UTF-16 e UTF-32 são codificações alternativas da mesma tabela Unicode.
utf8mb4 é sempre mais seguro que utf8 no MySQL?
Sim para projetos novos. O utf8 do MySQL é a variante mal nomeada limitada a 3 bytes utf8mb3, que não consegue armazenar nenhum caractere acima de U+FFFF: todo emoji, muitos caracteres CJK raros, todos os scripts históricos. utf8mb4 é UTF-8 completo de 4 bytes. A única ressalva é o tamanho do índice: cada caractere utf8mb4 pode ocupar 4 bytes, então o limite legado de índice InnoDB de 767 bytes restringe índices únicos a 191 caracteres (resolvido por innodb_large_prefix no MySQL 5.7+ e como padrão no 8.0).
Como detecto a codificação de um arquivo desconhecido?
Use file no Unix, chardet ou charset-normalizer em Python, ou jschardet no Node. Nenhum é perfeito: todos adivinham estatisticamente a partir da distribuição de bytes. A detecção de UTF-8 é bem confiável graças ao padrão de byte de continuação. Windows-1252, ISO-8859-1 e outras codificações legadas de byte único são quase indistinguíveis entre si, então a detecção frequentemente acaba dependendo de heurística de idioma.
UTF-16 consegue representar todo caractere Unicode?
Sim. UTF-16 cobre todos os 1.114.112 code points. Caracteres do BMP (U+0000–U+FFFF) usam uma unidade de código de 16 bits (2 bytes), e caracteres dos planos suplementares (U+10000–U+10FFFF) usam pares substitutos (4 bytes). A cobertura é idêntica à do UTF-8 e UTF-32; apenas o layout de bytes e a semântica de processamento mudam. A escolha entre eles é uma questão de ajuste ao ecossistema, não de capacidade.