Por que o ç vira ç
Não é corrupção nem perda de dado. É tradução com o dicionário errado, e entender uma linha resolve o assunto para sempre.
Em UTF-8, os caracteres acima do inglês básico ocupam mais de um
byte. O ç é gravado como dois bytes:
C3 e A7.
Já o Latin-1 é uma tabela de um byte por caractere. Quando ele lê aqueles
mesmos dois bytes, não vê um ç — vê dois caracteres
separados: C3 é Ã e A7 é
§. Daí ç virar ç.
É por isso que o lixo tem sempre a mesma cara. Ã e
 aparecem em quase todo mojibake porque C3 e
C2 são os primeiros bytes da maioria dos acentos latinos.
| Original | Bytes em UTF-8 | Lido como Latin-1 |
|---|---|---|
ç | C3 A7 | ç |
ã | C3 A3 | ã |
á | C3 A1 | á |
é | C3 A9 | é |
ó | C3 B3 | ó |
| espaço fixo | C2 A0 | Â |
A boa notícia e a má
A boa: nesse caso nada se perdeu. Os bytes continuam lá, só foram interpretados errado — dá para desfazer sem perder um acento sequer. É o que a ferramenta acima faz.
A má: existe um caso em que se perde, e ele tem
cara própria. Se o texto tiver � (losango com interrogação)
ou ? no lugar dos acentos, os bytes originais foram
substituídos e não há como recuperar — só voltando ao
backup ou à fonte.
Onde consertar de verdade
Consertar o texto é remédio; consertar a origem é cura. São quatro lugares onde a tradução pode dar errado, e basta um estar fora do lugar:
1. A conexão com o banco — o suspeito número um
Este é o mais comum e o mais enganoso: a coluna está certa, o texto que chegou está certo, e mesmo assim sai torto — porque a conexão não declarou o charset e o servidor assumiu latin1.
-- confira o que a sua conexão está usando
SHOW VARIABLES LIKE 'character_set_client';
SHOW VARIABLES LIKE 'character_set_connection';
# PHP (PDO)
new PDO($dsn, $u, $p, [PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4"]);
# ou direto na DSN: mysql:host=...;dbname=...;charset=utf8mb4
# Node (mysql2)
createPool({ ..., charset: 'utf8mb4' })
2. A coluna do banco
Use utf8mb4, nunca utf8. Apesar do nome, o
utf8 do MySQL é incompleto: guarda no máximo três bytes por
caractere e não aguenta emoji, que precisam de quatro. O
sintoma é o emoji sumir ou virar ???? enquanto os acentos
funcionam.
SELECT table_name, column_name, character_set_name
FROM information_schema.columns
WHERE table_schema = DATABASE() AND character_set_name IS NOT NULL;
3. A página
A declaração precisa estar nos primeiros bytes do HTML — se vier depois de muito conteúdo, o navegador já adivinhou errado:
<meta charset="utf-8"> <!-- primeira coisa no <head> -->
Content-Type: text/html; charset=utf-8 <!-- e no cabeçalho HTTP -->
4. O arquivo
Descubra o que ele realmente é antes de converter — e nunca converta por cima do original:
file -I dados.csv # diz o charset detectado
iconv -f ISO-8859-1 -t UTF-8 dados.csv > dados-utf8.csv
O caso da dupla codificação
Quando você vê ç em vez de ç — lixo em cima
de lixo — o texto passou pela conversão errada duas vezes.
Costuma acontecer quando alguém “conserta” um banco rodando a conversão
num conteúdo que já estava certo.
Tem solução, e a ferramenta acima desfaz as duas camadas. Mas é o melhor argumento para o backup: a segunda passada é o que transforma um problema de dez minutos num de dez horas.
O caso do Excel com CSV
Clássico brasileiro: você exporta CSV em UTF-8 correto, abre no Excel e os acentos quebram. O arquivo está certo — o Excel é que ignora UTF-8 sem marcação e assume a codificação regional do Windows.
A solução é gravar o BOM, três bytes invisíveis no começo que dizem ao Excel qual tabela usar:
// Node — o é o BOM
fs.writeFileSync('saida.csv', '' + conteudo, 'utf8')
// PHP
fwrite($f, "\xEF\xBB\xBF"); // antes de qualquer linha
Outras ferramentas de diagnóstico: erro de CORS, erro de npm, JWT rejeitado.