Tu scraper recorre diez páginas e imprime cien registros limpios. A la mañana siguiente lo vuelves a ejecutar y el CSV tiene doscientas filas, la mitad duplicadas. Un compañero abre el archivo en Excel y cada comilla tipográfica aparece como “. Una semana después el proceso se cae a mitad de escritura y deja un archivo JSON que ningún parser puede abrir. Nada de esto es un problema de scraping: lo deciden las pocas líneas de almacenamiento al final del script.
Esta guía cubre los tres formatos en los que escriben la mayoría de los scrapers en Python: CSV con el módulo csv y pandas, JSON y JSON Lines, y SQLite con un upsert que elimina los duplicados entre ejecuciones. Verás cómo añadir datos sin riesgo, qué codificación espera Excel, cuánto ocupa cada formato y cuándo pasar de SQLite a PostgreSQL. Cada ejemplo se ejecutó el 28 de septiembre de 2026 contra quotes.toscrape.com, un sitio de pruebas creado para practicar scraping, con Python 3.13, Requests 2.34, beautifulsoup4 4.15, pandas 3.0 y SQLite 3.50. La parte del parsing está en el tutorial de BeautifulSoup; aquí empezamos cuando las filas ya existen.
¿Qué opciones hay para guardar datos de scraping?
Hay dos familias. Los archivos planos (CSV, JSON, JSON Lines, XLSX) son un único archivo que se escribe de principio a fin. No necesitan servidor y se abren con herramientas conocidas, pero no tienen ni idea de qué es un duplicado. Las bases de datos (SQLite, PostgreSQL, MySQL) guardan filas en tablas con claves y restricciones: fusionan los duplicados por ti, responden preguntas con SQL y sobreviven a un fallo a mitad de escritura.
SQLite está entre las dos. Es una base de datos, pero toda la base de datos es un archivo normal en disco, y Python incluye el módulo sqlite3 en su biblioteca estándar. Para un scraper que corre en una sola máquina, esa combinación es difícil de superar.
¿Cómo guarda sus filas un scraper?
Sea cual sea el formato, un scraper bien hecho sigue los mismos cinco pasos:
- Normaliza cada registro en un dict con las mismas claves siempre:
quote_id,text,author,tagsy una marca de tiempo. - Da a cada registro una clave estable. Usa el ID del propio sitio si lo tiene (un SKU de producto, el ID de un artículo en la URL). quotes.toscrape.com no tiene ninguno, así que calculamos un hash del autor y del texto de la cita y obtenemos una clave de 16 caracteres.
- Elige el modo de escritura. Sobrescribir el archivo (una instantánea nueva), añadir al final (un registro que crece) o hacer upsert en una tabla (una fila por clave, actualizada en su sitio).
- Escribe en un solo paso. O llega el lote entero o no llega nada: una transacción de base de datos, o un archivo temporal que sustituye al antiguo cuando está completo.
- Vuelve a leerlo. Abre el archivo con el lector que usará quien lo reciba (Excel, pandas,
json.loads) antes de fiarte de él.
El paso 2 es el que más scripts se saltan, y es la razón de que aparezcan duplicados.
Comparativa de CSV, JSON Lines y SQLite
| CSV | JSON (un array) | JSON Lines | SQLite | |
|---|---|---|---|---|
| Campos anidados (listas de etiquetas) | Hay que aplanarlos, por ejemplo love|life | Nativo | Nativo | Texto JSON en una columna, consultado con json_each |
| Añadir datos en cada ejecución | Sí, con la cabecera una sola vez | No, hay que reescribir el ] final | Sí, una línea por registro | Sí, con upsert |
| Elimina duplicados | No | No | No | Sí, por clave primaria |
| Se abre en Excel | Sí, con un BOM UTF-8 | No | No | No (hay que exportar antes) |
| Sobrevive a un fallo a mitad de escritura | La última línea puede quedar cortada | El archivo puede quedar ilegible | Solo se pierde la última línea | Sí, las transacciones se revierten |
| Consultar sin cargarlo todo | No | No | Línea a línea | Sí, con SQL e índices |
| Tamaño para nuestras 100 citas | 25,9 KB | 42,4 KB (con sangría) | 35,8 KB | 49,2 KB (con un índice) |
Los tamaños salen de nuestra propia prueba. SQLite es el más grande aquí porque guarda los datos en páginas fijas de 4 KB (12 páginas para esta tabla y su índice); esa sobrecarga se reduce a medida que crece la tabla. Como comparación, los mismos datos en XLSX ocuparon 17,0 KB, ya que XLSX es un formato comprimido.
¿Cómo se guardan datos de scraping en CSV con Python?
El módulo csv de la biblioteca estándar se encarga de las comillas, las comas dentro de los campos y los saltos de línea dentro de valores entre comillas. DictWriter convierte cada dict en una fila según el nombre de columna:
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"])})Tres detalles de esa función evitan las quejas habituales con los CSV:
newline="". La documentación de csv de Python lo pide tanto para lectores como para escritores. Sin él, los saltos de línea dentro de campos entre comillas se leen mal y, en Windows, cada fila termina con un\rde más, que muchos lectores convierten en una fila vacía después de cada registro.utf-8-sigsolo en la primera escritura. Este códec escribe una marca de orden de bytes o BOM (EF BB BF) al principio del archivo; la documentación de codecs la describe como la variante de UTF-8 que usa Microsoft. Excel lee esa marca y decodifica el archivo como UTF-8, así que\u201csigue siendo\u201c. Cuando el script añade datos más tarde, cambia autf-8sin más; si no, caería una segunda marca en mitad del archivo. Nuestro archivo de prueba tenía exactamente una después de dos ejecuciones.- La cabecera solo cuando el archivo es nuevo. Si se añade la cabecera en cada ejecución, quedan filas sueltas
quote_id,text,...mezcladas con los datos.
Las etiquetas se unen con |, un separador que nunca aparece en los valores.
Cuando leas un archivo con BOM, vuelve a usar encoding="utf-8-sig". Con utf-8 a secas, el nombre de la primera columna llega como '\ufeffquote_id' y row["quote_id"] lanza KeyError. Nos pasó en las pruebas; tienes más trampas de codificación en errores de codificación Unicode en Python.
Con pandas, el mismo archivo es una sola línea, y drop_duplicates arregla un CSV o un JSON Lines que ya contiene repeticiones:
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") # requiere openpyxlUna hoja de Excel admite como máximo 1.048.576 filas, según las especificaciones y límites de Microsoft. Un CSV no tiene ese límite, pero Excel solo mostrará esa cantidad de filas.
¿Cómo se guardan datos de scraping en JSON o JSON Lines?
Un único array JSON sirve para una instantánea que otro programa carga de una vez. Escríbelo con ensure_ascii=False para que el texto con acentos siga siendo legible en lugar de convertirse en escapes como \u201c, y escríbelo de forma atómica: vuelca el contenido en un archivo temporal de la misma carpeta y luego sustitúyelo con os.replace. Si el proceso muere a la mitad, el archivo antiguo sigue 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)El punto débil de un array JSON es añadir datos. El archivo termina en ], así que agregar un registro obliga a leer y reescribir el archivo entero. JSON Lines lo evita: cada línea es un valor JSON completo, el archivo está en UTF-8 y las líneas terminan en \n. Añadir es una escritura normal, y un fallo solo puede dañar la última línea:
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")pandas lo lee con pd.read_json(path, lines=True), y chunksize= permite procesar un archivo grande por partes. Una sorpresa de nuestra prueba: la documentación de read_json dice que las columnas cuyo nombre termina en _at o _time se interpretan como fechas por defecto. Nuestra columna scraped_at se convirtió en un datetime con zona horaria, y to_excel() se detuvo con Excel does not support datetimes with timezones (Excel no admite fechas con zona horaria). Pasa convert_dates=False si quieres conservar el texto tal cual.
Si un archivo no carga y lanza JSONDecodeError, las causas habituales (una escritura truncada, dos arrays en un archivo, un JSON Lines leído como un solo documento) están en JSONDecodeError: Expecting value.
¿Cómo se guardan datos de scraping en SQLite sin duplicados?
Declara la clave estable como clave primaria y deja que la base de datos decida entre insertar y actualizar. SQLite lo admite desde la versión 3.24.0 con la cláusula upsert (una inserción que, si la clave ya existe, se convierte en actualización); en la parte DO UPDATE, el prefijo especial excluded. se refiere a los valores que la inserción rechazada intentó escribir (documentación de UPSERT de 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 solo se escribe cuando la fila es nueva; last_seen avanza en cada ejecución. Ese par te da un historial incremental gratis: una fila cuyo last_seen es anterior a la última ejecución ha desaparecido del sitio, y una fila cuyo first_seen coincide con la última ejecución es nueva. Los rastreadores de precios y los monitores de cambios se construyen exactamente sobre este patrón.
Dos ejecuciones del script completo de más abajo imprimieron:
scraped 100 rows: 100 new, 0 already in the database
scraped 100 rows: 0 new, 100 already in the databaseLos archivos CSV y JSON Lines de esas mismas dos ejecuciones tenían 200 registros cada uno. La base de datos tenía 100.
Con los datos en SQLite, las preguntas se convierten en consultas. Estas se ejecutaron contra nuestra base de datos de prueba:
import sqlite3
con = sqlite3.connect("data/quotes.db")
# Autores con más citas
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)
# Citas con la etiqueta "love" (las etiquetas se guardan como array JSON)
print(con.execute("""
SELECT COUNT(*) FROM quotes, json_each(quotes.tags)
WHERE json_each.value = 'love'""").fetchone()[0])
# Filas que no estaban en el sitio durante la última ejecución
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 el análisis, pd.read_sql_query(sql, con) devuelve el resultado como un DataFrame. Los pasos de limpieza que suelen venir después están en cómo limpiar datos de scraping con pandas.
Script completo: scraping y escritura en CSV, JSON Lines y SQLite
El script sigue el enlace "Next" del sitio hasta que desaparece, reintenta ante respuestas 429 y 5xx con espera exponencial (el Retry de urllib3 también respeta la cabecera Retry-After), espera un segundo entre páginas y escribe una sola marca de tiempo por ejecución para que las comparaciones de last_seen funcionen. quotes.toscrape.com tiene 10 páginas y 100 citas.
pip install requests beautifulsoup4 lxml pandas openpyxl"""Hace scraping de quotes.toscrape.com y guarda las filas en CSV, JSON Lines y 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:
# El sitio no tiene IDs de cita, así que derivamos una clave estable del contenido.
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") # una marca de tiempo por ejecución
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: # una transacción: commit si todo va bien, rollback si hay error
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: envuelve el upsert en una sola transacción: si falla cualquier fila, no se escribe ninguna. No cierra la conexión, así que de eso se encarga el bloque finally. Los marcadores con nombre (:quote_id) permiten que executemany reciba los mismos dicts que usa el escritor CSV y evitan que los apóstrofos rompan el SQL.
Para un sitio real, mantén la misma estructura y cambia los selectores, la clave y la pausa. Revisa antes los términos del sitio y su robots.txt (robots.txt explicado), y usa una API oficial cuando exista. La misma pausa de un segundo y los mismos ajustes de reintento son lo que mantiene un proceso lejos de los errores HTTP 429. Cuando un scraper se convierte en un crawler programado que reparte sus peticiones entre varias IP de salida, Proxies rotativos y Proxies residenciales se conectan a la misma requests.Session mediante su ajuste proxies; el código de almacenamiento no cambia.
¿Cuándo pasar de SQLite a PostgreSQL?
La guía del propio proyecto SQLite es directa sobre sus límites: admite un solo escritor a la vez por archivo de base de datos, y no es la opción adecuada cuando los datos y la aplicación están en máquinas distintas. En un proyecto de scraping, eso se traduce en tres señales:
- Varios workers escriben a la vez. Procesos de crawler en tres servidores necesitan un servidor de base de datos; unos pocos procesos en una sola máquina pueden turnarse.
- La base de datos debe ser accesible por red. Un panel, una API y un scraper en hosts separados deberían compartir PostgreSQL, no un archivo
.dben una unidad de red. - Otras personas necesitan permisos. Usuarios, roles y acceso de solo lectura son funciones de un servidor.
El tamaño rara vez es el motivo: el máximo documentado ronda los 281 TB, muy por encima de lo que produce un solo scraper. La migración en sí es pequeña. PostgreSQL usa la misma sintaxis INSERT ... ON CONFLICT (key) DO UPDATE con EXCLUDED, así que el upsert anterior se traslada casi sin cambios. Los pipelines que crecen hasta tener varias etapas se tratan en ¿Qué es ETL?.
Casos de uso
- Seguimiento de precios: una fila por producto y día, con el SKU y la fecha como clave, alimenta el seguimiento de precios y el seguimiento de precios de la competencia.
- Detección de cambios:
first_seenylast_seenmuestran los elementos nuevos y los eliminados, que es la base del seguimiento de cambios en sitios web. - Conjuntos de datos para investigación: los archivos JSON Lines son fáciles de pasar a analistas y de cargar en notebooks para estudios de mercado.
- Rastreos grandes: un web crawler que visita miles de URL puede guardar su cola de pendientes y sus resultados en SQLite, como en cómo crear un web crawler en Python.
- Procesos de scraping recurrentes: ejecuciones programadas de extracción de datos que no deben duplicar las filas de ayer.
Errores comunes
- No tener una clave estable. Sin ella, cada ejecución vuelve a añadir el conjunto de datos completo. Usa el ID del sitio o calcula un hash de los campos que identifican un registro.
- Abrir archivos CSV sin
newline="". Caracteres\rde más en Windows y campos de varias líneas rotos. - Usar
utf-8a secas en un CSV pensado para Excel. Los caracteres acentuados y las comillas tipográficas se convierten en secuencias comoé. - Escribir el archivo de salida en su sitio. Un fallo deja medio archivo. Escribe en un archivo temporal y sustitúyelo con
os.replace. - Un commit por fila en SQLite. Cada commit sincroniza con el disco. Agrupa el lote en una sola transacción con
executemany. - Construir el SQL con f-strings. Una cita con un apóstrofo rompe la sentencia. Usa marcadores de posición.
- Mantenerlo todo en memoria en un rastreo grande. Escribe por página o por lote para que un fallo pierda una página, no la ejecución entera.
Guía de decisión
| Necesidad | Recomendación |
|---|---|
| Un archivo que un compañero abre en Excel | CSV con utf-8-sig, o XLSX mediante pandas |
| Registros anidados para otro programa | JSON (un array), escrito de forma atómica |
| Un registro de solo anexado con cada ejecución | JSON Lines |
| Ejecuciones repetidas sin duplicados | SQLite con clave primaria y upsert |
| Historial: cuándo se vio cada elemento por primera y última vez | SQLite con first_seen y last_seen |
| Varias máquinas escribiendo a la vez | PostgreSQL con el mismo upsert ON CONFLICT |
| Análisis puntual | Cualquiera de los anteriores, cargado en pandas |
Preguntas frecuentes
¿Cuál es el mejor formato para guardar datos de scraping?
Depende de quién los lea: CSV para hojas de cálculo, JSON Lines para registros anidados y registros de solo anexado, SQLite para scrapers programados que no deben duplicar filas. Muchos proyectos mantienen SQLite como fuente de verdad y exportan CSV para las personas.
¿Por qué mi CSV se ve con caracteres raros en Excel?
Cuando un CSV no tiene marca de orden de bytes (BOM), Excel en Windows suele decodificarlo con la página de códigos antigua del sistema en lugar de UTF-8, así que cada carácter de varios bytes se convierte en dos o tres caracteres erróneos. Escribe el archivo con encoding="utf-8-sig", o impórtalo desde el cuadro de importación de datos de Excel y elige UTF-8.
¿Cómo añado datos a un CSV existente sin repetir la cabecera?
Abre el archivo en modo "a" y escribe la cabecera solo cuando el archivo sea nuevo o esté vacío, como hace append_csv más arriba. Añadir no elimina duplicados; para eso, carga el archivo con pandas y llama a drop_duplicates, o guarda los datos en SQLite con una clave.
¿Qué diferencia hay entre JSON y JSON Lines?
Un archivo JSON contiene un solo valor, normalmente un array de registros, así que hay que leerlo y escribirlo entero. JSON Lines contiene un valor JSON por línea, así que puedes añadir un registro con una sola escritura y leer un archivo grande línea a línea.
¿Puede SQLite con un proyecto de scraping grande?
En una sola máquina, normalmente sí; el límite de tamaño está muy por encima de lo que produce un scraper. El límite que importa es la concurrencia: un solo escritor a la vez por archivo. Si varias máquinas escriben a la vez, necesitas PostgreSQL.
¿Cómo evito filas duplicadas al volver a ejecutar un scraper?
Da a cada registro una clave estable, conviértela en la clave primaria en SQLite e inserta con ON CONFLICT(key) DO UPDATE. En archivos planos, elimina los duplicados después de cargarlos con drop_duplicates(subset="key").
En resumen
El almacenamiento decide si un scraper sigue siendo útil después de su primera ejecución. CSV con utf-8-sig y newline="" es el formato de entrega para hojas de cálculo; JSON Lines, la forma segura más sencilla de añadir registros estructurados. SQLite con una clave estable y un upsert convierte las ejecuciones repetidas en una sola tabla consultable, con historial de primera y última aparición; PostgreSQL toma el relevo cuando varias máquinas escriben a la vez. Si tus ejecuciones crecen hasta el punto de que una sola dirección IP ya no basta para recopilar los datos con moderación, consulta los planes de proxy de Proxynet.




