Em Python, texto é str e dados binários são bytes. Essa é a resposta inicial que resolve a maior parte das dúvidas: use str enquanto manipula texto dentro do programa, converta para bytes com encode() ao atravessar uma fronteira binária e recupere str com decode() ao entrar novamente no domínio textual. UTF-8 é a codificação normalmente escolhida para essa conversão, não um tipo de string.

Essa separação aparece ao abrir arquivos, transmitir HTTP, conversar com bancos e processar JSON. Misturar os dois domínios produz erros explícitos, como UnicodeDecodeError, ou defeitos silenciosos, como João. Entender o modelo evita tentativas aleatórias de “consertar acentos” e torna o diagnóstico previsível.

Unicode não é UTF-8

Unicode é um repertório que atribui um ponto de código a caracteres. A letra A é U+0041; ç é U+00E7. Uma str representa uma sequência desses caracteres, independentemente de como será gravada. Para revisar operações textuais, veja também o guia de strings em Python.

UTF-8 define como pontos de código viram octetos. Caracteres ASCII usam um byte; muitos caracteres latinos usam dois; outros podem usar três ou quatro. Por isso, quantidade de caracteres e tamanho binário não são sinônimos:

texto = "ação"
dados = texto.encode("utf-8")

print(len(texto))       # 4 caracteres
print(len(dados))       # 6 bytes
print(dados)            # b'a\xc3\xa7\xc3\xa3o'
print(dados.decode("utf-8") == texto)  # True

O prefixo b identifica um literal de bytes. Escapes como \xc3 mostram valores hexadecimais, não texto corrompido. A documentação do Unicode HOWTO aprofunda pontos de código, representações e normalização.

Encode na saída, decode na entrada

encode() pertence a str e devolve bytes; decode() pertence a bytes e devolve str. Uma regra útil é decodificar uma vez, logo após receber bytes, e codificar uma vez, imediatamente antes de enviá-los. O núcleo da aplicação permanece textual.

def montar_mensagem(nome: str) -> bytes:
    texto = f"Olá, {nome}!"
    return texto.encode("utf-8")

pacote = montar_mensagem("Lívia")
mensagem = pacote.decode("utf-8")
print(mensagem)

Não aplique str(dados) para decodificar. str(b"olá") requer até que o literal seja válido e, em bytes reais, produz uma representação como "b'ol\xc3\xa1'". Use a codificação declarada pelo protocolo. A referência oficial de bytes e bytearray detalha operações binárias e mutabilidade.

Arquivos e JSON

Em modo texto, open realiza as conversões. Declare encoding="utf-8" para que o resultado não dependa da configuração da máquina:

from pathlib import Path

caminho = Path("clientes.txt")
caminho.write_text("Ana\nJosé\n", encoding="utf-8")
conteudo = caminho.read_text(encoding="utf-8")
print(conteudo.splitlines())

O mesmo princípio vale para open(caminho, "w", encoding="utf-8", newline=""). O tutorial de manipulação de arquivos TXT, CSV e JSON mostra formatos completos, enquanto pathlib em Python simplifica caminhos e leitura textual.

JSON é um formato textual. json.dumps() retorna str; json.loads() normalmente recebe str. Para preservar caracteres legíveis, use ensure_ascii=False; depois codifique se uma biblioteca exigir bytes:

import json

registro = {"cidade": "São Luís", "moeda": "€"}
texto_json = json.dumps(registro, ensure_ascii=False)
corpo_http = texto_json.encode("utf-8")

restaurado = json.loads(corpo_http.decode("utf-8"))
assert restaurado == registro

Consulte JSON em Python para validação e serialização de estruturas maiores.

Erros de codificação sem adivinhação

UnicodeEncodeError significa que a codificação de destino não representa algum caractere. ASCII, por exemplo, não representa ã. UnicodeDecodeError significa que a sequência de bytes não é válida na codificação escolhida. O erro registra codec, posição e motivo:

dados_latin1 = b"Ol\xe1"

try:
    print(dados_latin1.decode("utf-8"))
except UnicodeDecodeError as erro:
    print(erro.encoding, erro.start, erro.reason)

