Todos os artigos
102 artigos · atualizado semanalmente Veja nossas Ferramentas
Todos os artigos
Tutoriais

UTF-8 e problemas de encoding: por que acentos quebram e como resolver

O que é UTF-8, code points, mojibake e por que "configuração" vira "configuração" — o mecanismo explicado ponta a ponta.

UTF-8 e problemas de encoding: por que acentos quebram e como resolver
COVER · Tutoriais

Você abre um arquivo de texto, uma resposta de API, um campo no banco — e onde deveria estar "configuração" aparece configuração. Ou pior: um ponto de interrogação solitário no lugar de cada acento. O arquivo não está corrompido. O dado não sumiu. O problema é que alguém, em algum ponto do caminho, interpretou os bytes com o encoding errado.

Esse tipo de falha é silenciosa no sentido pior: o sistema aceita, grava, devolve — e só quando um humano olha para a tela percebe que algo está errado. Entender o mecanismo por trás disso leva uns vinte minutos. Continuar debugando sem entender pode levar semanas.


O que é UTF-8, de verdade

Antes de UTF-8, havia uma babel de encodings regionais. ASCII cobria inglês sem acentos (128 caracteres, 7 bits). Latin-1 (ISO-8859-1) estendia para 256 e cobria português, espanhol, francês. Windows-1252 era uma variação proprietária do Latin-1. Shift-JIS cobria japonês. Big5 cobria chinês tradicional. Nenhum deles se falava.

Unicode surgiu como tentativa de ter um único espaço de endereçamento para todos os caracteres de todos os sistemas de escrita do mundo. Cada caractere recebe um número — chamado de code point — no formato U+XXXX. A letra ç é U+00E7. O emoji 🔥 é U+1F525. O símbolo do yen ¥ é U+00A5.

Unicode define os code points. UTF-8 é a forma de codificar esses números em bytes.

Como UTF-8 transforma code points em bytes

UTF-8 é uma codificação de largura variável. Um caractere ocupa entre 1 e 4 bytes dependendo do seu code point:

Faixa de code points Bytes Exemplo
U+0000 a U+007F 1 byte A (U+0041) → 0x41
U+0080 a U+07FF 2 bytes ç (U+00E7) → 0xC3 0xA7
U+0800 a U+FFFF 3 bytes (U+20AC) → 0xE2 0x82 0xAC
U+10000 a U+10FFFF 4 bytes 🔥 (U+1F525) → 0xF0 0x9F 0x94 0xA5

O detalhe importante: os 128 primeiros caracteres — todo o ASCII — são exatamente os mesmos em UTF-8. Arquivos em inglês puro que funcionavam em ASCII continuam funcionando em UTF-8 sem mudança. Foi uma decisão de retrocompatibilidade intencional.

A consequência prática: ç em UTF-8 ocupa dois bytes (0xC3 0xA7). Em Latin-1, ocupa um byte (0xE7). Quando você mistura as duas coisas, os bytes são os mesmos mas a interpretação é diferente — e aí começa o problema.


Mojibake: o nome para o lixo de encoding

Mojibake (文字化け) é o termo japonês para o fenômeno de texto que virou lixo por causa de encoding errado. Em português, você provavelmente conhece pelas formas clássicas:

  • São Paulo lido como Latin-1 quando está em UTF-8 → São Paulo
  • configuração lido como UTF-8 quando está em Latin-1 → configuração
  • descrição lido como ASCII (que não suporta acento) → descri??o ou descri\xc3\xa7\xc3\xa3o

A lógica é sempre a mesma: bytes foram escritos com um encoding e lidos com outro.

# UTF-8 lido como Latin-1 (mojibake clássico)
texto_utf8 = "configuração".encode("utf-8")
# b'\x63\x6f\x6e\x66\x69\x67\x75\x72\x61\xc3\xa7\xc3\xa3\x6f'

mojibake = texto_utf8.decode("latin-1")
# 'configuração'

# Desfazer: re-encode em latin-1, decodifica como utf-8
recuperado = mojibake.encode("latin-1").decode("utf-8")
# 'configuração'

