Seu scraper percorre dez páginas e imprime cem registros limpos. Na manhã seguinte você roda de novo e o CSV tem duzentas linhas, metade delas duplicadas. Um colega abre o arquivo no Excel e cada aspa curva aparece como “. Uma semana depois, o processo cai no meio da gravação e deixa um arquivo JSON que nenhum parser consegue abrir. Nada disso é um problema de scraping: tudo é decidido pelas poucas linhas de armazenamento no fim do script.
Este guia cobre os três formatos em que a maioria dos scrapers em Python grava: CSV com o módulo csv e o pandas, JSON e JSON Lines, e SQLite com um upsert que elimina duplicatas entre execuções. Você vai ver como acrescentar dados com segurança, qual codificação o Excel espera, quanto cada formato ocupa e quando trocar o SQLite pelo PostgreSQL. Todos os exemplos rodaram em 28 de setembro de 2026 contra o quotes.toscrape.com, um sandbox criado para praticar scraping, com Python 3.13, Requests 2.34, beautifulsoup4 4.15, pandas 3.0 e SQLite 3.50. A parte de parsing está no tutorial de BeautifulSoup; aqui começamos quando as linhas já existem.
Quais são as opções para armazenar dados de scraping?
Existem duas famílias. Os arquivos planos (CSV, JSON, JSON Lines, XLSX) são um único arquivo gravado do início ao fim. Não precisam de servidor e abrem em ferramentas conhecidas, mas não fazem ideia do que é uma duplicata. Os bancos de dados (SQLite, PostgreSQL, MySQL) guardam linhas em tabelas com chaves e restrições: mesclam as duplicatas para você, respondem perguntas com SQL e sobrevivem a uma falha no meio da gravação.
O SQLite fica entre os dois. É um banco de dados, mas o banco inteiro é um arquivo comum no disco, e o Python traz o módulo sqlite3 na biblioteca padrão. Para um scraper que roda numa única máquina, essa combinação é difícil de superar.
Como um scraper salva suas linhas?
Seja qual for o formato, um scraper bem feito passa pelas mesmas cinco etapas:
- Normalize cada registro num dict com as mesmas chaves sempre:
quote_id,text,author,tagse um carimbo de data e hora. - Dê a cada registro uma chave estável. Use o ID do próprio site, se houver (um SKU de produto, o ID de um artigo na URL). O quotes.toscrape.com não tem nenhum, então calculamos um hash do autor e do texto da citação e obtemos uma chave de 16 caracteres.
- Escolha o modo de gravação. Sobrescrever o arquivo (um snapshot novo), acrescentar ao final (um log que cresce) ou fazer upsert numa tabela (uma linha por chave, atualizada no lugar).
- Grave em uma única etapa. Ou o lote inteiro chega, ou nada chega: uma transação de banco de dados, ou um arquivo temporário que substitui o antigo quando estiver completo.
- Leia de volta. Abra o arquivo com o leitor que o destinatário vai usar (Excel, pandas,
json.loads) antes de confiar nele.
A etapa 2 é a que a maioria dos scripts pula, e é por isso que as duplicatas aparecem.
Comparação entre CSV, JSON Lines e SQLite
| CSV | JSON (um array) | JSON Lines | SQLite | |
|---|---|---|---|---|
| Campos aninhados (listas de tags) | É preciso achatá-los, por exemplo love|life | Nativo | Nativo | Texto JSON numa coluna, consultado com json_each |
| Acrescentar a cada execução | Sim, com o cabeçalho uma única vez | Não, o ] final precisa ser reescrito | Sim, uma linha por registro | Sim, com upsert |
| Remove duplicatas | Não | Não | Não | Sim, pela chave primária |
| Abre no Excel | Sim, com um BOM UTF-8 | Não | Não | Não (exporte antes) |
| Sobrevive a uma falha no meio da gravação | A última linha pode ficar cortada | O arquivo pode ficar ilegível | Só a última linha se perde | Sim, as transações são revertidas |
| Consultar sem carregar tudo | Não | Não | Linha por linha | Sim, com SQL e índices |
| Tamanho para as nossas 100 citações | 25,9 KB | 42,4 KB (indentado) | 35,8 KB | 49,2 KB (com um índice) |
Os tamanhos vêm do nosso próprio teste. O SQLite é o maior aqui porque guarda os dados em páginas fixas de 4 KB (12 páginas para esta tabela e seu índice); essa sobrecarga diminui à medida que a tabela cresce. Para comparação, os mesmos dados em XLSX ocuparam 17,0 KB, já que o XLSX é um formato compactado.
Como salvar dados de scraping em CSV com Python?
O módulo csv da biblioteca padrão cuida das aspas, das vírgulas dentro dos campos e das quebras de linha dentro de valores entre aspas. O DictWriter transforma cada dict numa linha pelo nome da coluna:
import csv
from pathlib import Path
FIELDS = ["quote_id", "text", "author", "author_url", "tags", "page", "scraped_at"]
def append_csv(rows, path: Path) -> None:
new_file = not path.exists() or path.stat().st_size == 0
with path.open("a", newline="", encoding="utf-8-sig" if new_file else "utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
if new_file:
writer.writeheader()
for row in rows:
writer.writerow({**row, "tags": "|".join(row["tags"])})Três detalhes dessa função evitam as reclamações de sempre com CSV:
newline="". A documentação do csv do Python pede isso tanto para leitores quanto para gravadores. Sem ele, as quebras de linha dentro de campos entre aspas são lidas errado e, no Windows, cada linha termina com um\rextra, que muitos leitores transformam numa linha em branco depois de cada registro.utf-8-sigsó na primeira gravação. Esse codec grava uma marca de ordem de bytes, o BOM (EF BB BF), no início do arquivo; a documentação de codecs a descreve como a variante de UTF-8 usada pela Microsoft. O Excel lê essa marca e decodifica o arquivo como UTF-8, então\u201ccontinua\u201c. Quando o script acrescenta dados depois, ele muda parautf-8simples; caso contrário, uma segunda marca cairia no meio do arquivo. Nosso arquivo de teste tinha exatamente uma depois de duas execuções.- O cabeçalho só quando o arquivo é novo. Acrescentar o cabeçalho a cada execução deixa linhas soltas
quote_id,text,...no meio dos dados.
As tags são unidas com |, um separador que nunca aparece nos valores.
Ao ler um arquivo com BOM, use encoding="utf-8-sig" de novo. Com utf-8 simples, o nome da primeira coluna volta como '\ufeffquote_id' e row["quote_id"] gera KeyError. Isso aconteceu nos nossos testes; mais armadilhas de codificação estão em erros de codificação Unicode no Python.
Com o pandas, o mesmo arquivo sai em uma linha, e drop_duplicates corrige um CSV ou JSON Lines que já tem repetições:
import pandas as pd
df = pd.read_json("data/quotes.jsonl", lines=True, convert_dates=False)
df = df.drop_duplicates(subset="quote_id", keep="last")
df.assign(tags=df["tags"].str.join("|")).to_csv(
"data/quotes_clean.csv", index=False, encoding="utf-8-sig")
df.assign(tags=df["tags"].str.join(", ")).to_excel(
"data/quotes.xlsx", index=False, sheet_name="quotes") # requer openpyxlUma planilha do Excel comporta no máximo 1.048.576 linhas, segundo as especificações e limites da Microsoft. Um CSV não tem esse limite, mas o Excel só vai mostrar essa quantidade de linhas.
Como salvar dados de scraping em JSON ou JSON Lines?
Um único array JSON serve para um snapshot que outro programa carrega de uma vez. Grave-o com ensure_ascii=False para que o texto com acentos continue legível em vez de virar escapes como \u201c, e grave de forma atômica: despeje o conteúdo num arquivo temporário na mesma pasta e depois troque-o com os.replace. Se o processo morrer no meio, o arquivo antigo continua intacto.
import json, os, tempfile
from pathlib import Path
def write_json_atomic(data, path: Path) -> None:
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)O ponto fraco de um array JSON é acrescentar dados. O arquivo termina em ], então adicionar um registro exige ler e reescrever o arquivo inteiro. O JSON Lines evita isso: cada linha é um valor JSON completo, o arquivo está em UTF-8 e as linhas terminam em \n. Acrescentar é uma gravação simples, e uma falha só pode danificar a última linha:
def append_jsonl(rows, path: Path) -> None:
with path.open("a", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")O pandas lê esse formato com pd.read_json(path, lines=True), e chunksize= permite processar um arquivo grande em partes. Uma surpresa do nosso teste: a documentação do read_json diz que colunas cujo nome termina em _at ou _time são interpretadas como datas por padrão. Nossa coluna scraped_at virou um datetime com fuso horário, e o to_excel() parou com Excel does not support datetimes with timezones (o Excel não aceita datas com fuso horário). Passe convert_dates=False se quiser manter o texto como está.
Se um arquivo não carrega e gera JSONDecodeError, as causas comuns (uma gravação truncada, dois arrays num arquivo, um JSON Lines lido como um único documento) estão em JSONDecodeError: Expecting value.
Como armazenar dados de scraping no SQLite sem duplicatas?
Declare a chave estável como chave primária e deixe o banco de dados decidir entre inserir e atualizar. O SQLite suporta isso desde a versão 3.24.0 com a cláusula upsert (uma inserção que vira atualização quando a chave já existe); na parte DO UPDATE, o prefixo especial excluded. se refere aos valores que a inserção rejeitada tentou gravar (documentação de UPSERT do SQLite).
CREATE TABLE IF NOT EXISTS quotes (
quote_id TEXT PRIMARY KEY,
text TEXT NOT NULL,
author TEXT NOT NULL,
author_url TEXT,
tags TEXT, -- array JSON
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL
);
INSERT INTO quotes (quote_id, text, author, author_url, tags, first_seen, last_seen)
VALUES (:quote_id, :text, :author, :author_url, :tags, :scraped_at, :scraped_at)
ON CONFLICT(quote_id) DO UPDATE SET
tags = excluded.tags,
author_url = excluded.author_url,
last_seen = excluded.last_seen;first_seen só é gravado quando a linha é nova; last_seen avança a cada execução. Esse par dá um histórico incremental de graça: uma linha cujo last_seen é anterior à última execução sumiu do site, e uma linha cujo first_seen é igual à última execução é nova. Rastreadores de preço e monitores de mudanças são construídos exatamente sobre esse padrão.
Duas execuções do script completo mais abaixo imprimiram:
scraped 100 rows: 100 new, 0 already in the database
scraped 100 rows: 0 new, 100 already in the databaseOs arquivos CSV e JSON Lines dessas mesmas duas execuções tinham 200 registros cada. O banco de dados tinha 100.
Com os dados no SQLite, perguntas viram consultas. Estas rodaram contra o nosso banco de teste:
import sqlite3
con = sqlite3.connect("data/quotes.db")
# Autores com mais citações
for author, n in con.execute(
"SELECT author, COUNT(*) AS n FROM quotes GROUP BY author ORDER BY n DESC LIMIT 3"):
print(author, n)
# Citações com a tag "love" (as tags são guardadas como array JSON)
print(con.execute("""
SELECT COUNT(*) FROM quotes, json_each(quotes.tags)
WHERE json_each.value = 'love'""").fetchone()[0])
# Linhas que não estavam no site durante a última execução
print(con.execute("""
SELECT COUNT(*) FROM quotes
WHERE last_seen < (SELECT MAX(last_seen) FROM quotes)""").fetchone()[0])
con.close()Albert Einstein 10
J.K. Rowling 9
Marilyn Monroe 7
14
0Para análise, pd.read_sql_query(sql, con) devolve o resultado como um DataFrame. As etapas de limpeza que costumam vir em seguida estão em como limpar dados de scraping com pandas.
Script completo: scraping e gravação em CSV, JSON Lines e SQLite
O script segue o link "Next" do site até ele desaparecer, tenta de novo em respostas 429 e 5xx com espera exponencial (o Retry do urllib3 também respeita o cabeçalho Retry-After), espera um segundo entre as páginas e grava um único carimbo de data e hora por execução para que as comparações de last_seen funcionem. O quotes.toscrape.com tem 10 páginas e 100 citações.
pip install requests beautifulsoup4 lxml pandas openpyxl"""Faz scraping do quotes.toscrape.com e guarda as linhas em CSV, JSON Lines e SQLite."""
import csv
import hashlib
import json
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
BASE = "https://quotes.toscrape.com/"
FIELDS = ["quote_id", "text", "author", "author_url", "tags", "page", "scraped_at"]
def make_session() -> requests.Session:
retry = Retry(total=4, backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"])
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
session.headers["User-Agent"] = "quotes-storage-demo/1.0 (contact: you@example.com)"
return session
def quote_key(text: str, author: str) -> str:
# O site não tem IDs de citação, então derivamos uma chave estável do conteúdo.
return hashlib.sha1(f"{author}\n{text}".encode("utf-8")).hexdigest()[:16]
def scrape(session: requests.Session, delay: float = 1.0):
url, page = BASE, 1
run_at = datetime.now(timezone.utc).isoformat(timespec="seconds") # um carimbo de tempo por execução
while url:
resp = session.get(url, timeout=20)
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "lxml")
for q in soup.select("div.quote"):
text = q.select_one("span.text").get_text(strip=True)
author = q.select_one("small.author").get_text(strip=True)
yield {
"quote_id": quote_key(text, author),
"text": text,
"author": author,
"author_url": urljoin(BASE, q.select_one("span a")["href"]),
"tags": [t.get_text(strip=True) for t in q.select("a.tag")],
"page": page,
"scraped_at": run_at,
}
nxt = soup.select_one("li.next a")
url = urljoin(url, nxt["href"]) if nxt else None
page += 1
time.sleep(delay)
def append_csv(rows, path: Path) -> None:
new_file = not path.exists() or path.stat().st_size == 0
with path.open("a", newline="", encoding="utf-8-sig" if new_file else "utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
if new_file:
writer.writeheader()
for row in rows:
writer.writerow({**row, "tags": "|".join(row["tags"])})
def append_jsonl(rows, path: Path) -> None:
with path.open("a", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
SCHEMA = """
CREATE TABLE IF NOT EXISTS quotes (
quote_id TEXT PRIMARY KEY,
text TEXT NOT NULL,
author TEXT NOT NULL,
author_url TEXT,
tags TEXT, -- array JSON
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author);
"""
UPSERT = """
INSERT INTO quotes (quote_id, text, author, author_url, tags, first_seen, last_seen)
VALUES (:quote_id, :text, :author, :author_url, :tags, :scraped_at, :scraped_at)
ON CONFLICT(quote_id) DO UPDATE SET
tags = excluded.tags,
author_url = excluded.author_url,
last_seen = excluded.last_seen
"""
def save_sqlite(rows, path: Path) -> tuple[int, int]:
con = sqlite3.connect(path)
try:
con.executescript(SCHEMA)
before = con.execute("SELECT COUNT(*) FROM quotes").fetchone()[0]
with con: # uma transação: commit se der certo, rollback se der erro
con.executemany(UPSERT, [{**r, "tags": json.dumps(r["tags"])} for r in rows])
after = con.execute("SELECT COUNT(*) FROM quotes").fetchone()[0]
return after - before, len(rows) - (after - before)
finally:
con.close()
if __name__ == "__main__":
out = Path("data")
out.mkdir(exist_ok=True)
rows = list(scrape(make_session()))
append_csv(rows, out / "quotes.csv")
append_jsonl(rows, out / "quotes.jsonl")
new, seen = save_sqlite(rows, out / "quotes.db")
print(f"scraped {len(rows)} rows: {new} new, {seen} already in the database")with con: envolve o upsert numa única transação: se qualquer linha falhar, nenhuma é gravada. Ele não fecha a conexão, por isso o bloco finally faz isso. Os marcadores nomeados (:quote_id) permitem que o executemany receba os mesmos dicts que o gravador de CSV usa e impedem que apóstrofos quebrem o SQL.
Para um site real, mantenha a mesma estrutura e mude os seletores, a chave e o intervalo. Verifique antes os termos do site e o robots.txt (robots.txt explicado) e use uma API oficial quando existir. O mesmo intervalo de um segundo e as mesmas configurações de nova tentativa são o que mantém um processo longe dos erros HTTP 429. Quando um scraper vira um crawler agendado que distribui as requisições entre vários IPs de saída, Proxies rotativos e Proxies residenciais se conectam à mesma requests.Session pela configuração proxies; o código de armazenamento não muda.
Quando trocar o SQLite pelo PostgreSQL?
A orientação do próprio projeto SQLite é direta sobre os limites: ele aceita um gravador por vez em cada arquivo de banco de dados e não é a escolha certa quando os dados e a aplicação ficam em máquinas diferentes. Num projeto de scraping, isso se traduz em três sinais:
- Vários workers gravam ao mesmo tempo. Processos de crawler em três servidores precisam de um servidor de banco de dados; alguns processos numa única máquina podem se revezar.
- O banco de dados precisa ser acessível pela rede. Um painel, uma API e um scraper em hosts separados devem compartilhar um PostgreSQL, e não um arquivo
.dbnuma pasta de rede. - Outras pessoas precisam de permissões. Usuários, papéis e acesso somente leitura são recursos de servidor.
O tamanho raramente é o motivo: o máximo documentado fica em torno de 281 TB, muito além do que um único scraper produz. A migração em si é pequena. O PostgreSQL usa a mesma sintaxe INSERT ... ON CONFLICT (key) DO UPDATE com EXCLUDED, então o upsert acima passa quase sem mudanças. Pipelines que crescem para várias etapas são tratados em O que é ETL?.
Casos de uso
- Monitoramento de preços: uma linha por produto por dia, com SKU e data como chave, alimenta o monitoramento de preços e o monitoramento de preços da concorrência.
- Detecção de mudanças:
first_seenelast_seenmostram os itens novos e os removidos, que é o núcleo do monitoramento de mudanças em sites. - Conjuntos de dados para pesquisa: arquivos JSON Lines são fáceis de entregar a analistas e de carregar em notebooks para pesquisa de mercado.
- Rastreamentos grandes: um web crawler que visita milhares de URLs pode guardar sua fila de pendências e seus resultados no SQLite, como em como criar um web crawler em Python.
- Jobs de scraping recorrentes: execuções agendadas de extração de dados que não podem duplicar as linhas de ontem.
Erros comuns
- Nenhuma chave estável. Sem ela, cada execução acrescenta de novo o conjunto de dados inteiro. Use o ID do site ou calcule um hash dos campos que identificam um registro.
- Abrir arquivos CSV sem
newline="". Caracteres\rextras no Windows e campos de várias linhas quebrados. - Usar
utf-8simples num CSV feito para o Excel. Caracteres acentuados e aspas curvas viram sequências comoé. - Gravar o arquivo de saída no lugar. Uma falha deixa meio arquivo. Grave num arquivo temporário e troque-o com
os.replace. - Um commit por linha no SQLite. Cada commit sincroniza com o disco. Agrupe o lote numa única transação com
executemany. - Montar o SQL com f-strings. Uma citação com apóstrofo quebra a instrução. Use marcadores de posição.
- Manter tudo na memória num rastreamento grande. Grave por página ou por lote para que uma falha perca uma página, e não a execução inteira.
Guia de decisão
| Necessidade | Recomendação |
|---|---|
| Um arquivo que um colega abre no Excel | CSV com utf-8-sig, ou XLSX via pandas |
| Registros aninhados para outro programa | JSON (um array), gravado de forma atômica |
| Um log que só cresce, com cada execução | JSON Lines |
| Execuções repetidas sem duplicatas | SQLite com chave primária e upsert |
| Histórico: quando cada item foi visto pela primeira e pela última vez | SQLite com first_seen e last_seen |
| Várias máquinas gravando ao mesmo tempo | PostgreSQL com o mesmo upsert ON CONFLICT |
| Análise pontual | Qualquer um dos anteriores, carregado no pandas |
Perguntas frequentes
Qual é o melhor formato para salvar dados de scraping?
Depende de quem vai ler: CSV para planilhas, JSON Lines para registros aninhados e logs que só crescem, SQLite para scrapers agendados que não podem duplicar linhas. Muitos projetos mantêm o SQLite como fonte da verdade e exportam CSV para as pessoas.
Por que meu CSV aparece com caracteres estranhos no Excel?
Quando um CSV não tem marca de ordem de bytes (BOM), o Excel no Windows costuma decodificá-lo com a página de código antiga do sistema em vez de UTF-8, e cada caractere de vários bytes vira dois ou três caracteres errados. Grave o arquivo com encoding="utf-8-sig", ou importe-o pela caixa de diálogo de importação de dados do Excel e escolha UTF-8.
Como acrescentar dados a um CSV existente sem repetir o cabeçalho?
Abra o arquivo no modo "a" e grave o cabeçalho só quando o arquivo for novo ou estiver vazio, como faz o append_csv acima. Acrescentar não remove duplicatas; para isso, carregue o arquivo com o pandas e chame drop_duplicates, ou guarde os dados no SQLite com uma chave.
Qual é a diferença entre JSON e JSON Lines?
Um arquivo JSON contém um único valor, geralmente um array de registros, então precisa ser lido e gravado por inteiro. O JSON Lines contém um valor JSON por linha, então você acrescenta um registro com uma única gravação e lê um arquivo grande linha por linha.
O SQLite dá conta de um projeto de scraping grande?
Numa única máquina, geralmente sim; o limite de tamanho fica muito acima do que um scraper produz. O limite que importa é a concorrência: um gravador por vez em cada arquivo. Várias máquinas gravando ao mesmo tempo pedem PostgreSQL.
Como evitar linhas duplicadas ao rodar o scraper de novo?
Dê a cada registro uma chave estável, transforme-a na chave primária no SQLite e insira com ON CONFLICT(key) DO UPDATE. Em arquivos planos, remova as duplicatas depois de carregar com drop_duplicates(subset="key").
Em resumo
O armazenamento decide se um scraper continua útil depois da primeira execução. CSV com utf-8-sig e newline="" é o formato de entrega para planilhas; JSON Lines, a forma segura mais simples de acrescentar registros estruturados. SQLite com uma chave estável e um upsert transforma execuções repetidas numa única tabela consultável, com histórico de primeira e última aparição; o PostgreSQL assume quando várias máquinas gravam ao mesmo tempo. Se as suas execuções crescerem a ponto de um único endereço IP não bastar para coletar os dados com moderação, veja os planos de proxy da Proxynet.