print(dados_latin1.decode("latin-1"))  # Olá

A solução correta é descobrir a origem: cabeçalho HTTP, especificação do arquivo, contrato do fornecedor ou configuração do banco. Detector de encoding fornece hipótese, nunca prova. Se você controla o sistema, padronize UTF-8 de ponta a ponta.

Os manipuladores errors="ignore" e errors="replace" evitam exceções, mas descartam informação. Isso é perigoso em nomes, identificadores, assinaturas e auditoria. Prefira errors="strict", o padrão. Para uma importação legada em que perda foi aceita conscientemente, replace insere e permite contabilizar registros afetados. backslashreplace é útil em logs porque conserva uma representação do valor problemático. A lista completa está na documentação de codecs do Python.

Mojibake e dupla codificação

João costuma surgir quando bytes UTF-8 foram decodificados como Latin-1. O texto errado pode então ser salvo, perpetuando o dano. Não corrija com substituições manuais. Reprocesse os bytes originais com o codec correto. Se só restou a string corrompida e a cadeia é conhecida, uma recuperação controlada pode ser testada:

corrompido = "João"
recuperado = corrompido.encode("latin-1").decode("utf-8")
print(recuperado)  # João

Isso não é receita universal. Faça backup, valide amostras e registre alterações. Aplicar a transformação duas vezes corrompe dados válidos.

Normalização e comparação segura

Visualmente, é pode ser um ponto de código ou e seguido de acento combinante. As strings parecem iguais, mas podem ter bytes e comparação diferentes. Normalize antes de comparar entradas vindas de fontes distintas:

import unicodedata

a = "café"
b = "cafe\u0301"
print(a == b)  # False
print(unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b))  # True

NFC é uma escolha comum para armazenamento. NFKC também aplica equivalências de compatibilidade e pode alterar significado; não a use sem requisito. Para busca sem diferença entre maiúsculas e minúsculas, casefold() é mais abrangente que lower(), mas autorização nunca deve depender apenas de aparência textual.

Práticas, segurança e observabilidade

Defina encoding em arquivos e contratos. Não registre segredos nem corpos completos só para investigar um erro; registre origem, codec declarado, posição e uma amostra limitada em hexadecimal. Limite o tamanho antes de decodificar dados externos, pois uma entrada enorme ainda consome memória. Valide conteúdo depois da decodificação: UTF-8 válido não significa dado confiável.

Em URLs e HTTP, deixe bibliotecas maduras tratarem percent-encoding e cabeçalhos. Em bancos, confirme conexão, tabela e coluna em Unicode. Teste nomes com acentos, caracteres combinantes, alfabetos não latinos e emoji, mesmo que a interface não os destaque. O guia de tratamento de erros com try/except ajuda a capturar falhas no limite certo; logging em Python mostra como preservar contexto sem esconder exceções.

Perguntas frequentes

Qual é a diferença entre str e bytes?

str contém texto Unicode; bytes contém números de 0 a 255. Codificação transforma texto em bytes, e decodificação faz o caminho inverso.

Devo sempre usar UTF-8?

Use UTF-8 por padrão quando controla o contrato. Ao consumir dados externos, respeite a codificação declarada e não imponha UTF-8 a bytes legados.

Posso usar errors="ignore" para eliminar o erro?

Tecnicamente sim, mas ele remove dados silenciosamente. Use apenas quando a perda for explicitamente aceitável e mensurada; para dados de negócio, prefira falhar e corrigir a origem.

Por que o texto aparece como b'...'?

Porque o valor ainda é bytes ou sua representação foi convertida com str(). Decodifique os bytes uma única vez usando o codec correto.

Conclusão

Unicode modela caracteres, UTF-8 os representa em bytes, encode() prepara texto para uma fronteira binária e decode() interpreta bytes recebidos. Mantendo str no núcleo, declarando codecs nas bordas e tratando erros sem descartar informação, arquivos e integrações deixam de depender de sorte. Quando algo falhar, preserve os bytes originais, identifique o contrato e corrija a fronteira, não os sintomas.