---
title: "¿Qué es ETL? Extracción, transformación y carga con Python"
description: "ETL es extraer datos de una fuente, limpiarlos en una forma fija y cargarlos en una base de datos. Cómo funciona, ETL vs. ELT y un job de Python probado."
url: https://proxynet.io/es/blog/what-is-etl
date: 2026-09-28
author: "Acar Diveroli"
category: "Web scraping, Proxy 101"
lang: es
---

# ¿Qué es ETL? Extracción, transformación y carga con Python

Un scraper que el mes pasado funcionaba bien ahora llena una hoja de cálculo con precios como `£51.77`, el mismo libro tres veces y una columna donde "In stock" aparece junto a "In stock (19 available)". Nadie puede hacer un gráfico con eso. El scraper hizo su trabajo; lo que falta es todo lo que viene después: convertir el texto en bruto de la página en filas tipadas y sin duplicados, y guardarlas en un lugar que sobreviva a la siguiente ejecución. Esa parte que falta tiene nombre, y es más antigua que el web scraping: ETL.

En este artículo explicamos qué significa ETL, qué hace cada uno de sus tres pasos y cómo se ejecuta un pipeline ETL desde el disparador hasta la tabla terminada. Comparamos ETL con ELT, vemos las dos propiedades que separan un script de un pipeline (poder repetir la ejecución sin riesgo y la monitorización) y construimos un job completo y probado en Python que extrae datos de libros de un sandbox de scraping, los limpia y los carga en SQLite. Las últimas secciones tratan casos de uso, errores frecuentes y una breve guía de decisión.

> **Nota: Respuesta breve**
>
> ETL significa **extracción, transformación y carga** (extract, transform, load). La extracción obtiene los datos en bruto de una fuente, como un sitio web, una API o un archivo. La transformación les da una forma fija: tipos correctos, sin duplicados y con las filas que no pasan la validación apartadas. La carga escribe las filas limpias en un destino como SQLite, PostgreSQL o un data warehouse. En ELT cambia el orden: los datos en bruto se cargan primero y se transforman dentro del destino. Un buen job ETL puede ejecutarse dos veces sin crear duplicados y te avisa cuando falla.

## ¿Qué es ETL?

