← Voltar para o Blog

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:

CamadaOnde se define
NavegadorContent-Type e meta charset
Servidor webcharset na resposta
AplicaçãoConfiguração da linguagem e do driver
Conexão com o bancoclient_encoding
Banco de dadosEncoding e collation
Sistema operacionalVariáveis LANG e LC_*
TerminalO 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 status

O 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-8

Em RHEL, AlmaLinux e Rocky:

sudo dnf install glibc-langpack-pt
sudo localectl set-locale LANG=pt_BR.UTF-8

A 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-8
sudo systemctl daemon-reload
sudo systemctl restart meuapp

Confirme 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-8

Banco 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.csv

Se 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.csv

Nesse 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 | head

Essa 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=LANG nas units dos serviços, e ENV LANG nas imagens.
  • Banco com encoding UTF8 e collation adequada ao idioma.
  • charset=utf-8 nas 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_ci

Um 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))           # True

A 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.

Suba um servidor em minutos

Preço em real, suporte em português e dados no Brasil.

Criar conta