Data loaders: el corazón del framework

En la Figura 1.2 del primer capítulo vimos que Observable Framework se apoya en cuatro pilares: páginas en Markdown, JavaScript reactivo en el navegador, un sitio estático como resultado de la construcción y, entre ambos extremos, los data loaders. Este capítulo abre la Parte II dedicada a los datos y se concentra en ese tercer pilar: los scripts que convierten fuentes crudas en archivos listos para que el navegador los consuma (Observable, Inc., 2026).

La Parte I los presentó de forma panorámica. Aquí profundizamos en el modelo de ejecución, la convención de doble extensión, el contrato con la salida estándar, la caché, los intérpretes configurables, las rutas parametrizadas, el manejo de secretos y los patrones de diagnóstico. Al final del capítulo deberías ser capaz de escribir un data loader de producción, entender qué hace el framework con su salida y elegir con criterio cuándo no usarlo.


¿Qué es exactamente un data loader?

Un data loader es un programa que escribe un artefacto de datos en la salida estándar (stdout, el canal por defecto donde cualquier programa imprime texto en la terminal). Observable Framework lo ejecuta una sola vez durante la construcción del sitio, captura lo que el programa imprime y guarda el resultado como un archivo estático dentro de la carpeta dist/. La página que lo consume, a través de FileAttachment, lee ese archivo como si siempre hubiera estado ahí (Observable, Inc., 2026).

La Figura 5.1 resume la idea en tres pasos: una fuente cruda entra, un script en el lenguaje de tu elección la transforma, y el framework guarda el resultado como archivo listo para el navegador.

Figura 5.1. Un data loader es cualquier programa que lee una fuente y escribe un artefacto de datos en su salida estándar. El lenguaje es libre; el contrato es siempre el mismo.

Este diseño cambia dónde ocurre el cómputo costoso. En una arquitectura clásica de tablero analítico, el navegador pide datos a un servidor cada vez que alguien abre la página; el servidor los procesa y los envía. En Observable Framework, el procesamiento ocurre antes de publicar: el navegador recibe un archivo ya terminado y solo se encarga de visualizarlo (Horst, 2024).

Conviene contrastar este modelo con dos alternativas:

Idea central

Un data loader desplaza el procesamiento de datos desde el momento de la visita hacia el momento de la construcción. El navegador nunca ve la fuente original: recibe un artefacto preparado con un lenguaje de tu elección.


La convención de doble extensión

El framework decide qué hacer con un archivo en src/data/ (o en cualquier carpeta del proyecto) leyendo su nombre. Cada archivo se bautiza con tres piezas encadenadas: un nombre lógico, una extensión de formato y una extensión de lenguaje. La Figura 5.2 descompone el ejemplo canónico paises.csv.py.

paises.csv.py
Figura 5.2. Anatomía de un nombre de data loader. La extensión del formato (azul) define el artefacto visible para el navegador; la extensión del lenguaje (rojo) define el intérprete que el framework debe invocar.

Antes de ver la tabla de combinaciones conviene precisar un término que aparece varias veces en el capítulo. Un tipo MIME (siglas de Multipurpose Internet Mail Extensions) es un identificador estándar que los navegadores y servidores usan para saber cómo tratar un archivo: text/csv para una hoja de cálculo separada por comas, application/json para datos estructurados en JSON, application/parquet para tablas columnares binarias, etc. La extensión del formato (.csv, .json, .parquet) es lo que determina ese tipo MIME en la salida; la extensión del lenguaje (.py, .R, .js, .sh) es indiferente para el navegador y sólo afecta al intérprete que ejecuta el script durante la construcción (Observable, Inc., 2026).

La tabla siguiente resume combinaciones frecuentes:

Nombre del archivo Artefacto producido Intérprete
ventas.csv.py ventas.csv Python
metricas.json.js metricas.json Node.js
series.parquet.sh series.parquet Shell + DuckDB/CLI
cluster.json.R cluster.json Rscript
documentos.zip.py documentos.zip Python (archivo ZIP)

A continuación, un esqueleto mínimo de cada caso. Todos siguen el mismo patrón: construir el dato y escribirlo a stdout.

ventas.csv.py — CSV desde Python con polars:

import polars as pl
import sys

df = pl.DataFrame({"mes": ["ene", "feb", "mar"], "total": [120, 98, 143]})
df.write_csv(sys.stdout.buffer)

metricas.json.js — JSON desde Node:

const metricas = { activos: 1280, nuevos: 73, fecha: new Date().toISOString() };
process.stdout.write(JSON.stringify(metricas));

series.parquet.sh — Parquet generado con DuckDB por shell:

duckdb -c "COPY (SELECT * FROM read_csv_auto('src/data/raw/series.csv'))
            TO '/dev/stdout' (FORMAT 'parquet')"

cluster.json.R — JSON desde R con jsonlite:

library(jsonlite)
resultado <- list(k = 3, centros = list(c(0.1, 0.2), c(0.8, 0.5), c(0.3, 0.9)))
cat(toJSON(resultado, auto_unbox = TRUE))

documentos.zip.py — bundle ZIP con varios artefactos en una sola ejecución:

import json, sys
from zipfile import ZipFile

with ZipFile(sys.stdout.buffer, "w") as z:
    z.writestr("resumen.json", json.dumps({"total": 42}))
    z.writestr("detalle.csv", "id,valor\n1,3.14\n2,2.71\n")

El formato .zip merece mención aparte: un loader que imprime un archivo ZIP a stdout permite entregar varios artefactos desde una sola ejecución (por ejemplo, un CSV de resumen junto con un GeoJSON). La página consumidora los desempaqueta con FileAttachment("bundle.zip").zip().

Convención

La extensión del formato es lo que ve el navegador; la extensión del lenguaje es lo que ve el intérprete. El framework nunca lee el contenido del archivo para adivinar: se basa exclusivamente en el nombre.


El contrato con stdout

Un data loader no tiene una API especial: se comunica con el framework por medio de dos canales estándar de cualquier proceso del sistema operativo.

¿Qué son stdout y stderr?

Todo proceso en Unix, macOS o Windows dispone de dos flujos de salida: stdout (standard output) y stderr (standard error). Por convención, stdout transporta el resultado útil del programa y stderr los mensajes de diagnóstico. Separarlos permite canalizar el dato a un archivo y seguir viendo los avisos en la terminal.

  1. stdout transporta los datos. Todo lo que el script escriba en la salida estándar se convierte, byte por byte, en el artefacto final. Cuando el formato es binario (Parquet, Arrow, ZIP, imágenes), hay que escribir en el flujo binario (sys.stdout.buffer en Python, process.stdout.write con Buffer en Node) para evitar que el sistema reinterprete la codificación.
  2. stderr transporta los mensajes. Los avisos de progreso, las advertencias y los errores deben ir a la salida de error. Si se mezclan con los datos, el artefacto queda corrupto (Horst, 2024).

El data loader src/data/countries.csv.py de este mismo proyecto ilustra el patrón con una función auxiliar minimalista:

def log(msg: str) -> None:
    """Escribe mensajes de log a stderr."""
    print(msg, file=sys.stderr)

Con esa convención, cada fase del pipeline puede reportar cuántas filas procesó sin contaminar el CSV. La línea final del script lo deja claro:

df.to_csv(sys.stdout.buffer, index=False)

Nada más se escribe en stdout después de eso.

Error frecuente

Un print("procesando...") dentro del loader envía "procesando..." al CSV y produce una primera fila inválida. Si tu archivo cacheado empieza con una palabra suelta o un mensaje de depuración, esa es la causa.


El ciclo de vida en una figura

La Figura 5.3 muestra el recorrido completo: desde la fuente original hasta el navegador del lector. Su propósito es mostrar dónde ocurre cada cosa y en qué momento.

Figura 5.3. Ciclo de vida de un data loader. Todo lo que está en gris ocurre una sola vez, al construir el sitio. El navegador del lector solo interactúa con dist/ y nunca ve la fuente original.

Caché y reproducibilidad

El framework guarda la salida de cada loader en src/.observablehq/cache/, conservando la misma estructura de carpetas que la fuente. La próxima vez que el framework necesite ese artefacto, no ejecuta el loader si el archivo ya está en caché. Ignora deliberadamente las fechas de modificación del sistema de archivos: en un entorno de integración continua, las fechas se reescriben al clonar el repositorio y no son una señal confiable de cambios reales (Observable, Inc., 2026).

