SQLITE EN APPS DE ESCRITORIO: LA BASE DE DATOS QUE NO NECESITA SERVIDOR
Toda app de escritorio necesita que sus datos datos sean persistentes. El instinto del desarrollador web es levantar PostgreSQL o MySQL. Para una app de escritorio personal eso es matar una mosca con un cañón: requiere un servidor corriendo, configuración de usuarios, manejo de conexiones, y complejidad que no aporta nada en un contexto de un solo usuario. SQLite es la alternativa correcta — un solo archivo en disco, cero configuración, cero servidor, cero mantenimiento.
Stack: Python · SQLite · JSON
Proyecto de referencia: FocOs — persistencia de datos
Objetivo: Persistencia robusta sin servidor — un archivo en disco, años de desarrollo sin tocar el schema
01. EL PROBLEMA QUE RESUELVE
PostgreSQL y MySQL están diseñados para múltiples usuarios concurrentes, alta disponibilidad y cargas de trabajo distribuidas. En una app de escritorio personal, ninguna de esas características aplica — y sin embargo arrastras toda la infraestructura. SQLite resuelve exactamente el problema que tienes: persistencia confiable para un solo usuario, sin proceso externo, sin configuración, sin que el usuario final sepa que existe una base de datos.
// Explicación no técnica
Imagina que necesitas guardar una lista de compras. Tienes dos opciones: contratar a un chef profesional con cocina industrial para que la guarde, o simplemente escribirla en un cuaderno. PostgreSQL es el chef. SQLite es el cuaderno. Para guardar tu lista de compras, el cuaderno es la respuesta correcta — y lo puedes llevar en el bolsillo.
02. LA ESTRUCTURA DE FOCOS EN SQLITE
FocOs usa cuatro tablas principales. Los dos PRAGMAs del inicio son obligatorios: WAL mejora el rendimiento de escritura significativamente, y foreign_keys=ON activa la integridad referencial que SQLite tiene desactivada por defecto.
import sqlite3
from pathlib import Path
DB_PATH = Path('data/focos.db')
def init_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL") # Escrituras más rápidas
conn.execute("PRAGMA foreign_keys=ON") # Integridad referencial
conn.executescript("""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
status TEXT DEFAULT 'active',
meta TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER REFERENCES projects(id),
title TEXT NOT NULL,
status TEXT DEFAULT 'pending',
priority INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS workspaces (
ws_id INTEGER PRIMARY KEY,
project_id INTEGER REFERENCES projects(id),
layout TEXT DEFAULT 'default',
state TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER REFERENCES projects(id),
date TEXT DEFAULT (date('now')),
duration INTEGER DEFAULT 0,
notes TEXT DEFAULT ''
);
INSERT OR IGNORE INTO workspaces(ws_id) VALUES (1),(2),(3);
""")
conn.commit()
conn.close()
03. CRUD COMPLETO — SIN ORM, SIN MAGIA
El patrón es consistente en todas las operaciones: abrir conexión, ejecutar, commit si escribe, cerrar en el finally. Sin ORM, sin abstracción innecesaria — el SQL es visible, debuggeable y predecible. El soft delete en lugar de DELETE real preserva el historial y evita errores irreversibles.
def create_project(self, name: str, status: str = 'active') -> dict:
conn = sqlite3.connect(DB_PATH)
try:
cur = conn.execute(
"INSERT INTO projects(name, status) VALUES(?,?)",
(name, status)
)
conn.commit()
return { 'ok': True, 'id': cur.lastrowid }
except sqlite3.IntegrityError:
return { 'ok': False, 'error': f"Proyecto '{name}' ya existe" }
finally:
conn.close()
# READ
def get_projects(self) -> list:
conn = sqlite3.connect(DB_PATH)
rows = conn.execute(
"SELECT id, name, status, meta FROM projects ORDER BY id DESC"
).fetchall()
conn.close()
return [
{ 'id': r[0], 'name': r[1], 'status': r[2],
'meta': json.loads(r[3] or '{}') }
for r in rows
]
# UPDATE
def update_project_status(self, project_id: int, status: str) -> dict:
conn = sqlite3.connect(DB_PATH)
conn.execute(
"UPDATE projects SET status=? WHERE id=?",
(status, project_id)
)
conn.commit()
conn.close()
return { 'ok': True }
# DELETE (soft delete — cambiar status, no borrar)
def archive_project(self, project_id: int) -> dict:
return self.update_project_status(project_id, 'archived')
04. JSON DENTRO DE SQLITE — EL CAMPO META
SQLite no tiene tipo JSON nativo — se almacena como TEXT y se serializa/deserializa manualmente. La ventaja es que el campo meta puede crecer indefinidamente sin alterar el schema. La regla es clara: datos que necesitan búsqueda van en columnas dedicadas, datos flexibles van en meta.
# Guardar metadata compleja en un solo campo
meta = {
'tipo': 'software',
'stack': ['Python', 'JavaScript'],
'filosofia': 'El foco es la función principal',
'porcentaje': 85,
}
conn.execute(
"UPDATE projects SET meta=? WHERE id=?",
(json.dumps(meta, ensure_ascii=False), project_id)
)
# Leer y deserializar
row = conn.execute(
"SELECT meta FROM projects WHERE id=?", (project_id,)
).fetchone()
meta = json.loads(row[0]) if row else {}
print(meta['porcentaje']) # 85
| Tipo de dato | Donde va | Razon |
|---|---|---|
| ID, status, fechas, foreign keys | Columna dedicada | Se necesita en WHERE, ORDER BY o JOIN. |
| Stack, filosofía, porcentaje, config | Campo meta (JSON) | Solo se lee, no se filtra. Puede crecer sin migración. |
| Datos que evolucionan rápido | Campo meta (JSON) | Agregar campos sin ALTER TABLE ni migración. |
05. BACKUP AUTOMÁTICO — UNA LÍNEA
SQLite es un archivo. Hacer backup es copiar ese archivo. Sin dumps, sin exports, sin herramientas externas. La estrategia en FocOs es llamar a backup_db() al inicio de cada sesión de trabajo — si algo se corrompe, el backup del día está disponible de forma inmediata.
from datetime import datetime
def backup_db(self) -> dict:
fecha = datetime.now().strftime('%Y-%m-%d')
backup = DB_PATH.parent / f'focos_backup_{fecha}.db'
shutil.copy2(DB_PATH, backup)
return { 'ok': True, 'path': str(backup) }
# Llamar al inicio de cada sesión:
# backup_db() — si algo se corrompe, el backup del día está ahí
-- CONCLUSION
SQLite con el patrón meta JSON elimina la necesidad de migraciones de schema en proyectos que evolucionan rápido. Los campos estructurados van en columnas dedicadas para búsqueda rápida. Los datos flexibles van en el campo meta sin migración. Para una app de escritorio de un solo usuario, este enfoque es suficiente para años de desarrollo sin tocar la estructura de la base de datos.
> SYSTEM_READY > NODE_ONLINE