⏱️ Lectura: 15 min

Una consulta de búsqueda de texto completo en SQLite tardaba 5 segundos sobre una tabla de apenas 4.000 filas. Después de ejecutar un solo comando (ANALYZE), la misma consulta bajó a 0.05 segundos: cien veces más rápida, sin tocar una sola línea de SQL.

📑 En este artículo
  1. TL;DR
  2. Qué es y por qué importa correr SQLite en producción
  3. Cómo funciona el modo WAL por dentro
    1. Diagrama: lector y escritor en paralelo
  4. ANALYZE: el comando que le faltaba a tu SQLite en producción
  5. Ejemplos prácticos: de la consulta lenta a la consulta rápida
  6. Cómo empezar: SQLite en producción paso a paso
    1. 1. Instalar el CLI de sqlite3
    2. 2. Activar WAL y fijar un busy_timeout
    3. 3. Correr ANALYZE después de cargar datos reales
    4. 4. Programar un backup
  7. Backups: restic vs Litestream
  8. El patrón de múltiples bases de datos
  9. Casos de uso reales
  10. Errores comunes y cómo evitarlos
  11. SQLite en producción vs Postgres: cuándo migrar
  12. Profundizando: qué hace ANALYZE por dentro
  13. Preguntas frecuentes
    1. ¿Es seguro usar SQLite en producción para un sitio con tráfico real?
    2. ¿Con qué frecuencia hay que correr ANALYZE?
    3. ¿WAL reemplaza la necesidad de hacer backups?
    4. ¿Restic o Litestream, cuál conviene empezar a usar primero?
    5. ¿Cuándo conviene dividir una base SQLite en varios archivos?
    6. ¿SQLite en producción sirve para aplicaciones con Django?
  14. Referencias

Ese hallazgo, documentado por la desarrolladora Julia Evans mientras operaba un sitio en Django con SQLite, resume algo que muchos desarrolladores subestiman: correr SQLite en producción sigue siendo administrar una base de datos real, con sus reglas de bloqueo, su mantenimiento y sus propias formas de fallar. Esta guía repasa qué hay que saber para operarla en serio: desde el modo WAL hasta los backups que de verdad sirven para restaurar.

TL;DR

  • Vas a entender por qué ANALYZE puede bajar una consulta de 5 segundos a 0.05 sin cambiar una línea de SQL.
  • Vas a saber activar el modo WAL para que lecturas y escrituras no se bloqueen entre sí.
  • Vas a poder diseñar un DELETE por lotes que no tumbe a los demás workers de tu aplicación.
  • Vas a comparar restic y Litestream para elegir cómo respaldar tu base SQLite.
  • Vas a aprender el patrón de dividir una base en varios archivos .db para aislar cargas de trabajo.
  • Vas a identificar con qué señales conviene migrar de SQLite a Postgres.

Qué es y por qué importa correr SQLite en producción

SQLite no necesita un servidor aparte: el motor vive dentro del propio proceso de tu aplicación y los datos quedan en un archivo .db en disco. Esa simplicidad es la razón por la que frameworks como Django lo traen configurado por defecto y por la que cada vez más sitios pequeños y medianos lo eligen en lugar de levantar un contenedor de Postgres.

El problema es que esa misma simplicidad esconde la complejidad real: no hay un administrador de base de datos vigilando índices, ni un proceso separado que absorba bloqueos raros. Cuando algo anda lento o se traba, es responsabilidad exclusiva de quien escribió la aplicación. Operar SQLite en producción en serio implica conocer al menos tres cosas: cómo evitar que lecturas y escrituras se bloqueen entre sí, cómo mantener actualizadas las estadísticas que usa el planificador de consultas, y cómo respaldar el archivo sin corromperlo.

Cómo funciona el modo WAL por dentro

Por defecto, SQLite usa un modo de journaling por rollback: cuando alguien escribe, toma un bloqueo exclusivo sobre todo el archivo y los lectores tienen que esperar. Para un sitio con tráfico real, eso significa que una escritura lenta bloquea todas las lecturas simultáneas.

El modo Write-Ahead Logging (WAL) cambia esa dinámica. En vez de escribir directamente sobre el archivo principal, los cambios se agregan al final de un archivo -wal aparte. Los lectores siguen leyendo del archivo principal (y del WAL si hace falta) mientras el escritor agrega registros, así que las lecturas casi nunca se bloquean por una escritura en curso. Periódicamente ocurre un checkpoint que vuelca el contenido del WAL de regreso al archivo principal.

-- Activar WAL una sola vez por base de datos
PRAGMA journal_mode=WAL;
-- Verificar que quedó activo
PRAGMA journal_mode;
-- Debería responder: wal

