SQLite y DuckDB: cuándo cada una es la opción correcta
Índice de contenidos
- El punto en común: embedded
- Diferencia estructural: filas vs columnas
- Cuándo elegir SQLite
- Cuándo elegir DuckDB
- Python: las dos en la misma sesión
- Usarlas juntas
- Limitaciones reales
- Benchmark medido: agregación, búsqueda por clave e inserciones
- El veredicto rápido
- Conclusión
- Preguntas frecuentes
- ¿Puedo consultar mi base SQLite desde DuckDB sin montar un ETL?
- ¿Cuánto más rápida es DuckDB que SQLite en consultas analíticas?
- ¿Sirve SQLite si varios procesos escriben a la vez en la misma base?
- Fuentes
Probado con SQLite 3.53.4 · DuckDB 1.5.5 · Python 3.14.7 · Docker Engine 29.5.2 · verificado
Actualizado: 2026-09-16
SQLite y DuckDB son bases de datos embedded: ninguna requiere servidor, las dos trabajan sobre un archivo local. Pero su arquitectura es distinta. SQLite organiza datos por filas y destaca en transacciones cortas (OLTP); DuckDB organiza por columnas y brilla en análisis sobre grandes volúmenes (OLAP). Elegir la correcta, o combinarlas, es una ventaja técnica real.
SQLite[1] y DuckDB[2] comparten algo llamativo: las dos son bases de datos embebidas: una biblioteca que vive en tu proceso, sin servidor separado. Y sin embargo resuelven problemas distintos. SQLite es el rey de transacciones pequeñas y persistencia local; DuckDB es el rey del análisis columnar rápido sin infraestructura. Elegir mal entre ellas tiene coste real, y combinarlas cuando tiene sentido multiplica el valor de ambas.
Revisado el 16 de septiembre de 2026 con las versiones estables de ese día: SQLite 3.53.4, publicada el 24 de julio de 2026, y DuckDB 1.5.5, del 22 de julio. Incluye un benchmark reproducible ejecutado en nuestra máquina. En él, DuckDB resolvió dos agregaciones sobre 5 millones de filas al menos 40 veces más deprisa que SQLite, y más de 140 veces en la más simple. SQLite, a cambio, respondió cada búsqueda por clave primaria casi 60 veces más deprisa.
El punto en común: embedded
Las dos descartan el modelo cliente-servidor. Tu aplicación importa la biblioteca y ejecuta SQL sobre un archivo. No hay systemd, ni puertos, ni usuarios, ni replicación como aspecto central. Esto reduce drásticamente la complejidad operativa:
-
SQLite: un archivo
.db, más los archivos-waly-shmmientras haya conexiones abiertas en modo WAL. Con la base cerrada, una copia es uncp. En caliente usaVACUUM INTO, la API de backup osqlite3_rsync: copiar el archivo a mitad de una transacción puede dar una copia corrupta. -
DuckDB: un archivo
.duckdb(o en memoria, o directamente sobre Parquet).
Este patrón embedded es perfecto para apps móviles, desktop, servicios de una sola instancia, notebooks analíticos y pipelines ETL locales. Es el anti-patrón para sistemas con decenas de servicios concurrentes contra la misma base de datos.
Diferencia estructural: filas vs columnas
La separación no es cosmética. Es arquitectural:
-
SQLite guarda datos por filas. Cada fila es un bloque contiguo. Perfecto para
SELECT * FROM users WHERE id = 42: leer una fila entera sale de una sola página. -
DuckDB guarda datos por columnas. Cada columna es un bloque contiguo. Perfecto para
SELECT AVG(amount) FROM transactions WHERE year=2025: recorre pocos campos a lo largo de toda la tabla.
OLTP vs OLAP, en formato embedded. La diferencia también se nota en disco: la misma tabla de 5 millones de filas ocupó 124,5 MB en SQLite y 66,1 MB en DuckDB. DuckDB aplica compresión ligera a los datos que guarda.
Cuándo elegir SQLite
Escenarios donde SQLite es claramente correcto:
-
App móvil con BD local: iOS, Android y desktop. SQLite viene incluida en todos los dispositivos Android e iOS.
-
Configuración y persistencia de una app: Firefox, Chrome y Safari la llevan dentro.
-
Servicio web con un solo nodo y lectura/escritura mixta moderada.
-
Prototipo o MVP antes de migrar a PostgreSQL.
-
Backup durable y transaccional local con ACID completo.
-
WASM en el navegador vía la compilación WebAssembly oficial[3] o sql.js[4].
SQLite hace bien lo que debe hacer: transacciones pequeñas, alta integridad, baja latencia por operación. Los patrones de despliegue que han aguantado el paso del tiempo están en SQLite en producción: patrones que han envejecido bien.
Cuándo elegir DuckDB
Escenarios donde DuckDB brilla:
-
Análisis de logs: millones de filas, queries con agregados.
-
Consultar archivos Parquet/CSV directamente sin importar:
SELECT * FROM 'datos.parquet'funciona de serie. -
Reemplazar pandas en pipelines medianos: DuckDB consulta DataFrames de pandas y Polars sin importarlos, con SQL nativo.
-
Análisis ad-hoc en notebooks: Jupyter + DuckDB es una combinación potente.
-
Consolidar bases OLTP para reports sin tocar producción (CDC + DuckDB).
-
Procesamiento vectorizado sobre datos tabulares sin infraestructura distribuida.
DuckDB es, en la práctica, lo que pandas-con-SQL-nativo debería haber sido. Tienes más casos en DuckDB: analítica rápida sin mover los datos.
Python: las dos en la misma sesión
Ambas se usan igual desde Python. Este fragmento funciona tal cual con Python 3.14.7, DuckDB 1.5.5 y pandas 3.0.5, con un events.parquet que tenga una columna type:
# SQLite: OLTP, persistencia transaccional
import sqlite3
con = sqlite3.connect('app.db')
con.execute("CREATE TABLE IF NOT EXISTS users "
"(id INTEGER PRIMARY KEY, name TEXT)")
con.execute("INSERT INTO users (name) VALUES (?)", ("Ana",))
con.commit()
# DuckDB: OLAP, análisis rápido
import duckdb
con = duckdb.connect('analytics.duckdb')
# Consultar Parquet directamente, sin importar
df = con.execute("SELECT * FROM 'events.parquet' WHERE type='signup'").df()
El .df() final necesita pandas: sin él, DuckDB 1.5.5 falla con ModuleNotFoundError: No module named 'numpy'. DuckDB tiene integración Python especialmente pulida: consulta DataFrames de pandas y Polars por el nombre de su variable y devuelve resultados con .df() o .pl(). Esta característica encaja bien con pipelines de datos que también usan Kafka para streaming de eventos.
Usarlas juntas
Combinarlas es un patrón productivo:
-
OLTP en SQLite, OLAP en DuckDB: la app escribe en SQLite; un job periódico vuelca cambios a DuckDB/Parquet para análisis.
-
DuckDB lee SQLite directamente con la extensión
sqlite(antessqlite_scanner), que DuckDB descarga y carga sola la primera vez:ATTACH 'app.db' AS app (TYPE sqlite)y despuésSELECT * FROM app.users. La funciónsqlite_scan('app.db', 'users')sigue funcionando en 1.5.5. Sin ETL manual. -
Archivar datos antiguos de SQLite a Parquet con
COPY app.events TO 'events.parquet' (FORMAT parquet): DuckDB mantiene queries sobre históricos sin inflar la BD de producción.
Adjuntar el archivo ya acelera el análisis. En nuestra prueba, la agregación de 20 grupos tardó 41 ms en DuckDB sobre el archivo SQLite adjuntado, frente a 0,95 s en el propio SQLite. La copia en Parquet, que DuckDB escribió en 0,70 s y ocupó 66,4 MB, bajó esa consulta a 8 ms. El patrón "SQLite para caliente + DuckDB para frío" reduce complejidad mientras mantiene cada motor en su terreno óptimo.
Limitaciones reales
SQLite no es para:
-
Escrituras concurrentes altas: admite lectores sin límite, pero un solo escritor a la vez, también con WAL. El resto de escritores espera en cola.
-
Dos o más procesos escribiendo sin margen de espera. Si otro proceso tiene el bloqueo de escritura, tu
INSERTespera eltimeoutde la conexión (5 s por defecto en Python) y falla condatabase is locked. Lo hemos reproducido: contimeout=1.5, el error llegó a los 1,55 s, y un lector en WAL siguió leyendo mientras tanto. En sistemas de archivos de red, el bloqueo puede fallar y WAL no funciona. -
WAL con dos o más conexiones en versiones antiguas. El fallo "WAL-reset", presente de la 3.7.0 a la 3.51.2, podía corromper la base en casos raros si dos conexiones escribían o hacían checkpoint a la vez. Está corregido desde la 3.51.3 (y en 3.44.6 y 3.50.7). Python usa la SQLite del sistema: en la imagen
python:3.14.7-slim-trixiees la 3.46.1 de Debian 13, cuyo registro de cambios no recoge esa corrección. -
Bases que rozan el terabyte: el límite es de 281 TB, pero la propia documentación aconseja plantearse un motor cliente-servidor cuando el contenido se acerque a ese rango.
-
Replicación integrada: herramientas como Litestream[5] cubren este hueco, y lo explicamos en Litestream: replicación casi en tiempo real para SQLite.
DuckDB no es para:
-
OLTP real con más de un proceso: en modo lectura-escritura, un solo proceso abre el archivo, y el resto solo puede abrirlo en modo lectura. Dentro de ese proceso, los hilos escriben con control de concurrencia optimista, y el segundo que modifica la misma fila recibe un error de conflicto.
-
Cargas dominadas por consultas puntuales: cada búsqueda por clave primaria costó 253 µs, frente a 4,2–4,3 µs en SQLite. Sigue por debajo del milisegundo, pero un bucle de 20.000 búsquedas tarda 5 s en DuckDB y 0,09 s en SQLite.
-
Cargas con escritura concurrente continua desde más de un proceso. Para eso, su documentación propone Quack, un protocolo remoto en beta publicado el 12 de mayo de 2026, o DuckLake con el catálogo en PostgreSQL.
Intentar que cada una haga lo del otro es hacerse daño innecesariamente.
Benchmark medido: agregación, búsqueda por clave e inserciones
Las cifras que daba antes este artículo no tenían fuente: 15 s frente a 1 s para un GROUP BY sobre un millón de filas, y 1.000 transacciones por segundo con WAL. Las hemos sustituido por una medición propia.
La prueba corre en un contenedor python:3.14.7-trixie sobre Linux arm64 con 18 núcleos y 121 GB de RAM, sin límites de CPU. La máquina es compartida: la carga media de un minuto que marcó uptime osciló entre 3,8 y 6,4, a ratos algo por encima del umbral de 6 que nos habíamos marcado. Descartamos una ejecución anterior hecha con la carga entre 9 y 14.
Python usa la biblioteca SQLite del sistema (3.46.1 en Debian 13), así que el primer bloque compila la 3.53.4 desde el amalgamation oficial y la carga con LD_LIBRARY_PATH. El hash SHA3-256 del zip coincidió con el de la página de descargas:
mkdir -p bench/data && cd bench # guarda aquí bench.py
docker run --rm -it -v "$PWD":/w -w /w python:3.14.7-trixie bash
# dentro del contenedor:
V=3530400
curl -sSO https://www.sqlite.org/2026/sqlite-amalgamation-$V.zip
unzip -q sqlite-amalgamation-$V.zip
gcc -O2 -fPIC -shared -o libsqlite3.so.0 \
sqlite-amalgamation-$V/sqlite3.c -lpthread -lm
pip install duckdb==1.5.5
LD_LIBRARY_PATH=/w python bench.py
El script bench.py genera la misma tabla de 5 millones de filas en los dos motores con aritmética entera, sin datos aleatorios. Cada medida se repite cinco veces y se queda con la mediana:
import os, random, sqlite3, statistics, time
import duckdb
N, K, M, RUNS = 5_000_000, 20_000, 1_000, 5
DDL = ("CREATE TABLE events (id INTEGER PRIMARY KEY, user_id INTEGER,"
" category TEXT, amount DOUBLE)")
ROW = ("SELECT i, (i * 2654435761) % 100000, 'c' || CAST(i % 20 AS TEXT),"
" CAST((i * 7919) % 100000 AS DOUBLE) / 100")
AGG = ("SELECT category, count(*), round(sum(amount), 2) FROM events"
" GROUP BY category ORDER BY category")
TOP = ("SELECT user_id, round(sum(amount), 2) AS total FROM events"
" GROUP BY user_id ORDER BY total DESC, user_id LIMIT 10")
def med(fn):
times = []
for _ in range(RUNS):
t = time.perf_counter(); fn(); times.append(time.perf_counter() - t)
return statistics.median(times)
def fresh(path):
for ext in ("", "-wal", "-shm", "-journal", ".wal"):
if os.path.exists(path + ext): os.remove(path + ext)
return path
La primera prueba lanza dos agregaciones, una de 20 grupos y otra de 100.000 grupos con un top 10, y comprueba que los dos motores devuelven lo mismo. La segunda hace 20.000 búsquedas por clave primaria, con claves elegidas al azar:
lite = sqlite3.connect(fresh("data/bench.sqlite"))
lite.execute(DDL)
lite.execute(f"INSERT INTO events WITH RECURSIVE r(i) AS (SELECT 1"
f" UNION ALL SELECT i + 1 FROM r WHERE i < {N}) {ROW} FROM r")
lite.commit()
duck = duckdb.connect(fresh("data/bench.duckdb"))
duck.execute(DDL)
duck.execute(f"INSERT INTO events {ROW} FROM range(1, {N} + 1) t(i)")
duck.execute("CHECKPOINT")
print("sqlite", sqlite3.sqlite_version, "| duckdb", duckdb.__version__,
"| threads", duck.sql("SELECT current_setting('threads')").fetchone())
for name, sql in (("agg", AGG), ("top", TOP)):
assert lite.execute(sql).fetchall() == duck.execute(sql).fetchall()
print(f"{name} sqlite {med(lambda: lite.execute(sql).fetchall()):.3f} s"
f" duckdb {med(lambda: duck.execute(sql).fetchall()):.3f} s")
keys = random.Random(42).choices(range(1, N + 1), k=K)
def lookups(con):
for k in keys:
con.execute("SELECT * FROM events WHERE id = ?", [k]).fetchone()
for name, con in (("sqlite", lite), ("duckdb", duck)):
print(f"pk {name} {med(lambda: lookups(con)) / K * 1e6:.1f} µs/lookup")
La tercera mide 1.000 INSERT de una fila, cada uno en su propia transacción, sobre un archivo nuevo en cada repetición:
def inserts(connect):
con = connect()
con.execute("CREATE TABLE log (id INTEGER PRIMARY KEY, msg TEXT)")
t = time.perf_counter()
for i in range(M):
con.execute("INSERT INTO log VALUES (?, ?)", [i, "evento"])
rate = M / (time.perf_counter() - t)
con.close()
return rate
def sqlite_mode(journal, sync):
def connect():
con = sqlite3.connect(fresh("data/ins.sqlite"), isolation_level=None)
con.execute(f"PRAGMA journal_mode = {journal}")
con.execute(f"PRAGMA synchronous = {sync}")
return con
return connect
SQLite se prueba con tres combinaciones de journal_mode y synchronous: la de fábrica, WAL con la misma durabilidad y WAL con synchronous = NORMAL, que su documentación describe como el mejor equilibrio entre rendimiento y seguridad en modo WAL:
modes = {
"sqlite delete+full": sqlite_mode("DELETE", "FULL"),
"sqlite wal+full": sqlite_mode("WAL", "FULL"),
"sqlite wal+normal": sqlite_mode("WAL", "NORMAL"),
"duckdb": lambda: duckdb.connect(fresh("data/ins.duckdb")),
}
for name, connect in modes.items():
rates = [inserts(connect) for _ in range(RUNS)]
print(f"ins {name}: {statistics.median(rates):,.0f} tx/s")
Estos son los resultados de dos ejecuciones completas con la 3.53.4. Cada cifra es la mediana de cinco repeticiones, y los rangos recogen la diferencia entre ejecuciones; la fila de un hilo y el tamaño salen de una tercera pasada con las mismas tablas:
| Prueba (5 millones de filas) | SQLite 3.53.4 | DuckDB 1.5.5 |
|---|---|---|
| GROUP BY de 20 grupos | 0,95 s | 5–6 ms |
| GROUP BY de 100.000 grupos y top 10 | 0,97–0,98 s | 21–22 ms |
| Las dos agregaciones, DuckDB con 1 hilo | – | 15 y 32 ms |
| Búsqueda por clave primaria | 4,2–4,3 µs | 253 µs |
| INSERT sueltos, configuración de fábrica | 456–460 tx/s | 667–847 tx/s |
| INSERT sueltos, WAL y synchronous FULL | 1.401–1.520 tx/s | – |
| INSERT sueltos, WAL y synchronous NORMAL | 76.420–125.244 tx/s | – |
| Tamaño del archivo | 124,5 MB | 66,1 MB |
Las agregaciones son el terreno de DuckDB: 5–6 y 21–22 ms frente a 0,95 y 0,97–0,98 s de SQLite. No depende solo de los 18 núcleos, porque con SET threads = 1 DuckDB tardó 15 y 32 ms, al menos 30 veces menos que SQLite. Con la 3.46.1 de Debian, SQLite tardó 1,07 y 1,09 s, así que la versión apenas mueve estas cifras.
En búsquedas por clave, SQLite respondió casi 60 veces antes: 4,2–4,3 µs por consulta, con el coste de Python incluido, frente a 253 µs de DuckDB. En DuckDB 1.5.5, EXPLAIN de esa consulta muestra un SEQ_SCAN con el filtro id=42 aplicado dentro del escaneo, no una lectura del índice de la clave primaria.
En escrituras sueltas manda la sincronización con disco. Con la configuración de fábrica, DuckDB confirmó más transacciones por segundo que SQLite, y con WAL y la misma durabilidad SQLite pasó por delante. Con synchronous = NORMAL, que sincroniza con el disco en menos momentos que FULL, SQLite llegó a 76.000–125.000 transacciones por segundo, a cambio de poder perder las últimas si se va la luz. Un fsync de 4 KB tardó una mediana de 1,05 ms en este disco, así que en otro hardware estas filas son las que más cambian.
Agrupar ayuda a los dos: las mismas 1.000 inserciones en una sola transacción tardaron 5,2 ms en SQLite y 295 ms en DuckDB. La documentación de DuckDB desaconseja los INSERT fila a fila para cargas masivas; con un único INSERT ... SELECT, los 5 millones de filas entraron en 1,31 s (1,12 s en SQLite).
El veredicto rápido
Cómo decidir en diez segundos:
-
¿Transacciones cortas, alta integridad, un proceso activo? SQLite.
-
¿Queries analíticos sobre grandes volúmenes, pocas escrituras? DuckDB.
-
¿App móvil, desktop o servicio simple? SQLite.
-
¿Notebooks, ETL local, reemplazar pandas? DuckDB.
-
¿Ambas cosas? Úsalas juntas.
Cualquier duda honesta se resuelve mirando la query principal: si es casi siempre un WHERE id=?, SQLite. Si es un GROUP BY sobre mucha fila, DuckDB. Para gestión de datos más amplia, ver también las bases de datos vectoriales cuando la búsqueda semántica entra en la ecuación.
Conclusión
SQLite y DuckDB son herramientas complementarias, no competidoras. Ambas representan la filosofía "embedded importa" aplicada a problemas diferentes. Para OLTP local, SQLite es imbatible por simplicidad y robustez. Para OLAP sin infraestructura, DuckDB agregó 5 millones de filas en 5–6 ms desde un script de Python, sin servidor que mantener.
Conocer las dos y aplicar la correcta es una ventaja técnica real. Combinarlas cuando tiene sentido multiplica el valor de ambas.
Fuentes:
- SQLite: When to Use SQLite[6]
- DuckDB: Why DuckDB[7]
- Litestream: Streaming Replication for SQLite[5]
- sql.js: SQLite compiled to WebAssembly[4]
- SQLite: Release History[8]
- DuckDB: releases en GitHub[9]
- SQLite: Download Page[10]
- SQLite: Most Widely Deployed SQL Database Engine[11]
- SQLite: Write-Ahead Logging[12]
- SQLite: PRAGMA synchronous[13]
- SQLite: How To Corrupt An SQLite Database File[14]
- Debian: registro de cambios de sqlite3 3.46.1 en trixie[15]
- Python: módulo sqlite3[16]
- DuckDB: Concurrency[17]
- DuckDB: SQLite Extension[18]
- DuckDB: Python API[19]
- DuckDB: Storage[20]
- DuckDB: INSERT Statements[21]
- DuckDB: Quack Remote Protocol[22]
Preguntas frecuentes
¿Puedo consultar mi base SQLite desde DuckDB sin montar un ETL?
Sí: DuckDB lee SQLite directamente con su extensión sqlite, que se descarga y carga sola: ATTACH 'app.db' AS app (TYPE sqlite) y luego SELECT * FROM app.users, sin ETL manual (la antigua sqlite_scan('app.db', 'users') sigue funcionando en 1.5.5). Es la base del patrón «SQLite para caliente + DuckDB para frío»: la app escribe transacciones en SQLite y un job periódico vuelca los cambios a DuckDB o Parquet para análisis. Adjuntar el archivo ya acelera el análisis. En nuestra prueba, una agregación que SQLite resolvía en 0,95 s tardó 41 ms desde DuckDB sobre el mismo archivo y 8 ms sobre una copia en Parquet.
¿Cuánto más rápida es DuckDB que SQLite en consultas analíticas?
Mucho en su terreno, según nuestra prueba con 5 millones de filas en Linux arm64. DuckDB 1.5.5 resolvió un GROUP BY de 20 grupos en 5–6 ms, y SQLite 3.53.4 tardó 0,95 s. Con 100.000 grupos y top 10 fueron 21–22 ms frente a 0,97–0,98 s, y con un solo hilo DuckDB se quedó en 15 y 32 ms. Las tornas se invierten en OLTP: SQLite respondió cada búsqueda por clave primaria en 4,2–4,3 µs, casi 60 veces antes que DuckDB.
¿Sirve SQLite si varios procesos escriben a la vez en la misma base?
Solo si las escrituras son cortas y cada conexión tiene margen de espera. SQLite admite lectores sin límite pero un solo escritor a la vez, también con WAL; el resto espera el timeout (5 s por defecto en Python) y, si no le llega el turno, falla con database is locked. Con WAL y dos o más conexiones, usa SQLite 3.51.3 o posterior, que corrige el fallo "WAL-reset", y evita los sistemas de archivos de red, donde el bloqueo puede fallar. Para un servicio web de un solo nodo con lectura/escritura mixta moderada sigue siendo la opción correcta.
Fuentes
- SQLite
- DuckDB
- compilación WebAssembly oficial
- sql.js
- Litestream
- SQLite: When to Use SQLite
- DuckDB: Why DuckDB
- SQLite: Release History
- DuckDB: releases en GitHub
- SQLite: Download Page
- SQLite: Most Widely Deployed SQL Database Engine
- SQLite: Write-Ahead Logging
- SQLite: PRAGMA synchronous
- SQLite: How To Corrupt An SQLite Database File
- Debian: registro de cambios de sqlite3 3.46.1 en trixie
- Python: módulo sqlite3
- DuckDB: Concurrency
- DuckDB: SQLite Extension
- DuckDB: Python API
- DuckDB: Storage
- DuckDB: INSERT Statements
- DuckDB: Quack Remote Protocol