sqlite3.OperationalError: database is locked: solución

Varios procesos escriben en el mismo archivo de SQLite (por ejemplo, un ciclo de ingesta continuo y trabajos de reprocesado o de escaneo) y alguno falla con:

Traceback (most recent call last):
  File "ingesta.py", line 42, in guardar
    con.execute("INSERT INTO eventos(valor) VALUES (?)", (valor,))
sqlite3.OperationalError: database is locked

Lo peor es el efecto silencioso: un trabajo lanzado a mano y olvidado puede competir con el ciclo programado y dejar la canalización parada durante horas, sin más rastro que este error en un registro. Este artículo explica por qué ocurre, qué configurar en SQLite y cómo encontrar al proceso que estorba.

Por qué pasa

SQLite admite un solo escritor a la vez. La documentación de los códigos de resultado lo describe así: si el proceso A está en mitad de una transacción de escritura y el proceso B intenta iniciar otra, B recibe SQLITE_BUSY y debe esperar a que A termine. El módulo sqlite3 de Python lo muestra como OperationalError: database is locked.

En el modo de diario por defecto (rollback journal), además, lectores y escritores se estorban: varios procesos pueden leer a la vez (bloqueo SHARED), pero para confirmar una escritura hace falta un bloqueo EXCLUSIVE, con el que no puede coexistir ningún otro bloqueo. Un escritor puede tener que esperar a que terminen los lectores, y mientras confirma bloquea las lecturas nuevas.

La espera depende de dos cosas:

  • SQLite, por defecto, no tiene ningún manejador de ocupado (el valor por defecto es NULL), así que devolvería el error al instante.
  • Python lo compensa con el parámetro timeout de sqlite3.connect(), cuyo valor por defecto son 5 segundos. Pasado ese tiempo, lanza el error.

Cualquier transacción que retenga el bloqueo más de esos 5 segundos (un proceso que lee, procesa durante minutos y escribe, o uno que quedó colgado con una transacción abierta) provoca el error en los demás.

Solución

  1. Activa el modo WAL una sola vez. La documentación de SQLite explica que, con WAL, los lectores no bloquean a los escritores ni al revés, y que el modo es persistente: una vez fijado, la base sigue en WAL aunque se cierre y se reabra.

    import sqlite3
    
    con = sqlite3.connect("datos.db")
    print(con.execute("PRAGMA journal_mode=WAL").fetchone())  # ('wal',)
    con.close()

    WAL no elimina la regla de un solo escritor: según la documentación, solo puede haber un escritor a la vez. Por eso el paso siguiente sigue siendo necesario. Conviene ejecutar este paso con los demás procesos detenidos o, al menos, sin transacciones activas.

  2. Define cuánto debe esperar cada conexión. La espera es por conexión y no se hereda: debes fijarla en cada proceso. Con Python, el parámetro timeout (en segundos) lo hace; también puedes usar el pragma, en milisegundos:

    def abrir(ruta: str) -> sqlite3.Connection:
        con = sqlite3.connect(ruta, timeout=30, isolation_level=None)  # transacciones explícitas
        con.execute("PRAGMA busy_timeout=30000")  # equivale a timeout=30
        return con

    Una conexión solo puede tener un manejador de ocupado: busy_timeout reemplaza cualquier otro definido antes.

  3. Haz transacciones de escritura cortas y con BEGIN IMMEDIATE. Prepara los datos antes (lecturas, análisis, llamadas externas) y abre la transacción solo para escribir:

    con = abrir("datos.db")
    lote = [(v,) for v in valores]   # preparado antes de bloquear nada
    
    con.execute("BEGIN IMMEDIATE")
    try:
        con.executemany("INSERT INTO eventos(valor) VALUES (?)", lote)
        con.execute("COMMIT")
    except Exception:
        con.execute("ROLLBACK")
        raise

    La documentación de SQLite recomienda BEGIN IMMEDIATE para no encontrar SQLITE_BUSY a mitad de una transacción: puede fallar al iniciar, pero si tiene éxito, ninguna operación posterior hasta el COMMIT devolverá ese error. Hay un motivo más: si una transacción lee primero y luego escribe, y otra conexión escribió entremedias, SQLite devuelve el error de inmediato aunque el timeout sea de 30 segundos, porque esperar causaría un bloqueo mutuo. En WAL ese caso es SQLITE_BUSY_SNAPSHOT.

  4. Reduce el número de escritores. Si varios procesos solo producen datos, que envíen sus filas por una cola a un único proceso escritor. Las lecturas pueden seguir en paralelo.

  5. Encuentra procesos olvidados y evita duplicados. En Linux, ps -ef --forest muestra el árbol de procesos con su PID y su PPID (el proceso padre); un trabajo huérfano suele colgar de PID 1. Para saber quién tiene abierta la base:

    ps -ef --forest | grep -E 'ingesta|reprocesar|escaneo' | grep -v grep
    fuser datos.db
    lsof datos.db

    Detén el que sobra con kill <PID>. Para que no vuelva a pasar, ejecuta los trabajos largos como servicios (por ejemplo, con systemd) y protege los lanzamientos manuales con flock -n /tmp/escaneo.lock python escaneo.py, que no arranca si ya hay otra instancia con el mismo archivo de bloqueo.