Ese primer bloque es el hola mundo: dos líneas de SQL que cambian el comportamiento de bloqueo de toda la base. El segundo comando confirma que el cambio se aplicó, algo que conviene chequear después de cada deploy en un entorno nuevo, porque journal_mode se guarda en el propio archivo .db y no en la configuración de la aplicación.

Diagrama conceptual del modo WAL en SQLite en producción
En WAL, lecturas y escrituras usan archivos separados hasta el checkpoint. Foto de Emile Perron en Unsplash

En Django, WAL se activa agregando una opción en DATABASES:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "calendar.db",
        "OPTIONS": {
            "init_command": (
                "PRAGMA journal_mode=WAL; "
                "PRAGMA busy_timeout=5000;"
            ),
        },
    }
}

Este segundo ejemplo ya es más realista: además de WAL, define busy_timeout en 5.000 milisegundos. Ese valor le dice a SQLite cuánto esperar antes de devolver un error database is locked cuando otro proceso está escribiendo. Es exactamente el número que, como se ve más abajo, puede convertirse en un problema si un DELETE tarda más de esos 5 segundos.

💭 Clave: WAL resuelve el bloqueo entre lectores y el escritor, pero sigue existiendo un solo escritor a la vez. SQLite nunca deja de ser single-writer, ni con WAL activado.

Diagrama: lector y escritor en paralelo

sequenceDiagram
    participant W as Escritor
    participant WAL as Archivo WAL
    participant R as Lector
    participant DB as Base .db
    W->>WAL: agrega cambios
    R->>DB: lee datos confirmados
    R->>WAL: lee cambios recientes
    Note over W,DB: escritor y lector no se bloquean
    WAL->>DB: checkpoint vuelca cambios

ANALYZE: el comando que le faltaba a tu SQLite en producción

El planificador de consultas de SQLite decide, para cada SELECT, qué índice usar y en qué orden recorrer las tablas. Sin información sobre cuántas filas tiene cada tabla y cómo se distribuyen los valores, el planificador adivina. Y a veces adivina mal, sobre todo en consultas con FTS5 (el módulo de búsqueda de texto completo) que combinan varias condiciones.

El comando ANALYZE recorre las tablas e índices y guarda estadísticas (cantidad de filas, distribución de valores) en una tabla interna llamada sqlite_stat1. Con esos datos, el planificador deja de adivinar y elige el camino más corto de verdad.

ANALYZE;
-- Verificar que generó estadísticas
SELECT * FROM sqlite_stat1 LIMIT 5;

Ese SELECT final es la forma de confirmar que ANALYZE hizo su trabajo: si la tabla sqlite_stat1 tiene filas, el planificador ya cuenta con estadísticas reales. Si está vacía, seguís operando a ciegas.

Ejemplos prácticos: de la consulta lenta a la consulta rápida

El caso típico (el mismo que describe Julia Evans en su blog) es una tabla con búsqueda FTS5 que empieza a comportarse mal a medida que crece. El síntoma no es un error: es que una consulta que antes tardaba milisegundos empieza a tardar segundos completos, sin que nadie haya tocado el código.

import sqlite3

conn = sqlite3.connect("calendar.db")
cur = conn.cursor()

cur.execute("""
    EXPLAIN QUERY PLAN
    SELECT eventos.titulo
    FROM eventos_fts
    JOIN eventos ON eventos.id = eventos_fts.rowid
    WHERE eventos_fts MATCH ?
""", ("reunion",))

for fila in cur.fetchall():
    print(fila)

Antes de correr ANALYZE, el resultado de EXPLAIN QUERY PLAN suele mostrar un escaneo completo de la tabla auxiliar, señal de que el planificador no tiene por dónde guiarse. Después de correr ANALYZE sobre la misma base, el plan cambia a un uso directo del índice FTS5, y el tiempo de respuesta cae en el mismo orden de magnitud que reportó Julia Evans: de segundos a centésimas de segundo.

💡 Tip: Desde SQLite 3.18 existe PRAGMA optimize;, una versión liviana de ANALYZE pensada para ejecutarse al cerrar cada conexión. Llamarla en el close() de tu pool evita que las estadísticas queden desactualizadas sin necesidad de un cron aparte.

Cómo empezar: SQLite en producción paso a paso

Estos son los pasos mínimos para poner una base SQLite en producción en condiciones razonables, con el CLI instalado en cualquiera de los tres sistemas operativos principales.

1. Instalar el CLI de sqlite3

# Windows (con winget)
winget install SQLite.SQLite