ETL es un proceso de integración de datos: los datos salen de una o varias fuentes, se remodelan según reglas que tú defines y llegan a un único almacén donde se pueden consultar. La guía de arquitectura de Microsoft lo describe como un proceso que [consolida datos de fuentes diversas en un almacén de datos unificado](https://learn.microsoft.com/es-es/azure/architecture/data-guide/relational-data/etl), con la transformación aplicada según reglas de negocio antes de la carga.

La idea viene del data warehousing, y encaja igual de bien con el scraping. Un sitio web es una fuente como cualquier otra, solo que más desordenada: sus datos están formateados para personas, repartidos en varias páginas y pueden cambiar sin aviso. Si alguna vez escribiste un scraper y luego un segundo script para arreglar su salida, ya construiste dos tercios de un pipeline ETL.

## ¿Qué hace cada paso?

**La extracción** recoge los datos en bruto y los modifica lo menos posible. Con datos web eso significa enviar peticiones, seguir la paginación y sacar del HTML los campos que necesitas. La salida sigue siendo texto: `"£51.77"`, `"Three"`, `"\n In stock\n"`. Mantener la extracción simple tiene una ventaja: cuando algo se rompe, puedes saber si cambió la fuente o cambiaron tus reglas de limpieza.

**La transformación** es donde viven las reglas. Las operaciones típicas son:

- convertir tipos (el texto del precio a número, la palabra de la valoración a entero, una cadena de fecha a fecha)
- normalizar el texto (espacios en blanco, formas Unicode, mayúsculas y minúsculas)
- eliminar duplicados con una clave estable, como la URL del producto
- validar (un precio debe ser positivo, una valoración debe estar entre 1 y 5) y apartar las filas que fallan
- enriquecer o combinar (añadir una moneda, una categoría, la marca de tiempo del scraping)

Convertir el HTML de la página en campos ya es un paso de parsing; explicamos los tipos de parser y dónde fallan en [¿Qué es el parsing de datos?](/es/blog/what-is-data-parsing). Una limpieza completa con pandas está en [Cómo limpiar datos de scraping con pandas en Python](/es/blog/clean-scraped-data-with-pandas).

**La carga** escribe las filas limpias en el destino y hace visible el resultado de una sola vez. En un proyecto pequeño eso es un archivo SQLite; en un equipo, PostgreSQL o un warehouse en la nube. La carga es también donde nacen la mayoría de los errores de filas duplicadas, y por eso el método importa más que el destino. Las ventajas y desventajas de CSV, JSON y SQLite se tratan en [Cómo guardar datos de scraping en CSV, JSON y SQLite](/es/blog/save-scraped-data-csv-json-sqlite).

## ¿Cómo se ejecuta un pipeline ETL?

Una ejecución de un job ETL de scraping programado pasa por estos pasos:

1. **Un disparador inicia la ejecución.** Una entrada de cron, un orquestador como Apache Airflow o una persona que lanza el script.
2. **La extracción obtiene la fuente.** Las páginas se piden a un ritmo respetuoso, las peticiones fallidas se reintentan tras una pausa y se recogen los campos en bruto.
3. **La transformación limpia cada registro.** Se convierten los tipos, se descartan los duplicados y los registros que incumplen una regla se cuentan y se registran en el log en lugar de pasar en silencio.
4. **Un control de calidad decide.** Si la extracción no devolvió nada o se rechazaron demasiadas filas, la ejecución se detiene antes de tocar el destino. Un cambio de diseño en el sitio no debería sobrescribir los datos buenos de ayer.
5. **La carga escribe en una sola transacción.** Las filas se insertan o actualizan por clave (upsert); o se confirma todo el lote, o nada.
6. **La ejecución informa.** Los recuentos de filas extraídas, limpias, rechazadas y cargadas van a un log o a una alerta, para que un fallo silencioso se vuelva visible.

En jobs grandes las fases pueden solaparse, y la transformación empieza con los datos que ya han llegado. Con unos pocos miles de filas, ejecutarlas en secuencia facilita la depuración.

## ETL vs. ELT: ¿cuál es la diferencia?

ELT (extracción, carga, transformación) mantiene los mismos tres pasos, pero lleva la transformación al destino. Los datos en bruto se cargan tal cual, y SQL o el motor del warehouse los remodela después. La guía de Microsoft lo resume con claridad: en ELT la transformación ocurre en el almacén de datos de destino.

| | ETL | ELT |
|---|---|---|
| Orden | Extracción, transformación y después carga | Extracción, carga y después transformación |
| Dónde se hace la limpieza | En tu script o en un motor aparte | Dentro de la base de datos o el warehouse (SQL) |
| Qué contiene el destino | Solo filas limpias y validadas | Filas en bruto más tablas o vistas limpias |
| ¿Se guardan los datos en bruto? | Solo si los almacenas aparte | Sí, por diseño |
| Qué exige al destino | Cualquier almacén, incluso un archivo SQLite | Un destino capaz de transformar a gran escala |
| Corregir un error de limpieza | Volver a extraer o repetir desde archivos en bruto guardados | Reescribir el SQL y reconstruir desde las tablas en bruto |
| Encaja bien con | Jobs de scraping pequeños y medianos, esquemas estrictos | Grandes volúmenes, análisis exploratorio, reglas cambiantes |

Para el scraping hay un camino intermedio útil: guardar en disco el HTML o el JSON en bruto de cada ejecución (un área de staging) y transformar a partir de esos archivos. Si una regla de limpieza resulta ser incorrecta, reconstruyes la tabla sin enviar una sola petición nueva al sitio.

## ¿Por qué un job ETL debe poder ejecutarse dos veces sin riesgo?

Los jobs fallan a mitad de camino. Una conexión se cae en la página 38, la máquina se reinicia, un planificador vuelve a intentarlo. La pregunta es cómo queda el destino después del segundo intento. La guía de buenas prácticas de Apache Airflow responde directamente: trata una tarea como una [transacción en una base de datos](https://airflow.apache.org/docs/apache-airflow/stable/best-practices.html), no produzcas nunca resultados parciales y sustituye un `INSERT` simple por un upsert, porque repetir la ejecución con `INSERT` puede dejar filas duplicadas.

Esa propiedad se llama **idempotencia**: ejecutar el job una vez o tres sobre la misma entrada deja el mismo resultado. En la práctica sale de tres hábitos:

- **Una clave natural.** Cada fila tiene un identificador que no cambia entre ejecuciones. Para una página de producto sirve la URL; un ID autoincremental no.
- **Upsert en lugar de insert.** Un upsert inserta la fila si la clave no existe y la actualiza si ya existe. SQLite admite `INSERT … ON CONFLICT DO UPDATE` desde la versión 3.24.0, y el [calificador especial `excluded.`](https://sqlite.org/lang_upsert.html) se refiere a los valores que se habrían insertado. PostgreSQL tiene la misma sintaxis.
- **Una transacción por carga.** El lote se confirma completo o se revierte, así que un fallo nunca deja media tabla.

## Programar y monitorizar un job ETL

Un job que solo se ejecuta cuando te acuerdas es un script. Para una máquina y un job, basta con cron (o el Programador de tareas en Windows). Cuando los jobs dependen unos de otros (hacer scraping, luego limpiar, luego generar un informe), un orquestador se gana su lugar. En Airflow un pipeline es un DAG, un conjunto de tareas con dependencias, y su [argumento `schedule`](https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/dags.html) acepta valores predefinidos como `@daily`, una cadena cron o un intervalo de tiempo. Airflow también reintenta las tareas fallidas, otra razón más para que las tareas sean idempotentes.

La monitorización no necesita una plataforma desde el primer día. Registra cuatro números en cada ejecución (páginas obtenidas, filas extraídas, filas rechazadas, filas cargadas) y crea alertas para dos situaciones: el job no se ejecutó, o uno de esos números se alejó mucho de su valor habitual. Una caída de 1.000 filas a 20 suele significar que el sitio cambió su diseño, no que la tienda se quedó sin stock. Para el lado del scraping, [Cómo detectar cambios en una página web y recibir alertas](/es/blog/website-change-monitoring) muestra cómo detectar cuándo cambia la propia página.

## ¿Dónde encajan los proxies en ETL?

Solo en el paso de extracción, y solo cuando el job los necesita. Unas pocas páginas al día de un sitio público rara vez los requieren. Importan cuando recoges precios que varían según el país, cuando un job legítimo con muchas peticiones choca con los límites por IP que aplica un sitio, o cuando la fuente muestra contenido que depende de la ubicación. Un [Proxies residenciales](https://proxynet.io/es/residential-proxy) permite que el paso de extracción pida páginas desde un país y una ciudad concretos; un [Proxies rotativos](https://proxynet.io/es/rotating-proxy) reparte las peticiones entre varias IP en rastreos más grandes.

Un proxy no cambia las reglas. El robots.txt del sitio, sus términos y un ritmo de peticiones razonable siguen aplicándose, y una API oficial o una exportación va primero siempre que exista. Tratamos el lado de las peticiones con más detalle en [Cómo hacer web scraping sin que te bloqueen](/es/blog/web-scraping-without-getting-blocked) y en nuestra página de [extracción de datos](/es/data-scraping).

## Un job ETL completo en Python

El script siguiente es un pipeline entero en un solo archivo. Extrae todas las fichas de libros de [books.toscrape.com](https://books.toscrape.com/), un sandbox creado para practicar scraping, transforma los campos en filas tipadas y los carga en SQLite con un upsert. Reintenta ante respuestas `429` y `5xx` con pausas crecientes, espera un segundo entre páginas, se detiene si demasiadas filas fallan la validación y envía el tráfico por un proxy solo si defines `PROXY_URL`.

Primero instala las dos dependencias:

```bash
pip install requests beautifulsoup4
```

Luego guarda esto como `etl_books.py`:

```python
"""Un pequeño job ETL: books.toscrape.com -> filas limpias -> SQLite."""
import argparse
import logging
import os
import sqlite3
import time
import unicodedata
from datetime import datetime, timezone
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

BASE = "https://books.toscrape.com/catalogue/"
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
log = logging.getLogger("etl")

def make_session():
    retry = Retry(total=4, backoff_factor=1.5,
                  status_forcelist=(429, 500, 502, 503, 504),
                  respect_retry_after_header=True)
    s = requests.Session()
    s.mount("https://", HTTPAdapter(max_retries=retry))
    s.headers["User-Agent"] = "etl-demo/1.0 (contact: you@example.com)"
    proxy = os.getenv("PROXY_URL")  # p. ej. http://user:pass@pr.proxynet.io:8000
    if proxy:
        s.proxies = {"http": proxy, "https": proxy}
    return s

# ---------- EXTRACT: obtener páginas, conservar las cadenas en bruto ----------
def extract(session, max_pages, delay=1.0):
    raw, url, page = [], urljoin(BASE, "page-1.html"), 0
    while url and page < max_pages:
        resp = session.get(url, timeout=15)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.content, "html.parser")
        for card in soup.select("article.product_pod"):
            raw.append({
                "title": card.h3.a["title"],
                "href": card.h3.a["href"],
                "price": card.select_one("p.price_color").get_text(),
                "rating": card.select_one("p.star-rating")["class"][-1],
                "stock": card.select_one("p.availability").get_text(),
                "page_url": url,
            })
        page += 1
        nxt = soup.select_one("li.next a")
        url = urljoin(url, nxt["href"]) if nxt else None
        time.sleep(delay)
    log.info("extract: %d pages, %d raw rows", page, len(raw))
    return raw

# ---------- TRANSFORM: tipos, limpieza, duplicados, validación ----------
def transform(raw):
    clean, rejected, seen = [], 0, set()
    now = datetime.now(timezone.utc).isoformat(timespec="seconds")
    for r in raw:
        try:
            url = urljoin(r["page_url"], r["href"])
            if url in seen:
                continue
            seen.add(url)
            title = " ".join(unicodedata.normalize("NFKC", r["title"]).split())
            price = float(r["price"].strip().lstrip("Â£"))
            rating = RATINGS[r["rating"]]
            in_stock = "in stock" in r["stock"].lower()
            if not title or price <= 0:
                raise ValueError("empty title or bad price")
        except (KeyError, ValueError) as exc:
            rejected += 1
            log.warning("rejected %r: %s", r.get("title"), exc)
            continue
        clean.append((url, title, price, rating, int(in_stock), now))
    log.info("transform: %d clean, %d rejected", len(clean), rejected)
    return clean, rejected

# ---------- LOAD: upsert en SQLite en una sola transacción ----------
def load(rows, db_path):
    con = sqlite3.connect(db_path)
    with con:  # confirma si todo va bien, revierte si hay error
        con.execute("""CREATE TABLE IF NOT EXISTS books (
            url TEXT PRIMARY KEY, title TEXT NOT NULL, price_gbp REAL NOT NULL,
            rating INTEGER, in_stock INTEGER, updated_at TEXT)""")
        con.executemany("""INSERT INTO books VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT(url) DO UPDATE SET title=excluded.title,
              price_gbp=excluded.price_gbp, rating=excluded.rating,
              in_stock=excluded.in_stock, updated_at=excluded.updated_at""", rows)
        total = con.execute("SELECT COUNT(*) FROM books").fetchone()[0]
    con.close()
    log.info("load: %d rows upserted, %d rows in table", len(rows), total)
    return total

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--pages", type=int, default=3)
    ap.add_argument("--db", default="books.db")
    args = ap.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
    raw = extract(make_session(), args.pages)
    rows, rejected = transform(raw)
    if not rows or rejected > len(raw) * 0.1:  # control: no cargar un lote roto
        raise SystemExit(f"aborting load: {len(rows)} clean, {rejected} rejected")
    load(rows, args.db)

if __name__ == "__main__":
    main()
```

Ejecútalo para todo el catálogo con `python etl_books.py --pages 60`. El sitio tiene 50 páginas, así que el bucle se detiene solo cuando desaparece el enlace "next". Nuestra ejecución imprimió:

```text
INFO extract: 50 pages, 1000 raw rows
INFO transform: 1000 clean, 0 rejected
INFO load: 1000 rows upserted, 1000 rows in table
```

Ejecútalo una segunda vez y la última línea sigue terminando en `1000 rows in table`. El upsert actualizó las filas existentes en lugar de añadir copias: esa es la idempotencia descrita arriba. También probamos los caminos de error. Cuando una conexión agotó el tiempo de espera, se ejecutaron los reintentos y el script terminó con un error antes del paso de carga, así que la tabla conservó sus filas anteriores. Al pasar a `transform()` un precio de `£x` y una valoración de `Six`, ambas filas se rechazaron con el motivo registrado en el log, y una URL repetida se descartó como duplicado.

Para enviar el paso de extracción por un proxy, define la variable antes de ejecutar; no cambia nada más:

```bash
export PROXY_URL="http://user:pass@pr.proxynet.io:8000"
python etl_books.py --pages 60
```

Para ejecutarlo cada noche a las 03:00 en Linux, añade una línea al crontab como `0 3 * * * cd /opt/etl && .venv/bin/python etl_books.py --pages 60 >> etl.log 2>&1`. Cuando tengas varios jobs dependientes, pásalos a un orquestador.

## Dónde se usa ETL con datos web

- **Monitorización de precios:** un job nocturno extrae los precios de la competencia, normaliza las monedas y carga una tabla de historial; consulta nuestra página de [monitorización de precios](/es/price-monitoring) y [Cómo hacer seguimiento de precios de la competencia](/es/blog/competitor-price-tracking).
- **Estudios de mercado:** número de productos, valoraciones y cambios de surtido en muchas tiendas, cargados en un mismo esquema; consulta [estudios de mercado](/es/market-research).
- **Rastreos grandes:** primero se descubren miles de URL y después se extraen datos de cada una; nuestra página de [web crawler](/es/web-crawler) cubre la parte del descubrimiento.
- **Datos alternativos:** los analistas combinan datos web con otras fuentes, y las reglas de calidad del paso de transformación deciden si el resultado es fiable; consulta [¿Qué son los datos alternativos?](/es/blog/what-is-alternative-data).
- **Análisis y minería:** una tabla limpia y cargada es la entrada que necesita la [minería de datos](/es/blog/what-is-data-mining).

## Errores frecuentes en ETL

- **Limpiar dentro del paso de extracción.** Cuando el parsing y la conversión ocurren en el mismo bucle, no puedes distinguir un cambio del sitio de un error en tus reglas. Mantén la extracción en bruto.
- **`INSERT` simple en cada ejecución.** La segunda ejecución duplica la tabla. Usa una clave natural y un upsert.
- **Sin control de calidad.** Un cambio de diseño convierte cada precio en `None`, y el job carga tan tranquilo 1.000 filas vacías encima de los datos buenos.
- **Confirmar fila por fila.** Un fallo a mitad deja una tabla que no es ni el estado anterior ni el nuevo.
- **Rechazos silenciosos.** Descartar filas malas sin contarlas oculta los problemas hasta que alguien nota que los números parecen escasos.
- **Codificación incorrecta.** Leer bytes con el juego de caracteres equivocado convierte `£` en `Â£`; nuestro artículo sobre [errores de Unicode en Python](/es/blog/python-unicode-encoding-errors) explica la causa.
- **Ignorar los límites de la fuente.** Sin pausa, sin espera entre reintentos, sin respetar `Retry-After`. El sitio empieza a responder con `429`; consulta [429 Too Many Requests explicado](/es/blog/http-429-too-many-requests).

## Guía de decisión

| Tu situación | Qué usar |
|---|---|
| Una fuente, unos pocos miles de filas, una persona | Un único script ETL en Python y SQLite, ejecutado con cron |
| Las reglas cambian a menudo y quieres conservar los datos en bruto | ELT: carga filas o archivos en bruto y transforma con SQL |
| Varios jobs que dependen unos de otros | Un orquestador como Apache Airflow |
| Grandes volúmenes y ya tienes un warehouse en la nube | ELT dentro del warehouse |
| Precios o contenido que varían según el país | ETL con un proxy residencial en el paso de extracción |
| La fuente ofrece una API o una descarga | Extrae de la API; olvídate del scraping |

## Preguntas frecuentes

### ¿Qué significa ETL?

Extract, transform, load: extracción, transformación y carga. Los datos se extraen de una fuente, se transforman en una forma coherente y validada y se cargan en un almacén de destino, como una base de datos o un data warehouse.

### ¿El web scraping es lo mismo que ETL?

No. El scraping es una forma de hacer el paso de extracción. ETL también cubre lo que pasa después: limpiar, eliminar duplicados, validar y cargar los datos en un lugar donde se puedan consultar y mantener actualizados.

### ¿Se sigue usando ETL o lo ha sustituido ELT?

Se usan los dos. ELT es habitual cuando un warehouse en la nube puede hacer la transformación pesada y conviene conservar los datos en bruto. ETL sigue siendo la opción natural cuando el destino es pequeño, el esquema es estricto o los datos deben limpiarse antes de almacenarse.

### ¿Puedo construir un pipeline ETL solo con Python?

Sí. Para jobs pequeños y medianos bastan `requests`, un parser de HTML y el módulo `sqlite3` de la biblioteca estándar, como muestra el script de arriba. Bibliotecas como pandas ayudan cuando el paso de transformación se vuelve más pesado.

### ¿Qué es un pipeline ETL?

Es el proceso ETL preparado para ejecutarse de forma repetida: un disparador, los tres pasos, un control de calidad y un informe, normalmente según un calendario. Un script de un solo uso se convierte en pipeline cuando puede ejecutarse sin supervisión y recuperarse de los fallos.

### ¿Necesito Airflow para hacer ETL?

No para un job en una sola máquina; con cron basta. Un orquestador ayuda cuando varias tareas dependen unas de otras, necesitan reintentos e historial de ejecuciones, o las comparte un equipo.

## En resumen

ETL es la parte de un proyecto de datos que viene después de la recogida: extraer los datos en bruto, transformarlos en filas tipadas, sin duplicados y validadas, y cargarlos en una sola transacción con un upsert para que repetir la ejecución nunca duplique la tabla. ELT lleva la transformación al destino y encaja con grandes warehouses; para la mayoría de los jobs de scraping, un pequeño script ETL con SQLite es el punto de partida correcto. Añade un control de calidad y cuatro números registrados, prográmalo y recurre a un proxy solo cuando el paso de extracción necesite otra ubicación o más IP. Cuando sea así, consulta nuestros [planes de proxy](/es/proxy).