Para forzar la regeneración hay dos opciones:

Cuando un loader falla, el framework escribe un archivo hermano con extensión .err en la misma ruta de caché. Ese archivo contiene el mensaje de error y es el primer lugar donde buscar cuando npm run dev reporta un artefacto roto (Horst, 2024).

Inspeccionar la caché

Mientras desarrollas un loader, abre src/.observablehq/cache/ en tu editor o explorador. Ver el archivo real —las primeras filas de un CSV, la estructura de un JSON— es la forma más rápida de confirmar que el contrato con stdout se está cumpliendo.


Intérpretes y entornos

La relación entre una extensión de lenguaje y el comando que la ejecuta no está cableada: se declara en el bloque interpreters de observablehq.config.js. Este proyecto, por ejemplo, define:

interpreters: {
  ".py": ["uv", "run", "python", "-u"],
}

Dos detalles merecen atención. Primero, usar uv (Astral Software Inc., 2024) garantiza que el intérprete de Python se resuelva dentro del entorno virtual del proyecto; así, cualquiera que clone el repositorio obtiene las mismas versiones de pandas, numpy y polars sin instalar nada globalmente. Segundo, la bandera -u desactiva el buffering de stdout. Sin ella, Python podría retener los datos en memoria y liberarlos al terminar el proceso; con ella, el framework recibe bytes tan pronto como el script los produce, lo cual mejora los mensajes de error y evita truncamientos en archivos grandes.

El bloque interpreters admite cualquier comando ejecutable: un binario de ffmpeg para extraer fotogramas, un duckdb para consultar un archivo Parquet en shell, o un cargo run para un loader escrito en Rust (Observable, Inc., 2026).


Un loader mínimo, paso a paso

Este mismo libro incluye un loader de referencia en src/data/ejemplo.csv.py:

import polars as pl
import sys

df = pl.DataFrame({
    "nombre": ["Alice", "Bob", "Carol"],
    "puntaje": [85, 92, 78]
})

df.write_csv(sys.stdout.buffer)

Son ocho líneas. Construyen un DataFrame en memoria y lo escriben a stdout en formato CSV. Eso basta: el framework lo invoca con uv run python -u src/data/ejemplo.csv.py, captura la salida y la almacena como src/.observablehq/cache/data/ejemplo.csv.

Desde esta página podemos cargarlo con FileAttachment y mostrar el resultado con Inputs.table:

La opción typed: true instruye al cliente para que convierta las columnas numéricas (puntaje) a Number en lugar de dejarlas como cadenas. El resto del trabajo (formato, paginación, ordenamiento por columna) lo hace Inputs.table.


Patrones de producción

Los loaders más útiles rara vez se limitan a un DataFrame en memoria. A continuación algunos patrones que aparecen una y otra vez en proyectos reales.

ETL reproducible

El loader src/data/countries.csv.py de este libro es un buen ejemplo. Descompone el pipeline en funciones pequeñas con responsabilidades claras (fix_encoding, clean_column_names, convert_numeric_columns, add_continent) y reporta el progreso de cada una a stderr. El orden de operaciones es determinista, así que dos ejecuciones sobre la misma entrada producen byte a byte la misma salida. Esta propiedad convierte a la caché en una garantía de reproducibilidad: si el archivo cacheado cambia, es porque la fuente o la lógica cambió.

Varios artefactos desde un loader

Cuando una fuente produce datos que alimentan varias páginas, conviene emitir un bundle ZIP. Un loader llamado metricas.zip.py puede escribir en stdout un ZIP que contiene resumen.json, serie_temporal.csv y geografia.geojson. Las páginas lo consumen con FileAttachment("metricas.zip").zip() y extraen solo lo que necesitan, sin duplicar procesamiento.

Rutas parametrizadas

Observable Framework permite usar corchetes en nombres de archivo para generar múltiples artefactos a partir de un mismo loader. Si el proyecto tiene páginas en products/[product]/index.md, un loader en products/[product]/sales.csv.py recibirá el identificador del producto como argumento de línea de comandos (--product=42) y generará un CSV por cada valor declarado en dynamicPaths (Observable, Inc., 2026). Dentro de la página, observable.params.product resuelve al valor correspondiente, y una expresión como la siguiente funciona sin ceremonia adicional:

const info = FileAttachment(`${observable.params.product}.json`).json();

Secretos

Un loader que consulta una API privada necesita credenciales. La regla es simple: nunca escribir tokens en el archivo; leerlos de variables de entorno (os.environ["API_TOKEN"] en Python, process.env.API_TOKEN en Node). En desarrollo local conviene cargarlas desde un .env ignorado por Git; en la construcción en CI, desde el gestor de secretos del servicio (GitHub Actions, GitLab CI, Netlify). El artefacto resultante queda limpio y puede publicarse sin riesgo.


Cuándo no usar un data loader

El modelo de construcción asume que los datos pueden cachearse con seguridad entre despliegues. Hay escenarios donde esa premisa se rompe:

La frontera es clara: los data loaders son la opción correcta cuando los datos son numerosos, costosos de procesar y razonablemente estables entre despliegues.


Diagnóstico y depuración

Cuando un loader falla, conviene salir del framework y reproducir el problema de forma aislada. El procedimiento estándar tiene tres pasos (Horst, 2024):

  1. Ejecutar el loader a mano. Desde la raíz del proyecto:

    uv run python src/data/paises.csv.py > /tmp/paises.csv
    

    Redirigir la salida a un archivo temporal permite abrirla y verificar su forma. Si el comando termina con código distinto de cero, el error aparece directamente en la terminal.

  2. Leer el archivo .err. Si prefieres dejar que el framework ejecute el loader, revisa src/.observablehq/cache/<ruta>/<archivo>.err. Contiene el volcado de stderr y el código de salida.

  3. Añadir diagnóstico explícito a stderr. Un print("paso N:", len(df), file=sys.stderr) (Python) o un console.warn("paso N", rows.length) (Node) es la forma más directa de saber dónde se atasca el pipeline sin contaminar la salida.

Si el loader funciona en local pero falla en la construcción de CI, las causas más comunes son una dependencia del sistema ausente (duckdb, gdal, ffmpeg) o una variable de entorno no definida.


Puente hacia el capítulo siguiente

Hasta aquí hemos tratado al data loader como una caja que recibe una fuente y emite un artefacto, sin detenernos en qué ocurre dentro. El capítulo siguiente (Procesamiento, transformación y análisis) entra en esa caja: la carga inicial, la limpieza y transformación con pandas y polars (encoding, faltantes, joins entre tablas) y también el análisis propiamente dicho (agregaciones, resúmenes y exploración estadística que dejan la salida lista para visualizar). Más adelante, el capítulo 7 (SQL en Observable Framework) muestra cómo DuckDB convierte un .sql.js o un bloque sql de Markdown en otra forma natural de escribir data loaders.

La arquitectura no cambia: lo que construimos en este capítulo —un programa que imprime a stdout y cuyo resultado vive en dist/— sigue siendo la base de toda la Parte II.

Bibliografía

Astral Software Inc. (2024). uv: An extremely fast Python package and project manager [Https://docs.astral.sh/uv/].
Horst, A. (2024a). Data loaders for the win (…-win-win) [Https://observablehq.com/blog/data-loaders-for-the-win].
Horst, A. (2024b). How to write and troubleshoot data loaders [Https://observablehq.com/blog/write-troubleshoot-data-loaders].
Observable, Inc. (s/f). Observable Framework [Https://observablehq.com/framework/].
Observable, Inc. (2026a). Configuration [Https://observablehq.com/framework/config].
Observable, Inc. (2026b). Data loaders [Https://observablehq.com/framework/data-loaders].
Observable, Inc. (2026c). Files [Https://observablehq.com/framework/files].
Observable, Inc. (2026d). Parameterized routes [Https://observablehq.com/framework/params].
Observable, Inc. (2026e). Project structure [Https://observablehq.com/framework/project-structure].
Perkel, J. M. (2021). Reactive, reproducible, collaborative: computational notebooks evolve. Nature, 593(7857), 156–157. https://doi.org/10.1038/d41586-021-01174-w