Programas raramente existem de forma isolada — eles leem configurações, processam dados de entrada, geram relatórios e persistem informações. Arquivos são o mecanismo mais fundamental de persistência em qualquer sistema. Python oferece uma API limpa e expressiva para trabalhar com arquivos de texto, binários e formatos estruturados como CSV e JSON.
Abrindo e Fechando Arquivos
A função open() abre um arquivo e retorna um objeto de arquivo:
# Forma manual — exige fechar explicitamente
arquivo = open("dados.txt", "r", encoding="utf-8")
conteudo = arquivo.read()
arquivo.close()
# Forma recomendada — with fecha automaticamente
with open("dados.txt", "r", encoding="utf-8") as arquivo:
conteudo = arquivo.read()
Sempre especifique encoding="utf-8" — evita problemas com acentuação em diferentes sistemas operacionais.
O conselho merece a explicação, porque o motivo não é óbvio. Sem esse argumento, o Python usa a codificação preferida do sistema, e não uma codificação fixa: em Linux e macOS isso costuma ser UTF-8, e no Windows, historicamente, é uma página de código regional — cp1252 no Brasil. O mesmo programa, lendo o mesmo arquivo, funciona numa máquina e levanta UnicodeDecodeError na outra. Pior: às vezes não levanta erro nenhum e apenas troca os acentos por caracteres errados, o que passa despercebido até alguém ler o relatório.
Como é fácil esquecer, o Python oferece uma forma de caçar as ocorrências: executando com -X warn_default_encoding, todo open() sem encoding passa a emitir um aviso apontando a linha.
python -X warn_default_encoding -W error::EncodingWarning programa.py
# EncodingWarning: 'encoding' argument not specified
Vale rodar a suíte de testes assim de vez em quando. A regra só não se aplica ao modo binário, com "rb" e "wb", em que não há decodificação e passar encoding é erro.
Modos de Abertura
# Leitura (padrão) — erro se arquivo não existir
with open("arquivo.txt", "r") as f: ...
# Escrita — cria o arquivo ou sobrescreve se existir
with open("arquivo.txt", "w") as f: ...
# Adição — cria ou adiciona ao final
with open("arquivo.txt", "a") as f: ...
# Leitura e escrita
with open("arquivo.txt", "r+") as f: ...
# Modo binário — para imagens, PDFs, etc.
with open("imagem.png", "rb") as f: ...
with open("saida.png", "wb") as f: ...
# Criação exclusiva — erro se arquivo já existir
with open("novo.txt", "x") as f: ...
Lendo Arquivos
# read() — lê o arquivo inteiro como string
with open("texto.txt", "r", encoding="utf-8") as f:
conteudo = f.read()
print(conteudo)
# readline() — lê uma linha por vez
with open("texto.txt", "r", encoding="utf-8") as f:
primeira = f.readline()
segunda = f.readline()
# readlines() — retorna lista de linhas
with open("texto.txt", "r", encoding="utf-8") as f:
linhas = f.readlines()
print(linhas) # ['linha1\n', 'linha2\n', 'linha3\n']
# Iteração direta — mais eficiente para arquivos grandes
with open("texto.txt", "r", encoding="utf-8") as f:
for linha in f:
print(linha.strip()) # strip() remove o \n do final
Para arquivos grandes, a iteração direta é preferível — não carrega tudo na memória de uma vez.
Escrevendo Arquivos
# Escrita simples
with open("saida.txt", "w", encoding="utf-8") as f:
f.write("Primeira linha\n")
f.write("Segunda linha\n")
# writelines() — escreve lista de strings
linhas = ["Ana\n", "Bruno\n", "Carla\n"]
with open("nomes.txt", "w", encoding="utf-8") as f:
f.writelines(linhas)
# print() com file= — prático para formatação
with open("relatorio.txt", "w", encoding="utf-8") as f:
print("=== Relatório ===", file=f)
print(f"Total: {42}", file=f)
# Adicionando ao final
with open("log.txt", "a", encoding="utf-8") as f:
from datetime import datetime
f.write(f"{datetime.now()} — Evento registrado\n")
Trabalhando com Caminhos: pathlib
O módulo pathlib oferece uma forma orientada a objetos e multiplataforma de trabalhar com caminhos — substitui o antigo os.path:
from pathlib import Path
# Criando caminhos
base = Path("/home/usuario/projetos")
arquivo = base / "dados" / "resultado.txt"
print(arquivo) # /home/usuario/projetos/dados/resultado.txt
print(arquivo.name) # resultado.txt
print(arquivo.stem) # resultado
print(arquivo.suffix) # .txt
print(arquivo.parent) # /home/usuario/projetos/dados
# Verificações
print(arquivo.exists())
print(arquivo.is_file())
print(arquivo.is_dir())
# Leitura e escrita diretas
arquivo.parent.mkdir(parents=True, exist_ok=True)
arquivo.write_text("Conteúdo do arquivo", encoding="utf-8")
conteudo = arquivo.read_text(encoding="utf-8")
# Listando arquivos
pasta = Path(".")
for item in pasta.iterdir():
print(item)
# Filtrando por extensão
for py_file in pasta.glob("**/*.py"):
print(py_file)
# Caminho do usuário
home = Path.home()
config = home / ".config" / "meu_app" / "config.json"
Arquivos CSV
CSV (Comma-Separated Values) é o formato mais comum para dados tabulares:
import csv
from pathlib import Path
# Escrevendo CSV
alunos = [
{"nome": "Ana", "nota": 9.5, "turma": "A"},
{"nome": "Bruno", "nota": 7.0, "turma": "B"},
{"nome": "Carla", "nota": 8.5, "turma": "A"},
]
with open("alunos.csv", "w", newline="", encoding="utf-8") as f:
campos = ["nome", "nota", "turma"]
writer = csv.DictWriter(f, fieldnames=campos)
writer.writeheader()
writer.writerows(alunos)
# Lendo CSV
with open("alunos.csv", "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for linha in reader:
print(f"{linha['nome']:10} — nota: {linha['nota']}")
# atenção: linha['nota'] é a STRING "9.5", não o número
# CSV com delimitador diferente
with open("dados.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f, delimiter=";")
for linha in reader:
print(linha)
Duas ressalvas sobre CSV, e a primeira é a que mais produz relatório errado sem dar erro: o módulo csv devolve tudo como texto. Escrevemos 9.5 como número e lemos de volta a string "9.5". A f-string do exemplo acima esconde isso perfeitamente, porque imprimir string ou número dá o mesmo resultado na tela — o problema aparece na primeira conta.
notas = [l["nota"] for l in csv.DictReader(f)]
sum(notas)
# TypeError: unsupported operand type(s) for +: 'int' and 'str'
sorted(["9.5", "10.0", "7.0"])
# ['10.0', '7.0', '9.5'] — ordem alfabética: "10" vem antes de "7"
A soma ao menos falha barulhentamente. A ordenação é o caso perigoso: devolve uma lista plausível, com a nota 10 em primeiro lugar por começar com o algarismo 1, e ninguém percebe. A conversão é responsabilidade de quem lê, e deve acontecer na entrada — float(linha["nota"]) —, tratando o valor ausente ou malformado ali mesmo. Quando a tabela é grande e tem muitas colunas com tipos diferentes, vale considerar o pandas, que infere os tipos ao ler.
A segunda ressalva é o newline="". O exemplo o usa ao escrever, e a documentação do módulo pede que ele seja usado também na leitura — pelo motivo concreto de que, sem ele, uma quebra de linha dentro de um campo entre aspas é convertida em silêncio:
# um campo com \r\n dentro, como o Excel grava
with open("dados.csv", newline="", encoding="utf-8") as f:
list(csv.reader(f)) # ['1', 'linha um\r\nlinha dois'] — preservado
with open("dados.csv", encoding="utf-8") as f:
list(csv.reader(f)) # ['1', 'linha um\nlinha dois'] — alterado
Não há erro, e o dado que sai do programa não é o que entrou. Na escrita o argumento evita o problema espelhado: no Windows, sem ele, o terminador \r\n produzido pelo módulo é traduzido outra vez e o arquivo sai com uma linha em branco entre cada registro. A regra é curta — newline="" em toda abertura de arquivo CSV, nos dois sentidos.
Arquivos JSON
JSON é o formato padrão para troca de dados em APIs e configurações:
import json
from pathlib import Path
# Serialização — Python → JSON
config = {
"host": "localhost",
"porta": 5432,
"debug": True,
"tags": ["producao", "v2"],
"timeout": None
}
# Para string
json_str = json.dumps(config, indent=2, ensure_ascii=False)
print(json_str)
# Para arquivo
with open("config.json", "w", encoding="utf-8") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
# Desserialização — JSON → Python
with open("config.json", "r", encoding="utf-8") as f:
dados = json.load(f)
print(dados["host"]) # localhost
print(type(dados)) # <class 'dict'>
# Usando pathlib diretamente
config_path = Path("config.json")
dados = json.loads(config_path.read_text(encoding="utf-8"))
Correspondência de tipos:
JSON Python
─────────────────────
object → dict
array → list
string → str
number → int / float
true/false→ True / False
null → None
Arquivos Binários e pickle
Para serializar objetos Python arbitrários:
import pickle
from dataclasses import dataclass
from typing import List
@dataclass
class ModeloML:
nome: str
parametros: dict
acuracia: float
versao: str
modelo = ModeloML(
nome="ClassificadorSpam",
parametros={"learning_rate": 0.01, "epochs": 100},
acuracia=0.94,
versao="1.0.0"
)
# Salvando
with open("modelo.pkl", "wb") as f:
pickle.dump(modelo, f)
# Carregando
with open("modelo.pkl", "rb") as f:
modelo_carregado = pickle.load(f)
print(modelo_carregado)
print(modelo_carregado.acuracia) # 0.94
Atenção: nunca carregue arquivos .pkl de fontes não confiáveis — o pickle pode executar código arbitrário durante a desserialização.
Exemplo Completo: Sistema de Log
import json
from pathlib import Path
from datetime import datetime
from enum import Enum
class Nivel(str, Enum):
DEBUG = "DEBUG"
INFO = "INFO"
AVISO = "AVISO"
ERRO = "ERRO"
CRITICO = "CRITICO"
class Logger:
def __init__(self, nome: str, pasta: str = "logs"):
self.nome = nome
self.pasta = Path(pasta)
self.pasta.mkdir(parents=True, exist_ok=True)
data_hoje = datetime.now().strftime("%Y-%m-%d")
self._arquivo_txt = self.pasta / f"{nome}_{data_hoje}.log"
self._arquivo_json = self.pasta / f"{nome}_{data_hoje}.jsonl"
def _registrar(self, nivel: Nivel, mensagem: str, **extras):
agora = datetime.now()
entrada = {
"timestamp": agora.isoformat(),
"nivel": nivel.value,
"logger": self.nome,
"mensagem": mensagem,
**extras
}
# Formato legível para .log
linha_txt = (f"[{agora.strftime('%H:%M:%S')}] "
f"[{nivel.value:7}] "
f"[{self.nome}] {mensagem}")
if extras:
linha_txt += f" | {extras}"
with open(self._arquivo_txt, "a", encoding="utf-8") as f:
f.write(linha_txt + "\n")
# Formato estruturado para .jsonl (JSON Lines)
with open(self._arquivo_json, "a", encoding="utf-8") as f:
f.write(json.dumps(entrada, ensure_ascii=False) + "\n")
def debug(self, msg, **extras):
self._registrar(Nivel.DEBUG, msg, **extras)
def info(self, msg, **extras):
self._registrar(Nivel.INFO, msg, **extras)
def aviso(self, msg, **extras):
self._registrar(Nivel.AVISO, msg, **extras)
def erro(self, msg, **extras):
self._registrar(Nivel.ERRO, msg, **extras)
def ler_logs(self, nivel: Nivel = None) -> list:
"""Lê e filtra logs do arquivo JSONL."""
if not self._arquivo_json.exists():
return []
logs = []
# iteração linha a linha: arquivo de log é justamente o que cresce
with open(self._arquivo_json, encoding="utf-8") as f:
for linha in f:
entrada = json.loads(linha)
if nivel is None or entrada["nivel"] == nivel.value:
logs.append(entrada)
return logs
logger = Logger("sistema")
logger.info("Aplicação iniciada", versao="2.1.0")
logger.debug("Conectando ao banco", host="localhost", porta=5432)
logger.info("Usuário autenticado", usuario_id=42)
logger.aviso("Tentativa de login falhou", tentativas=3)
logger.erro("Falha na conexão com o banco", codigo=500)
print("\n=== Logs de ERRO ===")
for log in logger.ler_logs(Nivel.ERRO):
print(f" {log['timestamp']} — {log['mensagem']}")
print(f"\n=== Total de logs: {len(logger.ler_logs())} ===")
Abrir, ler e gravar é a parte fácil, e o with resolve a maior parte do que costumava dar errado: o arquivo fecha mesmo quando o bloco levanta exceção. O que sobra são decisões que o Python não toma por você. A codificação é a primeira delas — sem encoding="utf-8", quem decide é o sistema, e o mesmo arquivo lido no Windows vira Relatório em vez de dar erro. A segunda é o tamanho: read() e read_text() carregam tudo de uma vez, e só a iteração linha a linha mantém a memória constante, o que importa justamente nos arquivos que crescem, como os de log. O pathlib junta caminho, verificação, leitura e escrita num só objeto, com a ressalva de que mkdir(exist_ok=True) só cria a última pasta; a árvore inteira pede também parents=True.
Nos formatos estruturados, o cuidado muda de lugar. O csv devolve tudo como texto, e converter na entrada é o que impede uma ordenação alfabética de passar por ranking de notas; o newline="" vale nos dois sentidos, ou uma quebra de linha dentro de um campo muda sem aviso. O json é o formato de troca, mas não faz ida e volta perfeita: chaves inteiras voltam como texto e tuplas voltam como listas. O pickle preserva qualquer objeto Python, e por isso mesmo só deve abrir arquivo de origem confiável, porque desserializar pode executar código. Por fim, o JSON Lines do exemplo de log mostra por que um objeto por linha funciona tão bem para registro: cada entrada nova é um append e a leitura pode parar em qualquer ponto.
Fontes e leituras recomendadas
- Leitura e escrita de arquivos — tutorial oficial — https://docs.python.org/3/tutorial/inputoutput.html
- pathlib — caminhos orientados a objetos — https://docs.python.org/3/library/pathlib.html
- Módulo csv — https://docs.python.org/3/library/csv.html
- Módulo json — https://docs.python.org/3/library/json.html
- Módulo pickle — https://docs.python.org/3/library/pickle.html
- BEAZLEY, David; JONES, Brian K. Python Cookbook. 3. ed. O'Reilly Media, 2013. Cap. 5 — receitas avançadas para arquivos e I/O.
- SWEIGART, Al. Automate the Boring Stuff with Python. 2. ed. No Starch Press, 2019. Cap. 8 e 9 — leitura, escrita e organização de arquivos com foco prático.
- MATTHES, Eric. Python Crash Course. 3. ed. No Starch Press, 2023. Cap. 10 — arquivos e exceções combinados.
Exercícios
Exercício 1
Um script lê notas.csv com csv.DictReader e publica no mural da escola o primeiro colocado, usando sorted(linhas, key=lambda l: l["nota"], reverse=True)[0]. O arquivo tem Ana com 9.5, Bruno com 10.0 e Carla com 7.0. O mural anuncia Ana. Não houve erro, e o script funcionou certo durante todo o semestre anterior. Explique o que aconteceu e por que só agora.
Ver resposta
✓ Resposta: O módulo csv não interpreta tipos: tudo o que ele devolve é texto, mesmo quando o arquivo foi escrito a partir de números. As notas lidas são '9.5', '10.0' e '7.0', e a ordenação de strings compara caractere a caractere. O primeiro caractere de '10.0' é '1', que vem antes de '7' e de '9', então em ordem decrescente a lista fica '9.5', '7.0', '10.0' — o 10 vai para o último lugar. No semestre anterior ninguém tirou 10, e com todas as notas entre '0.0' e '9.9', com um algarismo antes do ponto, a ordem alfabética coincide com a numérica. Por isso o defeito sempre esteve lá e só se manifestou quando apareceu a primeira nota de dois dígitos, que é exatamente a que mais importa num ranking. É o tipo de erro mais caro que existe, porque o resultado é plausível: Ana tem mesmo uma nota alta, e nada na tela sugere que falta alguém. A soma, ao contrário, teria falhado de imediato com TypeError. A correção é converter na entrada, num lugar só — nota = float(linha["nota"]) logo ao ler, tratando ali o campo vazio ou malformado —, e não espalhar float() por cada ponto do programa que usa o valor, onde um deles sempre acaba esquecido. Com key=lambda l: float(l["nota"]), o primeiro colocado passa a ser Bruno.
Exercício 2
Um programa que roda há anos num servidor Linux é instalado no notebook Windows de um cliente. Ele lê um arquivo de texto gerado pelo próprio sistema, com open("relatorio.txt"), sem outros argumentos. No Linux o relatório aparece correto; no Windows, a linha "Relatório de manutenção" sai como Relatório de manutenção, e ninguém vê mensagem de erro. Explique o que acontece e como impedir que o problema volte em outro ponto do código.
Ver resposta
✓ Resposta: Sem o argumento encoding, o open() em modo texto usa a codificação preferida do sistema, não uma codificação fixa. No servidor Linux ela é UTF-8, a mesma com que o arquivo foi gravado, e tudo bate. No Windows, com a configuração regional do Brasil, costuma ser cp1252. As letras acentuadas em UTF-8 ocupam dois bytes — o ó é C3 B3 —, e o cp1252 lê cada byte como um caractere separado: C3 vira à e B3 vira ³. Os bytes das minúsculas acentuadas do português têm todos um caractere definido em cp1252, e é por isso que não há erro: o programa segue adiante com o texto corrompido, e ele pode até ser gravado de volta assim. Pior, a falha é intermitente. O Á maiúsculo em UTF-8 é C3 81, e o byte 81 não tem caractere em cp1252: no dia em que o relatório trouxer "ÁREA" ou "Índice", o mesmo programa levanta UnicodeDecodeError: 'charmap' codec can't decode byte 0x81, e a investigação começa pelo lugar errado, porque "sempre funcionou". O caminho inverso é mais honesto: um arquivo gravado em cp1252 e lido como UTF-8 levanta UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf3, porque o ó de um byte só não forma sequência UTF-8 válida. A correção pontual é open("relatorio.txt", encoding="utf-8"). Para não depender de lembrar em cada chamada, vale rodar a suíte de testes com python -X warn_default_encoding -W error::EncodingWarning, que transforma cada open() sem encoding em erro apontando a linha. O modo -X utf8, ou a variável PYTHONUTF8=1, também faz o UTF-8 valer como padrão, mas corrige apenas o ambiente onde foi configurado; o encoding explícito viaja com o código.
Exercício 3
Um programa guarda preferências do usuário em config.json. Ao salvar, faz with open("config.json", "w", encoding="utf-8") as f: json.dump(config, f). Numa versão nova, alguém guardou um set dentro de config. O salvamento levanta TypeError: Object of type set is not JSON serializable, o erro é capturado e registrado em log — e na próxima execução o programa não abre mais, porque não consegue ler a configuração. O que havia no arquivo, e como salvar de forma que uma falha nunca destrua a versão anterior?
Ver resposta
✓ Resposta: O modo "w" trunca o arquivo no momento em que ele é aberto, antes de qualquer escrita. A configuração antiga deixou de existir na linha do open. Depois, o json.dump escreve à medida que percorre o objeto, e só descobre o set quando chega nele. Se o config era {"a": {1, 2}}, o arquivo fica com {"a": — um JSON cortado ao meio, que o json.load da execução seguinte recusa. O with fechou o arquivo direitinho, como prometido, e fechar direitinho um arquivo pela metade não ajuda. A técnica segura é gravar num arquivo temporário na mesma pasta e só então trocá-lo pelo definitivo:
import json, os
from pathlib import Path
def salvar_json(caminho: Path, dados) -> None:
tmp = caminho.with_suffix(caminho.suffix + ".tmp")
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(dados, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
except BaseException:
tmp.unlink(missing_ok=True)
raise
os.replace(tmp, caminho)
Se a serialização falhar, a exceção sai antes do os.replace e o config.json original fica intacto; medido, ele continua com o conteúdo anterior. O os.replace substitui o destino numa operação só, e o fsync garante que os bytes chegaram ao disco antes da troca, o que protege também contra queda de energia no meio. O temporário precisa estar na mesma pasta (no mesmo sistema de arquivos), senão a troca deixa de ser atômica. Por fim, vale notar que o except que registrou o erro e seguiu em frente escondeu o problema até a execução seguinte: falha ao salvar configuração é o tipo de erro que merece chegar ao usuário.
Exercício 4
Uma API em Python guarda um cache de cotações em JSON: {1: 5.12, 2: 5.40}, onde a chave é o id da moeda. Depois de recarregar o cache do disco, cache[1] passa a levantar KeyError, embora o arquivo tenha as duas cotações. Na mesma revisão, alguém nota que as coordenadas salvas como tupla (lat, lon) voltam de outro jeito, e que um valor float("nan") vindo de um cálculo produziu um arquivo que o frontend em JavaScript se recusa a ler. Explique os três casos.
Ver resposta
✓ Resposta: Os três casos têm a mesma raiz: JSON tem menos tipos que Python, e a conversão não faz ida e volta perfeita. No primeiro, objeto JSON só aceita chave de texto. O json.dumps converte a chave inteira em string sem avisar, e json.loads(json.dumps({1: "a"})) devolve {'1': 'a'}. O cache recarregado tem as chaves '1' e '2', e cache[1] procura o inteiro, que não está lá. Tupla como chave, por sua vez, nem chega a ser convertida: levanta TypeError: keys must be str, int, float, bool or None, not tuple. No segundo, JSON não tem tupla, só array, então (1, 2) volta como a lista [1, 2]. Na maior parte do código isso passa despercebido, até a coordenada ser usada como chave de dicionário ou comparada com == a uma tupla, e [1, 2] == (1, 2) é False. No terceiro, o Python escreve NaN sem aspas por padrão, mas NaN não é JSON válido, e o JSON.parse do navegador rejeita o arquivo inteiro. A solução comum aos três é tratar o JSON como formato de fronteira, com conversão explícita nos dois sentidos: converter as chaves com {int(k): v for k, v in dados.items()} ao carregar, reconstruir a tupla onde o tipo importa e serializar com json.dumps(dados, allow_nan=False). Esse último levanta ValueError: Out of range float values are not JSON compliant no Python, onde o problema nasce, em vez de deixá-lo estourar no cliente. Datas seguem a mesma lógica: datetime nem serializa, e o costume é gravar isoformat() e reconverter ao ler.
Exercício 5
O Logger do exemplo deste artigo está em produção e o arquivo .jsonl do dia chega a 20 MB. Numa revisão de código, alguém propõe simplificar ler_logs assim: linhas = self._arquivo_json.read_text(encoding="utf-8").splitlines(), seguido do mesmo filtro. Avalie a proposta e aponte outros dois pontos do Logger que merecem atenção antes de ele crescer mais.
Ver resposta
✓ Resposta: A proposta é mais curta e troca memória constante por memória proporcional ao arquivo. Medido com tracemalloc num .jsonl de 200 mil linhas e 21 MB, a leitura com read_text().splitlines() atinge um pico de 49 MiB, porque a string inteira e a lista de linhas coexistem, enquanto a iteração com for linha in f fica em 146 KiB. Log é justamente o arquivo que só cresce, e a versão linha a linha é também a que permite parar cedo — buscar os dez últimos erros, por exemplo — sem ler o resto. A proposta deve ser recusada. O primeiro ponto de atenção é o próprio ler_logs: ele acumula as entradas em lista, e com filtro amplo o problema de memória volta pela porta dos fundos. Transformá-lo em gerador, com yield entrada no lugar do append, deixa o chamador decidir quanto consumir. Vale também tolerar uma linha corrompida (um processo morto no meio da escrita deixa a última linha pela metade) com try/except json.JSONDecodeError por linha, em vez de perder o arquivo inteiro na primeira falha. O segundo ponto é a escrita: cada registro abre e fecha dois arquivos, o que custa caro em volume alto, e a data do nome do arquivo é calculada só no __init__, de modo que um processo que atravessa a meia-noite continua gravando no arquivo do dia anterior. Para uso real, o módulo logging da biblioteca padrão já resolve rotação por data (TimedRotatingFileHandler) e concorrência entre threads, e um Formatter próprio produz o JSON Lines. O exemplo do artigo é bom para entender o formato; em produção, o logging é o ponto de partida.