O acento que virou interrogação: locale e UTF-8 no servidor
O nome do cliente aparece como "Jo?o" no relatório e correto na tela. O CSV abre torto no Excel. A ordenação ignora acento. Todos são o mesmo problema, em camadas diferentes.
Equipe EasyOps Cloud · · 7 min de leitura
O nome do cliente aparece como Jo?o no relatório em PDF e correto na tela do
sistema. O CSV exportado abre com João no Excel. A busca por "café" não
encontra "CAFE". A ordenação alfabética coloca "Ávila" depois de "Zuza".
São quatro sintomas do mesmo problema, manifestando-se em camadas diferentes. E a correção exige entender onde exatamente a informação se perde, porque mexer na camada errada não resolve — só muda o sintoma de lugar.
As camadas onde a codificação pode quebrar
Um caractere sai do teclado do usuário e atravessa muita coisa:
| Camada | Onde se define |
|---|---|
| Navegador | Content-Type e meta charset |
| Servidor web | charset na resposta |
| Aplicação | Configuração da linguagem e do driver |
| Conexão com o banco | client_encoding |
| Banco de dados | Encoding e collation |
| Sistema operacional | Variáveis LANG e LC_* |
| Terminal | O emulador de terminal em si |
Cada uma pode estar em UTF-8 ou em outra coisa. O caractere sobrevive apenas se todas concordarem — e o erro clássico é corrigir a última camada, ver o sintoma sumir da tela e concluir que o dado foi salvo corretamente.
Vale um princípio antes de tudo: converta na entrada, armazene em UTF-8, e converta apenas na saída, quando o destino exigir outra coisa. Dado armazenado com codificação inconsistente é o problema mais caro de corrigir depois, porque exige adivinhar a origem de cada registro.
Conferir o sistema
locale
localectl statusO resultado desejado tem UTF-8 em toda parte. Se aparecerem valores vazios, POSIX
ou C, o sistema não está configurado — e o sintoma típico é o script que falha
ao processar arquivo com acento, ou o sort que ordena errado.
Em Debian e Ubuntu:
sudo apt install locales
sudo sed -i 's/^# *pt_BR.UTF-8/pt_BR.UTF-8/' /etc/locale.gen
sudo locale-gen
sudo localectl set-locale LANG=pt_BR.UTF-8Em RHEL, AlmaLinux e Rocky:
sudo dnf install glibc-langpack-pt
sudo localectl set-locale LANG=pt_BR.UTF-8A mudança vale para sessões novas. Reconecte antes de testar.
Vale um alerta sobre pt_BR.UTF-8 como padrão global do sistema. Ele muda o
formato de número e de data em ferramentas de linha de comando — o separador
decimal vira vírgula, e scripts que fazem contas começam a se comportar de forma
inesperada.
Um meio-termo seguro é manter LANG=C.UTF-8, que dá suporte completo a UTF-8 sem
alterar formatação, e definir o idioma apenas onde ele é necessário.
Serviço não herda o seu locale
Este é o ponto que confunde. Configurar o locale do seu usuário não afeta um
serviço do systemd — que não faz login e tem ambiente próprio, pelo mesmo motivo
que o cron não herda o seu PATH.
O sintoma é uma aplicação que processa acento corretamente quando você a executa na mão e quebra quando ela roda como serviço.
# /etc/systemd/system/meuapp.service.d/locale.conf
[Service]
Environment=LANG=pt_BR.UTF-8
Environment=LC_ALL=pt_BR.UTF-8sudo systemctl daemon-reload
sudo systemctl restart meuappConfirme o que o processo realmente recebeu:
sudo cat /proc/$(systemctl show -p MainPID --value meuapp)/environ | \
tr '\0' '\n' | grep -E '^(LANG|LC_)'O mesmo vale para container. A maioria das imagens base vem com LANG não
definido:
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8Banco de dados: encoding e collation
No Postgres, duas propriedades diferentes e igualmente importantes:
SHOW server_encoding;
SHOW client_encoding;
SELECT datname, pg_encoding_to_char(encoding), datcollate
FROM pg_database;O encoding define como os caracteres são armazenados — precisa ser UTF8. O collation
define ordenação e comparação, e é o que faz "Ávila" vir antes de "Zuza" ou
depois.
Um banco criado com collation C ordena por código de caractere, colocando todos
os acentuados após o alfabeto comum. Já pt_BR.UTF-8 ordena como uma pessoa
espera.
CREATE DATABASE app
ENCODING 'UTF8'
LC_COLLATE 'pt_BR.UTF-8'
LC_CTYPE 'pt_BR.UTF-8'
TEMPLATE template0;O TEMPLATE template0 é obrigatório ao mudar collation, e é o detalhe que faz o
comando falhar quando esquecido.
Trocar o encoding de um banco existente exige dump e restauração — não há comando para converter no lugar. Já a ordenação dá para resolver por consulta, sem migrar nada:
SELECT nome FROM clientes ORDER BY nome COLLATE "pt_BR.UTF-8";E para busca que ignore acento e maiúscula, a extensão unaccent resolve de forma
limpa:
CREATE EXTENSION IF NOT EXISTS unaccent;
SELECT * FROM clientes WHERE unaccent(lower(nome)) LIKE unaccent(lower('%café%'));Vale criar um índice sobre a expressão, senão a busca vira varredura completa — assunto tratado no artigo sobre parâmetros do Postgres.
O CSV que abre torto no Excel
O caso mais reportado por usuário final, e o mais mal compreendido: o arquivo está correto e o Excel é que não pergunta.
Ao abrir um .csv com duplo clique, o Excel no Windows assume a codificação do
sistema — normalmente Windows-1252 — e não UTF-8. O resultado é João.
Há duas saídas, e a primeira é a que funciona sem treinar ninguém:
# BOM no início do arquivo sinaliza UTF-8 ao Excel
with open("saida.csv", "w", encoding="utf-8-sig", newline="") as f:
...O utf-8-sig acrescenta uma marca de três bytes no começo. O Excel a reconhece e
abre corretamente. A ressalva é que alguns leitores tratam essa marca como
conteúdo, e o cabeçalho da primeira coluna aparece com caracteres estranhos —
vale testar no destino real.
A alternativa é exportar em .xlsx de verdade, que carrega a codificação no
próprio formato e elimina a ambiguidade. Para relatório destinado a Excel,
costuma ser a decisão certa.
Vale notar que o separador também muda: em configuração brasileira, o Excel espera ponto e vírgula, não vírgula.
Consertar arquivo já quebrado
Quando o dado já existe com codificação errada:
# Descobrir o que é
file -i arquivo.csv
chardetect arquivo.csv 2>/dev/null
# Converter
iconv -f ISO-8859-1 -t UTF-8 arquivo.csv -o arquivo-utf8.csvSe aparecer erro de sequência inválida, o arquivo tem codificação mista — parte
em uma, parte em outra. O -c descarta o que não converte, o que resolve o
arquivo e perde caracteres:
iconv -f ISO-8859-1 -t UTF-8 -c arquivo.csv -o saida.csvNesse caso, vale investigar a origem antes de converter em massa. Arquivo com codificação mista costuma indicar que o problema continua acontecendo no processo que o gera.
Para achar o que está quebrado dentro de um arquivo grande:
grep -axv '.*' arquivo.csv | headEssa linha lista as que não são texto válido no locale atual — útil para conferir se a conversão resolveu tudo antes de substituir o original.
O checklist
- Sistema em `C.UTF-8` ou
pt_BR.UTF-8, conscientemente. Environment=LANGnas units dos serviços, eENV LANGnas imagens.- Banco com encoding UTF8 e collation adequada ao idioma.
charset=utf-8nas respostas do servidor web.- BOM ou `.xlsx` para o que vai ao Excel.
- Converter na entrada, armazenar em UTF-8, converter só na saída.
O sinal de que está tudo certo é entediante: acento aparece igual na tela, no relatório, no e-mail e no arquivo exportado — e ninguém precisa pensar no assunto.
Emoji, e por que ele quebra o banco
Um caso específico que aparece com frequência em sistema que recebe texto de usuário: o comentário com emoji causa erro ao ser salvo, ou o texto é truncado exatamente no emoji.
A causa está no MySQL e no MariaDB. O que eles historicamente chamaram de utf8
não é UTF-8 completo — é uma variante limitada a três bytes por caractere.
Emoji, alguns ideogramas e símbolos matemáticos precisam de quatro.
O UTF-8 de verdade, nesses bancos, chama-se utf8mb4:
ALTER DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE comentarios CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;A conexão também precisa concordar, senão a conversão acontece no caminho:
# my.cnf
[client]
default-character-set = utf8mb4
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ciUm efeito colateral a conhecer: como utf8mb4 reserva quatro bytes por caractere,
índices sobre colunas de texto longo podem estourar o limite de tamanho de
chave. Costuma ser resolvido reduzindo o tamanho declarado da coluna, ou
indexando apenas um prefixo dela.
O Postgres não tem esse problema — o UTF8 dele sempre foi o completo.
Normalização: dois textos iguais que não são
Um último caso, sutil e que aparece em busca e em comparação de chave. Existem duas formas de representar um caractere acentuado em Unicode: um único ponto de código para "é", ou a letra "e" seguida de um acento combinante.
Visualmente são idênticos. Para o banco e para a linguagem, são cadeias diferentes — com tamanhos diferentes e comparação que retorna falso.
Isso costuma aparecer quando o dado vem de origens distintas: texto digitado no macOS tende a usar a forma decomposta, enquanto Windows e Linux usam a composta.
import unicodedata
a = "café" # composta
b = "café" # decomposta
print(a == b) # False
print(unicodedata.normalize("NFC", a) ==
unicodedata.normalize("NFC", b)) # TrueA correção é normalizar na entrada, escolhendo uma forma — NFC é a convenção
mais comum na web — e aplicando-a a todo texto antes de armazenar ou comparar.
Fazer isso na borda do sistema é bem mais simples que descobrir, meses depois,
por que dois registros aparentemente iguais não casam.