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[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 -wal y -shm mientras haya conexiones abiertas en modo WAL. Con la base cerrada, una copia es un cp. En caliente usa VACUUM INTO, la API de backup o sqlite3_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 (antes sqlite_scanner), que DuckDB descarga y carga sola la primera vez: ATTACH 'app.db' AS app (TYPE sqlite) y después SELECT * FROM app.users. La función sqlite_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 INSERT espera el timeout de la conexión (5 s por defecto en Python) y falla con database is locked. Lo hemos reproducido: con timeout=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-trixie es 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:

  1. SQLite: When to Use SQLite[6]
  2. DuckDB: Why DuckDB[7]
  3. Litestream: Streaming Replication for SQLite[5]
  4. sql.js: SQLite compiled to WebAssembly[4]
  5. SQLite: Release History[8]
  6. DuckDB: releases en GitHub[9]
  7. SQLite: Download Page[10]
  8. SQLite: Most Widely Deployed SQL Database Engine[11]
  9. SQLite: Write-Ahead Logging[12]
  10. SQLite: PRAGMA synchronous[13]
  11. SQLite: How To Corrupt An SQLite Database File[14]
  12. Debian: registro de cambios de sqlite3 3.46.1 en trixie[15]
  13. Python: módulo sqlite3[16]
  14. DuckDB: Concurrency[17]
  15. DuckDB: SQLite Extension[18]
  16. DuckDB: Python API[19]
  17. DuckDB: Storage[20]
  18. DuckDB: INSERT Statements[21]
  19. 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

  1. SQLite
  2. DuckDB
  3. compilación WebAssembly oficial
  4. sql.js
  5. Litestream
  6. SQLite: When to Use SQLite
  7. DuckDB: Why DuckDB
  8. SQLite: Release History
  9. DuckDB: releases en GitHub
  10. SQLite: Download Page
  11. SQLite: Most Widely Deployed SQL Database Engine
  12. SQLite: Write-Ahead Logging
  13. SQLite: PRAGMA synchronous
  14. SQLite: How To Corrupt An SQLite Database File
  15. Debian: registro de cambios de sqlite3 3.46.1 en trixie
  16. Python: módulo sqlite3
  17. DuckDB: Concurrency
  18. DuckDB: SQLite Extension
  19. DuckDB: Python API
  20. DuckDB: Storage
  21. DuckDB: INSERT Statements
  22. DuckDB: Quack Remote Protocol