3. De un CSV a una base de datos: cuándo hace falta y cuándo es postureo
Nivel 5: Guardar, actualizar y escalar
Hay un momento en todo proyecto en que alguien dice que esto ya pide una base de datos de verdad. A veces tiene razón. Muchas más veces está describiendo un problema que no tiene, y el precio de montarla se paga en horas de mantenimiento que salen del presupuesto de investigar estrategias. Este módulo es el criterio para decidirlo sin quedar mal en ninguna de las dos direcciones.
Qué hace una base de datos que los ficheros no hacen
Una base de datos no es un sitio donde guardar tablas; los ficheros ya son eso. Lo que aporta es un conjunto de garantías que en ficheros tendrías que programar tú:
- Escrituras concurrentes seguras. Varios procesos escribiendo a la vez sin que uno pise el fichero que otro está reescribiendo. Con particiones y reescrituras completas esto se resuelve a mano hasta cierto punto, y a partir de ahí duele.
- Transacciones. O entra todo el lote o no entra nada. Si tu proceso muere a mitad de la carga, el almacén no queda en un estado intermedio que nadie sabe interpretar.
- Restricciones de integridad. Una clave única sobre símbolo y fecha impide físicamente el duplicado que en el módulo anterior tenías que evitar con disciplina.
- Control de acceso. Usuarios distintos con permisos distintos, y un registro de quién tocó qué. En cuanto hay más de una persona, esto deja de ser burocracia.
- Consultas complejas optimizadas. Cruces entre varias tablas, filtros combinados, agregaciones por ventana, y un planificador que decide cómo ejecutarlas.
Las señales reales de que los ficheros ya no bastan
Ninguna de estas señales es el tamaño del dato, y ese es el malentendido más caro:
- Más de un proceso escribe en el mismo almacén al mismo tiempo.
- Más de una persona lee, y no todas deben ver lo mismo.
- Tus consultas cruzan tablas distintas de forma no trivial: precios contra operaciones societarias contra fundamentales contra tu registro de órdenes.
- Necesitas que un fallo a mitad de escritura no deje datos a medias.
- Tienes que responder a una pregunta arbitraria en menos de un segundo, no dentro de un guion que ya sabes.
Ahora el reverso. Si eres un usuario, con datos diarios de unos miles de valores, un proceso que se ejecuta una vez al día y un almacén de decenas de megabytes, no tienes ninguna de esas señales. Tienes ficheros bien organizados, y eso es una arquitectura correcta, no una fase provisional.
SQL sin base de datos: el punto medio que casi nadie usa
El argumento que más se oye a favor de la base de datos es que SQL es más expresivo que encadenar operaciones de pandas. Es verdad, y no implica lo que la gente cree que implica, porque existen motores analíticos embebidos que ejecutan SQL directamente sobre tus ficheros columnares, sin servidor, sin usuarios y sin proceso que mantener.
import duckdb
con = duckdb.connect() # en memoria: no hay servidor ni fichero de base de datos
top = con.execute("""
SELECT simbolo, avg(volumen) AS vol_medio, count(*) AS sesiones
FROM read_parquet('almacen/precios/**/*.parquet', hive_partitioning = true)
WHERE anio BETWEEN 2023 AND 2024 AND volumen > 0
GROUP BY simbolo
HAVING count(*) > 400
ORDER BY vol_medio DESC
LIMIT 10
""").df()Fíjate en lo que hace ese SELECT: el filtro sobre anio se empuja hasta la capa de ficheros y solo se abren las carpetas de 2023 y 2024, y de las columnas solo se leen las tres que aparecen en la consulta. Tienes la expresividad de SQL sobre el almacén del módulo uno, sin haber instalado un servidor. Para la inmensa mayoría de proyectos personales, aquí se acaba la conversación.
Series temporales o relacional: qué cambia de verdad
Si de verdad hace falta una base de datos, la siguiente elección tampoco es de moda:
- Una relacional clásica brilla cuando el dato tiene relaciones: cartera, órdenes, ejecuciones, comisiones, instrumentos, emisores. Ahí quieres claves foráneas, transacciones y restricciones que impidan una orden huérfana.
- Una base orientada a series temporales brilla cuando el patrón es escribir mucho y siempre al final, y leer por ventanas: últimas dos horas, media por minuto, remuestreo, retención automática de lo viejo. Con 1.200 instrumentos a un punto por segundo tienes unos 103 millones de puntos al día, y ahí la ingesta continua y el remuestreo dejan de ser detalles.
import sqlite3
con = sqlite3.connect("almacen/precios.db")
con.execute("""
CREATE TABLE IF NOT EXISTS precios (
simbolo TEXT NOT NULL,
fecha TEXT NOT NULL,
cierre REAL NOT NULL,
volumen INTEGER NOT NULL,
descargado_en TEXT NOT NULL,
PRIMARY KEY (simbolo, fecha)
)
""")
# La clausula ON CONFLICT necesita que exista esa clave o un indice unico:
# sin la PRIMARY KEY de arriba, esta sentencia no se ejecuta, falla
con.executemany("""
INSERT INTO precios (simbolo, fecha, cierre, volumen, descargado_en)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(simbolo, fecha) DO UPDATE SET
cierre = excluded.cierre,
volumen = excluded.volumen,
descargado_en = excluded.descargado_en
""", filas)
con.commit()Caso: el proyecto que se montó un servidor para nada
Un caso típico y perfectamente reconocible. Un solo usuario, 500 valores, datos diarios, veinticinco años: 3,1 millones de filas, decenas de megabytes en columnar. El proceso se lanza una vez al día y tarda un par de minutos. Montar un servidor de base de datos ahí añade un servicio que hay que arrancar, actualizar, respaldar y vigilar, y una capa de conexión que puede caerse a las tres de la mañana.
¿Qué se gana a cambio? Con un solo escritor, ninguna garantía de concurrencia que te haga falta. Con consultas conocidas y lanzadas desde tu propio guion, ningún planificador que te salve. Con un solo usuario, ningún control de acceso.
El criterio honesto es este: la mayoría de los proyectos personales nunca necesitan más que ficheros bien organizados más un motor SQL embebido cuando llega una consulta rara. El día que aparezcan dos escritores, una segunda persona o un requisito de integridad, migras, y para entonces sabrás exactamente qué tabla y qué restricciones necesitas. Migrar tarde cuesta un rato; migrar pronto cuesta todo el proyecto.