Esse truque de recuperação funciona apenas quando a conversão original foi Latin-1 ↔ UTF-8 e os bytes originais não foram alterados. Se houve truncamento, substituição de caracteres inválidos, ou mais de uma conversão errada no caminho, os dados podem ser irrecuperáveis.


Por que acentos quebram: os três pontos de falha

O caminho de um texto acentuado envolve pelo menos três contratos de encoding: onde é escrito, onde é armazenado, e onde é lido. Se qualquer ponto diverge dos outros, você tem problema.

1. Banco de dados com charset errado

MySQL tem uma quirk histórica que já destruiu dados de muita gente: a collation utf8 do MySQL não é UTF-8 de verdade — ela suporta apenas até 3 bytes por caractere, o que exclui emojis e alguns caracteres asiáticos raros (os de 4 bytes, acima de U+FFFF). O UTF-8 correto no MySQL chama-se utf8mb4.

-- Verificar charset da tabela
SHOW CREATE TABLE usuarios;

-- Corrigir tabela e colunas
ALTER TABLE usuarios
  CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- Garantir que a conexão também está em utf8mb4
SET NAMES utf8mb4;

Se a tabela estiver em latin1 e você inserir texto UTF-8, o MySQL vai gravar os bytes literalmente sem converter — você vai ler os dados de volta corretamente enquanto a conexão também estiver em Latin-1, mas na hora que mudar a conexão para UTF-8 vai ver mojibake em tudo que tem acento.

2. Arquivo salvo no encoding errado

O editor de texto, o script Python, o gerador de CSV — todos precisam gravar o arquivo no encoding correto. No Windows, a grande armadilha histórica é que o Notepad e muitos editores salvavam em Windows-1252 por padrão. Hoje, VSCode salva em UTF-8 por padrão, mas o Excel ainda gera CSV em cp1252 quando você usa "Salvar Como" no Windows.

Para verificar o encoding de um arquivo sem abrir num editor:

# Linux/macOS
file -i meu-arquivo.csv
# meu-arquivo.csv: text/plain; charset=iso-8859-1

# Python
import chardet
with open("meu-arquivo.csv", "rb") as f:
    resultado = chardet.detect(f.read())
print(resultado)
# {'encoding': 'ISO-8859-1', 'confidence': 0.73, 'language': ''}

Note a confiança de 73%: detecção automática de encoding é probabilística. Para arquivos ambíguos (com poucos caracteres especiais), pode errar. Se o arquivo vier com documentação sobre o encoding, use isso — não confie só na detecção automática.

3. HTTP header ou meta tag ausente

Para conteúdo web, o encoding precisa ser declarado explicitamente. Um servidor que devolve HTML sem Content-Type: text/html; charset=utf-8 está delegando ao browser a tarefa de adivinhar. A maioria dos browsers modernos vai de UTF-8, mas versões antigas e alguns parsers de scraping não.

<!-- No <head> do HTML -->
<meta charset="UTF-8">
# Em uma API Flask
return Response(json.dumps(data, ensure_ascii=False), 
                content_type='application/json; charset=utf-8')

O ensure_ascii=False no Python é crítico: por padrão, json.dumps escapa todos os caracteres não-ASCII como \uXXXX. O JSON resultante é tecnicamente válido mas ilegível para debug e desnecessariamente maior.


Garantir UTF-8 ponta a ponta

A estratégia mais simples é: não misture encodings. Defina UTF-8 como padrão em todos os pontos e nunca mude.

Checklist prático:

  • Banco de dados: charset utf8mb4, collation utf8mb4_unicode_ci (MySQL/MariaDB) ou UTF8 padrão (PostgreSQL — já é UTF-8 por padrão na criação)
  • Conexão com banco: SET NAMES utf8mb4 (MySQL) ou client_encoding = UTF8 (PostgreSQL)
  • Arquivos de código: UTF-8 sem BOM no editor (verifique nas configurações do editor)
  • Arquivos de dados (CSV, TXT): UTF-8 com ou sem BOM dependendo do consumidor (Excel no Windows precisa de BOM para reconhecer automaticamente)
  • APIs: header Content-Type com charset=utf-8 explícito
  • HTML: <meta charset="UTF-8"> como primeira tag do <head>
  • Python: open(arquivo, encoding="utf-8") — nunca confiar no encoding padrão do sistema