# macOS (con Homebrew)
brew install sqlite3

# Linux (Debian/Ubuntu)
sudo apt install sqlite3

2. Activar WAL y fijar un busy_timeout

sqlite3 calendar.db "PRAGMA journal_mode=WAL;"
sqlite3 calendar.db "PRAGMA busy_timeout=5000;"

3. Correr ANALYZE después de cargar datos reales

sqlite3 calendar.db "ANALYZE;"

4. Programar un backup

Esto se desarrolla en la siguiente sección, pero el paso mínimo es tener algún mecanismo corriendo antes de que la base crezca, no después.

Backups: restic vs Litestream

Hacer cp del archivo .db mientras la aplicación escribe es la forma más rápida de terminar con un backup corrupto. SQLite ofrece VACUUM INTO como forma segura de generar una copia consistente sin detener el proceso:

sqlite3 /data/calendar.db "VACUUM INTO '/tmp/calendar_backup.db'"
gzip /tmp/calendar_backup.db

A partir de ahí hay dos caminos habituales para subir esa copia a almacenamiento externo: snapshots periódicos con restic, o replicación continua con Litestream.

HerramientaCuándo usarlaVentajaLimitación
resticBackups periódicos (cada hora o cada día) de una copia generada con VACUUM INTODeduplicación y cifrado listos para usar; snapshots fáciles de listar y podarCada corrida procesa el archivo completo comprimido, y puede quedarse sin memoria en bases grandes
LitestreamReplicación continua casi en tiempo real, minimizando pérdida de datos ante una caídaSube solo los cambios del WAL, no la base entera, en cada cicloRequiere un proceso corriendo todo el tiempo junto a la aplicación

Litestream se configura con un archivo YAML y un solo comando:

litestream replicate -config litestream.yml

Dentro de litestream.yml conviene fijar cuánto historial retener, por ejemplo retention: 400h para guardar poco más de dos semanas y media de cambios incrementales.

Pipeline de backup de SQLite en producción con Litestream hacia S3
Litestream sube solo los cambios del WAL, no la base completa. Foto de Ilya Pavlov en Unsplash
flowchart TD
    A["App Django"] --> B["calendar.db local"]
    B --> C["litestream replicate"]
    C --> D[("Bucket S3")]
    subgraph Backup continuo
    C
    D
    end
⚠️ Ojo: Un backup que nunca se probó restaurar no es un backup, es una promesa. Programá al menos una restauración de prueba antes de confiar en el mecanismo elegido.

El patrón de múltiples bases de datos

Una técnica poco conocida para aliviar contención en SQLite es dividir los datos en varios archivos .db en vez de meter todo en uno solo. Si dos grupos de tablas no necesitan transacciones conjuntas, separarlos en bases distintas reduce cuánto tiempo un escritor bloquea al resto.

Es el mismo patrón que usa el proyecto Mess With DNS, que corre sobre SQLite desde 2022 dividiendo sus tablas en tres archivos separados en lugar de uno solo. Cuatro años después, la base sigue siendo SQLite, y la migración desde Postgres se describe como una decisión que valió la pena.

Casos de uso reales

SQLite en producción funciona bien para: sitios con bajo o moderado volumen de escrituras concurrentes, herramientas internas de equipo, APIs de lectura intensiva con cache, y proyectos personales que necesitan durar años con mantenimiento mínimo. El caso de Mess With DNS (cuatro años en producción sin volver a Postgres) es el ejemplo de que, con las prácticas correctas, SQLite aguanta cargas reales y sostenidas.

Errores comunes y cómo evitarlos

  • DELETE masivo que tumba workers: borrar miles de filas de una sola vez puede tardar más que el busy_timeout configurado (por ejemplo, 5 segundos). Si otro proceso intenta escribir mientras tanto, agota el timeout, falla, y en algunos entornos ese fallo hace que el proceso completo se reinicie. La solución es borrar en lotes pequeños, con un LIMIT y un bucle, para que cada transacción individual termine rápido.
  • Backups que se quedan bloqueados: un proceso de backup que muere sin liberar su lock (por ejemplo, por quedarse sin memoria) puede dejar el repositorio de restic marcado como bloqueado para la próxima corrida. Antes de cada backup conviene forzar el desbloqueo si el proceso anterior no terminó limpio.
  • Confiar en la ORM sin mirar el plan de consultas: usar Django ORM (o cualquier ORM) sin revisar nunca EXPLAIN QUERY PLAN funciona mientras la tabla es chica. El problema aparece de golpe cuando crece, como pasó con la consulta FTS5 de 4.000 filas.
  • Olvidar que WAL necesita checkpoint: si la aplicación nunca cierra conexiones o el checkpoint automático está deshabilitado, el archivo -wal puede crecer sin límite. Ejecutar PRAGMA wal_checkpoint(TRUNCATE); de forma periódica evita ese crecimiento descontrolado.