Ten en cuenta las restricciones de WAL que indica la documentación: todos los procesos deben estar en el mismo equipo, WAL no funciona sobre un sistema de archivos de red, y el archivo -wal forma parte del estado persistente de la base. Si copias o mueves datos.db, hazlo junto con su -wal; sin él podrías perder transacciones ya confirmadas. Además, si siempre hay al menos un lector activo, los puntos de control no pueden completarse y el archivo -wal crece sin límite.

Cómo verificar que quedó resuelto

con = abrir("datos.db")
print(con.execute("PRAGMA journal_mode").fetchone()[0])  # wal
print(con.execute("PRAGMA busy_timeout").fetchone()[0])  # 30000

Después simula la concurrencia con varios procesos que escriben a la vez:

import multiprocessing as mp

def escribir(n):
    con = abrir("datos.db")
    for i in range(200):
        con.execute("BEGIN IMMEDIATE")
        con.execute("INSERT INTO eventos(valor) VALUES (?)", (f"{n}-{i}",))
        con.execute("COMMIT")
    con.close()

if __name__ == "__main__":
    with mp.Pool(4) as pool:
        pool.map(escribir, range(4))

Debe terminar sin OperationalError y añadir 800 filas. En producción, comprueba que el registro de tus procesos no vuelve a mostrar database is locked tras una noche de trabajo y que tu contador de elementos procesados sigue creciendo:

sqlite3 datos.db "SELECT count(*) FROM procesados;"
sleep 60
sqlite3 datos.db "SELECT count(*) FROM procesados;"

Si el segundo número es mayor que el primero, el proceso avanza. Por último, ps -ef --forest no debe mostrar más de una instancia de cada trabajo.

Variantes y errores relacionados

database table is locked

Es otro código: SQLITE_LOCKED indica un conflicto dentro de la misma conexión (o con una conexión que comparte caché), no entre procesos. Se reproduce, por ejemplo, ejecutando DROP TABLE mientras un cursor de la misma conexión aún lee esa tabla. Cerrar o terminar de leer el cursor lo resuelve; busy_timeout no ayuda.

ProgrammingError: SQLite objects created in a thread can only be used in that same thread

Aparece si compartes una conexión entre hilos. Abre una conexión por hilo o por proceso, que además encaja con la configuración por conexión de busy_timeout.

La base está en un volumen de red

WAL no funciona sobre un sistema de archivos de red. Si varios servidores deben escribir en la misma base, SQLite deja de ser la herramienta adecuada y conviene considerar un motor cliente-servidor.

Transacciones implícitas del módulo sqlite3

Con el control de transacciones heredado (el valor por defecto), el módulo abre una transacción implícita antes de INSERT, UPDATE, DELETE o REPLACE, de tipo DEFERRED salvo que indiques otra, y with con: confirma al salir. Si prefieres ese estilo, pasa isolation_level="IMMEDIATE" a connect() para que esa transacción implícita sea IMMEDIATE. Los ejemplos de este artículo usan isolation_level=None y BEGIN/COMMIT explícitos para que el momento del bloqueo sea evidente. Desde Python 3.12 existe además el atributo autocommit.

Resumen

  • SQLite permite un solo escritor a la vez; el segundo recibe SQLITE_BUSY, que Python muestra como database is locked tras agotar su timeout por defecto de 5 segundos.
  • Activa PRAGMA journal_mode=WAL una vez (es persistente) y fija la espera en cada conexión con timeout=30 o PRAGMA busy_timeout=30000.
  • Usa transacciones cortas con BEGIN IMMEDIATE y prepara los datos antes de abrirlas.
  • Busca procesos olvidados con ps -ef --forest, fuser o lsof y evita duplicados con servicios o flock.
  • WAL exige que todos los procesos estén en el mismo equipo y no funciona sobre sistemas de archivos de red.

Referencias

Comentarios