O problema do BOM (Byte Order Mark) merece atenção: BOM em UTF-8 (0xEF 0xBB 0xBF) é opcional e tecnicamente desnecessário — UTF-8 não tem questão de endianness como UTF-16. Mas alguns consumidores, especialmente o Excel no Windows, usam o BOM para detectar UTF-8 automaticamente. Se você controla a geração e sabe que o destino é Excel, adicionar BOM é pragmático. Se o destino é qualquer outra coisa, BOM pode causar problemas (parsers que não esperam os três bytes extras no início).


A relação com arquivos CSV

CSV merece menção especial porque concentra todos esses problemas num só lugar: não tem cabeçalho de metadados declarando o encoding, é gerado por ferramentas díspares (Python, Excel, sistemas legados), e circula entre times que nunca conversaram sobre encoding.

Se você está lidando especificamente com CSV quebrando em pipeline de dados, o post Importando CSV com encoding errado: como diagnosticar e corrigir cobre os casos práticos com Excel, Python e detecção automática em detalhe. O que descrevo aqui é o mecanismo por baixo — o que explica por que aquelas soluções funcionam.


Perguntas frequentes

Por que ç vira ç especificamente?

Porque ç em UTF-8 é 0xC3 0xA7. Quando esses dois bytes são interpretados como Latin-1: 0xC3 é à e 0xA7 é §. É uma tradução byte-a-byte determinística — toda vez que você vê ç, é ç em UTF-8 lido como Latin-1. Da mesma forma, ã (U+00E3) em UTF-8 é 0xC3 0xA3, que em Latin-1 é ã. Por isso ão vira ão — é o padrão mais reconhecível de mojibake em português.

Como detectar programaticamente se um texto tem mojibake?

Não há como com 100% de certeza, mas algumas heurísticas ajudam: presença de sequências ç, ã, é, á, Ã, â indica UTF-8 lido como Latin-1. O oposto — caracteres individuais fora do ASCII onde o arquivo deveria ser Latin-1 — é mais difícil de detectar automaticamente. Para dados em produção, o melhor é estabelecer a convenção de encoding desde o início e nunca depender de detecção.

utf8 e utf8mb4 no MySQL: qual diferença real?

utf8 no MySQL suporta code points até U+FFFF (3 bytes por caractere). utf8mb4 suporta até U+10FFFF (4 bytes), cobrindo emojis e scripts raros. A diferença prática: se um usuário inserir um emoji num campo TEXT com charset utf8, o MySQL vai rejeitar com erro ou truncar dependendo do sql_mode. Use sempre utf8mb4 em novos projetos — o custo de armazenamento é insignificante e você evita surpresas.

O Python 3 resolve isso automaticamente?

Parcialmente. Python 3 trata strings internas como Unicode (UTF-32 em memória), o que elimina o problema dentro do processo. O problema reaparece nas fronteiras: ao ler um arquivo, a função open() usa o encoding padrão do sistema operacional se você não especificar (locale.getpreferredencoding()). No Windows, isso pode ser cp1252. Em Linux com locale em UTF-8, é UTF-8. Sempre especifique encoding="utf-8" explicitamente ao abrir arquivos para comportamento consistente entre ambientes.


Bytes não mentem — a interpretação que erra

O dado não quebra sozinho. Quando você vê mojibake, os bytes estão lá, intactos. O que falhou foi o contrato de interpretação em algum ponto do caminho — uma configuração não explicitada, uma ferramenta com default diferente, um campo de banco com charset errado.

A solução não é mágica: é definir UTF-8 como padrão em cada camada e tornar isso explícito ao invés de depender de defaults. Para inspecionar code points e ver como caracteres específicos se traduzem em bytes, o ASCII Table Generator mostra o mapeamento de cada caractere com seus valores em decimal, hexadecimal e binário — útil quando você está tentando entender por que um byte específico está gerando o caractere errado.

O momento certo para pensar nisso é antes do primeiro deploy, não depois da primeira reclamação de usuário sobre acento quebrado em produção.

RD
Autor
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
Ver perfil