SQLite en producción vs Postgres: cuándo migrar

EscenarioSQLite en producciónPostgres
Escrituras concurrentes de varios procesosLimitado: un solo escritor a la vez, incluso con WALMúltiples escritores simultáneos sin bloqueo global
Operación y despliegueUn archivo, sin servidor aparte que administrarRequiere un servicio corriendo, usuarios y conexiones
Mantenimiento de rendimientoManual: ANALYZE, PRAGMA optimize, checkpointsAutovacuum y planificador con estadísticas automáticas
Volumen de datosCómodo hasta decenas de miles de filas por tabla en la mayoría de los casosEscala a volúmenes mucho mayores sin rediseño

La señal más clara para migrar de SQLite en producción a Postgres no es el tamaño de los datos, sino la cantidad de procesos que necesitan escribir al mismo tiempo. Si tu aplicación corre con un solo worker, SQLite probablemente alcanza. Si necesitás varios workers escribiendo en paralelo de forma constante, el modelo de un solo escritor se vuelve el cuello de botella.

Profundizando: qué hace ANALYZE por dentro

La tabla sqlite_stat1 que genera ANALYZE guarda, por cada índice, el número aproximado de filas y cuántas filas distintas hay por cada prefijo de columnas indexadas. Con esos números, el optimizador puede estimar cuántas filas devolverá cada rama de un JOIN antes de ejecutarlo, y elegir el orden que menos filas intermedias genere.

Sin esas estadísticas, SQLite usa heurísticas genéricas que asumen una distribución uniforme de datos. Para índices simples eso suele bastar. Para consultas FTS5 que combinan un MATCH con un JOIN, la heurística genérica puede llevar al planificador a recorrer estructuras auxiliares completas en vez de usar el índice invertido, que es exactamente el patrón que explica un salto de 5 segundos a 0.05.

Otra pieza avanzada útil es mmap_size, que le permite a SQLite mapear el archivo de base de datos directamente en memoria en vez de pasar por el sistema de I/O tradicional, reduciendo copias de datos en lecturas intensivas:

PRAGMA mmap_size = 268435456; -- 256 MB
-- Confirmar el valor activo
PRAGMA mmap_size;

📖 Resumen en Telegram: Ver resumen

Tu próximo paso: cargá tu base de desarrollo con datos realistas, corré EXPLAIN QUERY PLAN sobre tu consulta más lenta, ejecutá ANALYZE y compará el plan antes y después.

Preguntas frecuentes

¿Es seguro usar SQLite en producción para un sitio con tráfico real?

Sí, siempre que la carga de escrituras concurrentes sea moderada. El modo WAL, un busy_timeout razonable y backups probados cubren la mayoría de los casos de sitios pequeños y medianos.

¿Con qué frecuencia hay que correr ANALYZE?

Después de cambios grandes en el volumen de datos, o de forma periódica con PRAGMA optimize; en cada cierre de conexión, que aplica una versión liviana de ANALYZE sin necesidad de un cron dedicado.

¿WAL reemplaza la necesidad de hacer backups?

No. WAL resuelve bloqueos entre lectores y escritor, pero no protege contra un disco corrupto, un borrado accidental o la pérdida del servidor completo. Los backups siguen siendo obligatorios.

¿Restic o Litestream, cuál conviene empezar a usar primero?

Si la base es pequeña y un backup diario alcanza, restic es más simple de configurar. Si la aplicación no puede permitirse perder más de unos minutos de datos ante una caída, Litestream es la opción más segura.

¿Cuándo conviene dividir una base SQLite en varios archivos?

Cuando distintos grupos de tablas no necesitan transacciones conjuntas y una de esas cargas genera muchas escrituras que bloquearían innecesariamente al resto.

¿SQLite en producción sirve para aplicaciones con Django?

Sí, es el motor por defecto de Django y funciona bien en producción para sitios de tráfico moderado, siempre configurando WAL, busy_timeout y un plan de backups desde el principio.

Referencias

📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.

Imagen destacada: Foto de Mohammad Rahmani en Unsplash


Andrés Morales

Desarrollador e investigador en inteligencia artificial. Escribe sobre modelos de lenguaje, frameworks, herramientas para devs y lanzamientos open source. Cubre papers de ML, ecosistema de startups tech y tendencias de programación.

0 Comentarios

Deja un comentario

Marcador de posición del avatar

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.