Tutorial de Python SQLite3: Guía completa de la base de datos SQLite en Python
Updated on
Necesitas almacenar datos estructurados en tu aplicación Python. Quizá sean ajustes de usuario, lecturas de sensores, entradas de logs o una lista de tareas. Recurres a un archivo CSV, pero los datos crecen y las consultas se vuelven dolorosas. Consideras PostgreSQL o MySQL, pero configurar un servidor de base de datos para una herramienta local o un prototipo se siente como exceso. Solo necesitas una forma confiable de almacenar, consultar y actualizar datos estructurados sin sobrecarga.
Este es exactamente el problema que resuelve SQLite. SQLite es un motor de base de datos autocontenido y sin servidor que guarda todo en un solo archivo. Python incluye el módulo sqlite3 en la biblioteca estándar, así que no hay nada que instalar. Obtienes soporte completo de SQL, transacciones ACID y la capacidad de manejar conjuntos de datos de hasta 281 terabytes, todo sin ejecutar un proceso de servidor ni administrar archivos de configuración.
Esta guía recorre todo lo que necesitas para usar SQLite de forma efectiva en Python: desde crear tu primera base de datos hasta consultas avanzadas, integración con pandas, ajustes de rendimiento y un proyecto completo del mundo real.
¿Qué es SQLite y por qué usarlo?
SQLite es un motor de base de datos relacional embebido. A diferencia de PostgreSQL o MySQL, no se ejecuta como un proceso de servidor separado. La base de datos completa vive en un único archivo en disco (o en memoria). El módulo sqlite3 de la biblioteca estándar de Python ofrece una interfaz compatible con DB-API 2.0 para SQLite.
Características clave de SQLite:
- Cero configuración: Sin configuración de servidor, sin usuarios, sin permisos que gestionar
- Sin servidor: El motor de base de datos se ejecuta dentro del proceso de tu aplicación
- Archivo único: Toda la base de datos (tablas, índices, datos) es un solo archivo
.db - Multiplataforma: El archivo de base de datos funciona en cualquier SO sin conversión
- Compatible con ACID: Soporte completo de transacciones con commit y rollback
- Ligero: La librería pesa ~750 KB, más pequeña que la mayoría de imágenes
Cuándo usar SQLite
| Caso de uso | SQLite | PostgreSQL/MySQL |
|---|---|---|
| Apps de escritorio/móvil | Mejor opción | Excesivo |
| Prototipado | Mejor opción | Más lento de montar |
| Dispositivos embebidos / IoT | Mejor opción | No viable |
| Unit testing | Mejor opción | Requiere servidor de prueba |
| Scripts de análisis de datos | Buena opción | Sobrecarga innecesaria |
| Web app con < 100K visitas diarias | Funciona bien | Más escalable |
| Web app de alta concurrencia | No ideal | Mejor opción |
| Varios clientes con muchas escrituras | No ideal | Mejor opción |
SQLite cubre la mayoría de aplicaciones que no requieren múltiples conexiones de escritura simultáneas. Android e iOS usan SQLite como base de datos local por defecto. Firefox, Chrome y macOS lo usan internamente. Es el motor de base de datos más desplegado del mundo.
Conectarse a una base de datos
La función sqlite3.connect() crea una conexión a un archivo de base de datos. Si el archivo no existe, SQLite lo crea automáticamente.
import sqlite3
# Connect to a database file (creates it if it doesn't exist)
conn = sqlite3.connect("myapp.db")
# Create a cursor to execute SQL statements
cursor = conn.cursor()
# Always close the connection when done
conn.close()Bases de datos en memoria
Usa la cadena especial :memory: para crear una base de datos que existe solo en RAM. Es ideal para pruebas y procesamiento temporal de datos.
import sqlite3
conn = sqlite3.connect(":memory:")
cursor = conn.cursor()
cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)")
cursor.execute("INSERT INTO test VALUES (1, 'hello')")
cursor.execute("SELECT * FROM test")
print(cursor.fetchone()) # (1, 'hello')
conn.close() # Database is gone after thisUsar context managers
El patrón recomendado usa un context manager (sentencia with) para asegurar que la conexión se gestione correctamente incluso si ocurre un error. Las conexiones SQLite también hacen auto-commit si todo sale bien y auto-rollback si hay una excepción dentro del bloque with.
import sqlite3
# Connection as context manager handles commit/rollback
with sqlite3.connect("myapp.db") as conn:
cursor = conn.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
cursor.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
# Auto-commits if no exception
# Auto-rolls back if an exception occurs
# Note: the connection is NOT closed by 'with' -- it just commits/rolls back
# For full cleanup, close explicitly or use a wrapper:
conn.close()Una función helper limpia que maneja commit/rollback y también el cierre:
import sqlite3
from contextlib import contextmanager
@contextmanager
def get_db(db_path="myapp.db"):
"""Context manager that commits on success, rolls back on error, and always closes."""
conn = sqlite3.connect(db_path)
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
# Usage
with get_db() as conn:
conn.execute("INSERT INTO users (name) VALUES (?)", ("Bob",))Crear tablas
Usa CREATE TABLE para definir el esquema. SQLite soporta un sistema de tipos simplificado con cinco clases de almacenamiento.
Tipos de datos en SQLite
| Clase de almacenamiento | Descripción | Equivalente en Python |
|---|---|---|
NULL | Valor nulo | None |
INTEGER | Entero con signo (1, 2, 3, 4, 6 u 8 bytes) | int |
REAL | Punto flotante (float IEEE de 8 bytes) | float |
TEXT | Cadena UTF-8 o UTF-16 | str |
BLOB | Datos binarios, almacenados exactamente como se ingresan | bytes |
SQLite usa tipado dinámico. Puedes almacenar cualquier tipo en cualquier columna independientemente del tipo declarado. El tipo declarado es una pista, no una restricción. Esto es diferente a PostgreSQL y MySQL.
Crear una tabla con restricciones
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
department TEXT DEFAULT 'General',
salary REAL CHECK(salary > 0),
hire_date TEXT NOT NULL,
is_active INTEGER DEFAULT 1
)
""")
conn.commit()
conn.close()Restricciones clave:
PRIMARY KEY: Identificador único para cada fila.INTEGER PRIMARY KEYes un alias del rowid interno.AUTOINCREMENT: Garantiza que el rowid siempre aumente (nunca reutiliza IDs borrados).NOT NULL: La columna no puede contener valores NULL.UNIQUE: No puede haber dos filas con el mismo valor en esta columna.DEFAULT: Valor por defecto si no se proporciona ninguno durante el INSERT.CHECK: Valida datos contra una condición.
Crear múltiples tablas relacionadas
import sqlite3
conn = sqlite3.connect("school.db")
cursor = conn.cursor()
# Enable foreign key enforcement (off by default in SQLite)
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("""
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
credits INTEGER DEFAULT 3
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS enrollments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
grade TEXT,
enrolled_date TEXT DEFAULT CURRENT_DATE,
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE,
UNIQUE(student_id, course_id)
)
""")
conn.commit()
conn.close()Operaciones CRUD
CRUD significa Create, Read, Update, Delete: las cuatro operaciones básicas del almacenamiento persistente.
INSERT: agregar datos
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
# Insert a single row
cursor.execute(
"INSERT INTO employees (name, email, department, salary, hire_date) VALUES (?, ?, ?, ?, ?)",
("Alice Johnson", "alice@company.com", "Engineering", 95000, "2024-01-15")
)
# Insert multiple rows with executemany
employees = [
("Bob Smith", "bob@company.com", "Marketing", 72000, "2024-03-01"),
("Carol White", "carol@company.com", "Engineering", 98000, "2023-11-20"),
("David Brown", "david@company.com", "Sales", 68000, "2024-06-10"),
("Eve Davis", "eve@company.com", "Engineering", 105000, "2023-08-05"),
]
cursor.executemany(
"INSERT INTO employees (name, email, department, salary, hire_date) VALUES (?, ?, ?, ?, ?)",
employees
)
# Get the last inserted row ID
print(f"Last inserted ID: {cursor.lastrowid}")
# Get the number of rows affected
print(f"Rows inserted: {cursor.rowcount}")
conn.commit()
conn.close()INSERT OR REPLACE (Upsert)
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
# Insert or update if email already exists
cursor.execute("""
INSERT INTO employees (name, email, department, salary, hire_date)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(email) DO UPDATE SET
name = excluded.name,
salary = excluded.salary
""", ("Alice Johnson", "alice@company.com", "Engineering", 100000, "2024-01-15"))
conn.commit()
conn.close()SELECT: leer datos
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
# Select all rows
cursor.execute("SELECT * FROM employees")
all_rows = cursor.fetchall()
for row in all_rows:
print(row) # Returns tuples by default
# Select specific columns with a condition
cursor.execute(
"SELECT name, salary FROM employees WHERE department = ?",
("Engineering",)
)
engineers = cursor.fetchall()
for name, salary in engineers:
print(f"{name}: ${salary:,.0f}")
# Select with ordering and limit
cursor.execute("""
SELECT name, salary FROM employees
ORDER BY salary DESC
LIMIT 3
""")
top_earners = cursor.fetchall()
print("Top 3 earners:", top_earners)
conn.close()Obtener resultados como diccionarios
Por defecto, fetchall() devuelve tuplas. Para obtener diccionarios (nombre de columna como clave), usa sqlite3.Row:
import sqlite3
conn = sqlite3.connect("myapp.db")
conn.row_factory = sqlite3.Row # Set row factory
cursor = conn.cursor()
cursor.execute("SELECT * FROM employees WHERE department = ?", ("Engineering",))
for row in cursor.fetchall():
print(dict(row)) # Convert Row to dict
print(row["name"], row["salary"]) # Access by column name
conn.close()UPDATE: modificar datos
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
# Update a single field
cursor.execute(
"UPDATE employees SET salary = ? WHERE email = ?",
(110000, "alice@company.com")
)
print(f"Rows updated: {cursor.rowcount}")
# Update with a calculation
cursor.execute("""
UPDATE employees
SET salary = salary * 1.10
WHERE department = 'Engineering' AND salary < 100000
""")
print(f"Engineers with raises: {cursor.rowcount}")
# Conditional update
cursor.execute("""
UPDATE employees
SET is_active = 0
WHERE hire_date < '2023-01-01'
""")
conn.commit()
conn.close()DELETE: eliminar datos
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
# Delete specific rows
cursor.execute("DELETE FROM employees WHERE is_active = 0")
print(f"Rows deleted: {cursor.rowcount}")
# Delete with a subquery
cursor.execute("""
DELETE FROM enrollments
WHERE student_id NOT IN (SELECT id FROM students)
""")
# Delete all rows (truncate equivalent)
cursor.execute("DELETE FROM employees")
conn.commit()
conn.close()Consultas parametrizadas y prevención de SQL injection
Nunca construyas consultas SQL con formateo de strings o f-strings. Usa siempre consultas parametrizadas para prevenir ataques de SQL injection.
El problema: SQL injection
import sqlite3
# DANGEROUS: Never do this
user_input = "'; DROP TABLE employees; --"
query = f"SELECT * FROM employees WHERE name = '{user_input}'"
# This would execute: SELECT * FROM employees WHERE name = ''; DROP TABLE employees; --'La solución: consultas parametrizadas
SQLite3 soporta dos estilos de placeholders:
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
# Style 1: Question mark placeholders (positional)
cursor.execute(
"SELECT * FROM employees WHERE department = ? AND salary > ?",
("Engineering", 90000)
)
# Style 2: Named placeholders
cursor.execute(
"SELECT * FROM employees WHERE department = :dept AND salary > :min_salary",
{"dept": "Engineering", "min_salary": 90000}
)
# Named placeholders are clearer with many parameters
cursor.execute("""
INSERT INTO employees (name, email, department, salary, hire_date)
VALUES (:name, :email, :dept, :salary, :date)
""", {
"name": "Frank Green",
"email": "frank@company.com",
"dept": "Engineering",
"salary": 92000,
"date": "2025-09-01"
})
conn.commit()
conn.close()Las consultas parametrizadas gestionan el escape y el quoting automáticamente. El motor de base de datos trata los valores como datos, nunca como código SQL. Esto elimina el SQL injection sin importar el contenido del input.
Cláusulas WHERE dinámicas
A veces necesitas construir consultas dinámicamente, por ejemplo cuando los filtros son opcionales. Aquí tienes un patrón seguro:
import sqlite3
def search_employees(department=None, min_salary=None, is_active=None):
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
conditions = []
params = []
if department is not None:
conditions.append("department = ?")
params.append(department)
if min_salary is not None:
conditions.append("salary >= ?")
params.append(min_salary)
if is_active is not None:
conditions.append("is_active = ?")
params.append(is_active)
query = "SELECT * FROM employees"
if conditions:
query += " WHERE " + " AND ".join(conditions)
cursor.execute(query, params)
results = cursor.fetchall()
conn.close()
return results
# Usage
engineers = search_employees(department="Engineering", min_salary=90000)
active_staff = search_employees(is_active=1)
everyone = search_employees() # No filtersTrabajar con transacciones
Una transacción agrupa múltiples operaciones SQL en una unidad atómica. O bien todas las operaciones tienen éxito (commit) o ninguna tiene efecto (rollback).
Control manual de transacciones
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
try:
# Transfer money between accounts
cursor.execute("UPDATE accounts SET balance = balance - 500 WHERE id = 1")
cursor.execute("UPDATE accounts SET balance = balance + 500 WHERE id = 2")
# Verify no negative balance
cursor.execute("SELECT balance FROM accounts WHERE id = 1")
balance = cursor.fetchone()[0]
if balance < 0:
raise ValueError("Insufficient funds")
conn.commit() # All good, save changes
print("Transfer complete")
except Exception as e:
conn.rollback() # Something went wrong, undo everything
print(f"Transfer failed: {e}")
finally:
conn.close()Autocommit y niveles de aislamiento
Por defecto, sqlite3 opera en modo de “transacción diferida” (deferred). Puedes cambiar este comportamiento:
import sqlite3
# Default behavior: transactions are deferred
conn = sqlite3.connect("myapp.db")
# Autocommit mode (each statement is its own transaction)
conn = sqlite3.connect("myapp.db", isolation_level=None)
# Immediate locking (prevents concurrent write conflicts)
conn = sqlite3.connect("myapp.db")
conn.execute("BEGIN IMMEDIATE")
# ... your operations ...
conn.commit()Savepoints para rollbacks parciales
Los savepoints permiten hacer rollback de una parte de una transacción sin deshacer todo:
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
try:
cursor.execute("INSERT INTO orders (customer_id, total) VALUES (1, 100)")
# Start a savepoint
cursor.execute("SAVEPOINT order_items")
try:
cursor.execute("INSERT INTO order_items (order_id, product, qty) VALUES (1, 'Widget', 5)")
cursor.execute("INSERT INTO order_items (order_id, product, qty) VALUES (1, 'Gadget', -1)")
# This might fail due to CHECK constraint
cursor.execute("RELEASE SAVEPOINT order_items")
except sqlite3.IntegrityError:
cursor.execute("ROLLBACK TO SAVEPOINT order_items")
print("Some items failed, but order was still created")
conn.commit()
except Exception:
conn.rollback()
finally:
conn.close()Obtener datos: fetchone, fetchall, fetchmany
El objeto cursor ofrece tres métodos para recuperar resultados de una consulta.
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
cursor.execute("SELECT * FROM employees ORDER BY salary DESC")
# fetchone: Get the next single row (or None if no more rows)
top_earner = cursor.fetchone()
print(f"Top earner: {top_earner}")
# fetchmany: Get the next N rows
next_three = cursor.fetchmany(3)
print(f"Next 3: {next_three}")
# fetchall: Get all remaining rows
remaining = cursor.fetchall()
print(f"Remaining: {len(remaining)} rows")
conn.close()Iterar sobre resultados de forma eficiente
Para conjuntos de resultados grandes, itera directamente sobre el cursor en vez de cargar todo en memoria:
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
cursor.execute("SELECT name, salary FROM employees")
# Memory-efficient: processes one row at a time
for name, salary in cursor:
print(f"{name}: ${salary:,.0f}")
conn.close()fetchmany con procesamiento por lotes
Al procesar millones de filas, fetchmany() en un bucle te da control sobre el uso de memoria:
import sqlite3
conn = sqlite3.connect("large_data.db")
cursor = conn.cursor()
cursor.execute("SELECT * FROM sensor_readings")
batch_size = 1000
while True:
rows = cursor.fetchmany(batch_size)
if not rows:
break
# Process batch
for row in rows:
pass # your processing logic
conn.close()Consultas avanzadas
JOINs
import sqlite3
conn = sqlite3.connect("school.db")
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# INNER JOIN: Students with their enrolled courses
cursor.execute("""
SELECT s.name AS student, c.title AS course, e.grade
FROM enrollments e
INNER JOIN students s ON e.student_id = s.id
INNER JOIN courses c ON e.course_id = c.id
ORDER BY s.name, c.title
""")
for row in cursor.fetchall():
print(f"{row['student']} - {row['course']}: {row['grade'] or 'In Progress'}")
# LEFT JOIN: All students, even those not enrolled
cursor.execute("""
SELECT s.name, COUNT(e.id) AS course_count
FROM students s
LEFT JOIN enrollments e ON s.id = e.student_id
GROUP BY s.id
ORDER BY course_count DESC
""")
for row in cursor.fetchall():
print(f"{row['name']}: {row['course_count']} courses")
conn.close()Agregación
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
# Department statistics
cursor.execute("""
SELECT
department,
COUNT(*) AS headcount,
ROUND(AVG(salary), 2) AS avg_salary,
MIN(salary) AS min_salary,
MAX(salary) AS max_salary,
SUM(salary) AS total_payroll
FROM employees
WHERE is_active = 1
GROUP BY department
HAVING COUNT(*) >= 2
ORDER BY avg_salary DESC
""")
print(f"{'Department':<15} {'Count':<8} {'Avg Salary':<12} {'Min':<10} {'Max':<10}")
print("-" * 55)
for row in cursor.fetchall():
dept, count, avg_sal, min_sal, max_sal, total = row
print(f"{dept:<15} {count:<8} ${avg_sal:<11,.0f} ${min_sal:<9,.0f} ${max_sal:<9,.0f}")
conn.close()Subconsultas
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
# Employees earning above their department average
cursor.execute("""
SELECT name, department, salary
FROM employees e
WHERE salary > (
SELECT AVG(salary) FROM employees
WHERE department = e.department
)
ORDER BY department, salary DESC
""")
for name, dept, salary in cursor.fetchall():
print(f"{name} ({dept}): ${salary:,.0f}")
# Most recent hire in each department
cursor.execute("""
SELECT name, department, hire_date
FROM employees
WHERE (department, hire_date) IN (
SELECT department, MAX(hire_date)
FROM employees
GROUP BY department
)
""")
for row in cursor.fetchall():
print(row)
conn.close()Funciones de ventana (SQLite 3.25+)
SQLite soporta funciones de ventana para totales acumulados, rankings y otras consultas analíticas:
import sqlite3
conn = sqlite3.connect("myapp.db")
cursor = conn.cursor()
# Rank employees by salary within each department
cursor.execute("""
SELECT
name,
department,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank,
RANK() OVER (ORDER BY salary DESC) AS overall_rank
FROM employees
WHERE is_active = 1
""")
for name, dept, salary, dept_rank, overall_rank in cursor.fetchall():
print(f"#{overall_rank} overall, #{dept_rank} in {dept}: {name} (${salary:,.0f})")
# Running total of payroll by hire date
cursor.execute("""
SELECT
name,
hire_date,
salary,
SUM(salary) OVER (ORDER BY hire_date) AS running_total
FROM employees
ORDER BY hire_date
""")
for name, date, salary, running in cursor.fetchall():
print(f"{date} | {name}: ${salary:,.0f} | Running total: ${running:,.0f}")
conn.close()SQLite con pandas
pandas tiene soporte integrado para SQLite mediante read_sql() y to_sql(). Esto facilita mover datos entre DataFrames y bases de datos SQLite.
Leer resultados SQL en un DataFrame
import sqlite3
import pandas as pd
conn = sqlite3.connect("myapp.db")
# Read a SQL query into a DataFrame
df = pd.read_sql("SELECT * FROM employees WHERE is_active = 1", conn)
print(df.head())
print(df.describe())
# Parameterized query with pandas
df = pd.read_sql(
"SELECT * FROM employees WHERE department = ? AND salary > ?",
conn,
params=("Engineering", 90000)
)
# Read an entire table
df = pd.read_sql("SELECT * FROM employees", conn, index_col="id")
conn.close()Escribir un DataFrame en SQLite
import sqlite3
import pandas as pd
# Create a sample DataFrame
df = pd.DataFrame({
"product": ["Laptop", "Phone", "Tablet", "Monitor", "Keyboard"],
"price": [999.99, 699.99, 449.99, 349.99, 79.99],
"stock": [50, 200, 75, 30, 500],
"category": ["Electronics", "Electronics", "Electronics", "Peripherals", "Peripherals"]
})
conn = sqlite3.connect("store.db")
# Write DataFrame to a new table
df.to_sql("products", conn, if_exists="replace", index=False)
# Append to an existing table
new_products = pd.DataFrame({
"product": ["Mouse", "Webcam"],
"price": [49.99, 89.99],
"stock": [300, 100],
"category": ["Peripherals", "Peripherals"]
})
new_products.to_sql("products", conn, if_exists="append", index=False)
# Verify
result = pd.read_sql("SELECT * FROM products", conn)
print(result)
conn.close()Flujo round-trip: CSV → SQLite → análisis
import sqlite3
import pandas as pd
# Step 1: Load CSV into SQLite
df = pd.read_csv("sales_data.csv")
conn = sqlite3.connect("analytics.db")
df.to_sql("sales", conn, if_exists="replace", index=False)
# Step 2: Run SQL analytics
summary = pd.read_sql("""
SELECT
region,
strftime('%Y-%m', sale_date) AS month,
COUNT(*) AS transactions,
SUM(amount) AS revenue,
AVG(amount) AS avg_transaction
FROM sales
GROUP BY region, month
ORDER BY month, revenue DESC
""", conn)
print(summary)
# Step 3: Export results
summary.to_csv("monthly_summary.csv", index=False)
conn.close()Una vez que tengas los resultados de tu consulta en un DataFrame, puedes visualizarlos directamente usando PyGWalker (opens in a new tab). PyGWalker convierte cualquier pandas DataFrame en una interfaz de visualización interactiva similar a Tableau, permitiéndote arrastrar y soltar campos para crear gráficos sin escribir código de plotting:
import pygwalker as pyg
# Turn your SQL query results into an interactive visualization
walker = pyg.walk(summary)Para un flujo todavía más fluido, RunCell (opens in a new tab) ofrece un entorno Jupyter con IA donde puedes escribir consultas SQL, obtener depuración asistida por IA y visualizar resultados al instante, todo en un solo notebook.
Manejo de errores
El módulo sqlite3 define varios tipos de excepciones. Capturar excepciones específicas lleva a mejores mensajes de error y estrategias de recuperación.
import sqlite3
def safe_insert(db_path, name, email, salary):
"""Insert an employee with proper error handling."""
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO employees (name, email, salary, hire_date) VALUES (?, ?, ?, date('now'))",
(name, email, salary)
)
conn.commit()
return cursor.lastrowid
except sqlite3.IntegrityError as e:
# Unique constraint violated (duplicate email, NOT NULL violated, etc.)
print(f"Data integrity error: {e}")
return None
except sqlite3.OperationalError as e:
# Table doesn't exist, SQL syntax error, database locked, etc.
print(f"Operational error: {e}")
return None
except sqlite3.DatabaseError as e:
# Corrupted database, disk full, etc.
print(f"Database error: {e}")
return None
except sqlite3.ProgrammingError as e:
# Using a closed cursor, wrong number of parameters, etc.
print(f"Programming error: {e}")
return None
finally:
if conn:
conn.close()Jerarquía de excepciones de SQLite3
| Excepción | Padre | Causas comunes |
|---|---|---|
sqlite3.Warning | Exception | Problemas no fatales |
sqlite3.Error | Exception | Clase base para todos los errores de sqlite3 |
sqlite3.InterfaceError | Error | Mal uso de la interfaz DB-API |
sqlite3.DatabaseError | Error | Errores relacionados con la base de datos |
sqlite3.DataError | DatabaseError | Problemas de procesamiento de datos |
sqlite3.OperationalError | DatabaseError | Base bloqueada, tabla ausente, error SQL |
sqlite3.IntegrityError | DatabaseError | Violaciones de UNIQUE, NOT NULL, FOREIGN KEY |
sqlite3.InternalError | DatabaseError | Error interno del módulo sqlite3 |
sqlite3.ProgrammingError | DatabaseError | Uso incorrecto del API |
sqlite3.NotSupportedError | DatabaseError | Función no soportada |
Consejos de rendimiento
Activar el modo WAL
El modo Write-Ahead Logging (WAL) permite lecturas concurrentes mientras se escribe y mejora el rendimiento de escritura:
import sqlite3
conn = sqlite3.connect("myapp.db")
conn.execute("PRAGMA journal_mode=WAL")
# Returns 'wal' if successfulEl modo WAL es especialmente útil cuando tienes un lector (p. ej., un servidor web) y un escritor (p. ej., un job en segundo plano) accediendo a la misma base de datos.
Usar executemany para inserts masivos
executemany es significativamente más rápido que llamar execute en un bucle porque reduce los “round trips” entre Python y SQLite:
import sqlite3
import time
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE numbers (value INTEGER)")
data = [(i,) for i in range(100_000)]
# Slow: individual inserts
start = time.perf_counter()
for d in data:
conn.execute("INSERT INTO numbers VALUES (?)", d)
conn.commit()
slow_time = time.perf_counter() - start
conn.execute("DELETE FROM numbers")
# Fast: executemany
start = time.perf_counter()
conn.executemany("INSERT INTO numbers VALUES (?)", data)
conn.commit()
fast_time = time.perf_counter() - start
print(f"Individual inserts: {slow_time:.2f}s")
print(f"executemany: {fast_time:.2f}s")
print(f"Speedup: {slow_time / fast_time:.1f}x")
conn.close()Crear índices para columnas consultadas frecuentemente
Los índices aceleran mucho las consultas SELECT a costa de escrituras ligeramente más lentas y más espacio en disco:
import sqlite3
conn = sqlite3.connect("myapp.db")
# Single column index
conn.execute("CREATE INDEX IF NOT EXISTS idx_employees_department ON employees(department)")
# Composite index (for queries that filter on both columns)
conn.execute("CREATE INDEX IF NOT EXISTS idx_employees_dept_salary ON employees(department, salary)")
# Unique index (also enforces uniqueness)
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_employees_email ON employees(email)")
# Check existing indexes
cursor = conn.execute("SELECT name, sql FROM sqlite_master WHERE type='index'")
for name, sql in cursor.fetchall():
print(f"{name}: {sql}")
conn.close()Usar EXPLAIN QUERY PLAN para comprobar rendimiento
import sqlite3
conn = sqlite3.connect("myapp.db")
# Check how SQLite executes a query
result = conn.execute("""
EXPLAIN QUERY PLAN
SELECT * FROM employees WHERE department = 'Engineering' AND salary > 90000
""").fetchall()
for row in result:
print(row)
# Output shows whether an index is used or a full table scan occurs
conn.close()PRAGMAs adicionales para rendimiento
import sqlite3
conn = sqlite3.connect("myapp.db")
# Increase cache size (default is 2000 pages = ~8MB)
conn.execute("PRAGMA cache_size = -64000") # 64MB (negative = KB)
# Reduce disk syncs for speed (less durable but much faster)
conn.execute("PRAGMA synchronous = NORMAL") # Options: OFF, NORMAL, FULL (default)
# Store temp tables in memory
conn.execute("PRAGMA temp_store = MEMORY")
# Check database integrity
result = conn.execute("PRAGMA integrity_check").fetchone()
print(f"Integrity: {result[0]}") # Should print 'ok'
conn.close()Ejemplo del mundo real: Task Manager CLI
Aquí tienes una aplicación completa de gestor de tareas que demuestra todos los conceptos cubiertos en esta guía. Usa SQLite para almacenamiento persistente y soporta prioridades, etiquetas y fechas de vencimiento.
import sqlite3
import sys
from datetime import datetime, date
from contextlib import contextmanager
DB_PATH = "tasks.db"
@contextmanager
def get_db():
"""Get a database connection with Row factory and foreign keys enabled."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db():
"""Create tables if they don't exist."""
with get_db() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
priority INTEGER DEFAULT 2 CHECK(priority BETWEEN 1 AND 4),
status TEXT DEFAULT 'todo' CHECK(status IN ('todo', 'in_progress', 'done')),
due_date TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
completed_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS task_tags (
task_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (task_id, tag_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date)")
def add_task(title, description="", priority=2, due_date=None, tags=None):
"""Add a new task with optional tags."""
with get_db() as conn:
cursor = conn.execute(
"INSERT INTO tasks (title, description, priority, due_date) VALUES (?, ?, ?, ?)",
(title, description, priority, due_date)
)
task_id = cursor.lastrowid
if tags:
for tag_name in tags:
conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag_name,))
tag_id = conn.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)).fetchone()["id"]
conn.execute("INSERT INTO task_tags (task_id, tag_id) VALUES (?, ?)", (task_id, tag_id))
print(f"Task #{task_id} created: {title}")
return task_id
def list_tasks(status=None, priority=None, tag=None):
"""List tasks with optional filters."""
with get_db() as conn:
conditions = []
params = []
if status:
conditions.append("t.status = ?")
params.append(status)
if priority:
conditions.append("t.priority = ?")
params.append(priority)
if tag:
conditions.append("t.id IN (SELECT task_id FROM task_tags tt JOIN tags tg ON tt.tag_id = tg.id WHERE tg.name = ?)")
params.append(tag)
query = """
SELECT t.id, t.title, t.priority, t.status, t.due_date,
GROUP_CONCAT(tg.name, ', ') AS tags
FROM tasks t
LEFT JOIN task_tags tt ON t.id = tt.task_id
LEFT JOIN tags tg ON tt.tag_id = tg.id
"""
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " GROUP BY t.id ORDER BY t.priority ASC, t.due_date ASC"
rows = conn.execute(query, params).fetchall()
priority_labels = {1: "URGENT", 2: "HIGH", 3: "MEDIUM", 4: "LOW"}
print(f"\n{'ID':<5} {'Priority':<10} {'Status':<13} {'Due':<12} {'Title':<30} {'Tags'}")
print("-" * 85)
for row in rows:
due = row["due_date"] or "—"
tags_str = row["tags"] or "—"
p_label = priority_labels.get(row["priority"], "?")
print(f"{row['id']:<5} {p_label:<10} {row['status']:<13} {due:<12} {row['title']:<30} {tags_str}")
print(f"\nTotal: {len(rows)} tasks")
def complete_task(task_id):
"""Mark a task as done."""
with get_db() as conn:
result = conn.execute(
"UPDATE tasks SET status = 'done', completed_at = ? WHERE id = ?",
(datetime.now().isoformat(), task_id)
)
if result.rowcount:
print(f"Task #{task_id} marked as done.")
else:
print(f"Task #{task_id} not found.")
def stats():
"""Show task statistics."""
with get_db() as conn:
row = conn.execute("""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'todo' THEN 1 ELSE 0 END) AS todo,
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) AS in_progress,
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done,
SUM(CASE WHEN due_date < date('now') AND status != 'done' THEN 1 ELSE 0 END) AS overdue
FROM tasks
""").fetchone()
print(f"\nTask Statistics:")
print(f" Total: {row['total']}")
print(f" To Do: {row['todo']}")
print(f" In Progress: {row['in_progress']}")
print(f" Done: {row['done']}")
print(f" Overdue: {row['overdue']}")
# Initialize and demo
if __name__ == "__main__":
init_db()
# Add sample tasks
add_task("Design database schema", priority=1, due_date="2026-02-20", tags=["backend", "database"])
add_task("Write API endpoints", priority=2, due_date="2026-02-25", tags=["backend", "api"])
add_task("Create unit tests", priority=3, tags=["testing"])
add_task("Update documentation", priority=4, due_date="2026-03-01", tags=["docs"])
# List and stats
list_tasks()
stats()
complete_task(1)
list_tasks(status="done")SQLite vs PostgreSQL vs MySQL vs MongoDB
| Característica | SQLite | PostgreSQL | MySQL | MongoDB |
|---|---|---|---|---|
| Tipo | Embebido | Cliente-servidor | Cliente-servidor | Base documental |
| Instalación | Cero config | Instalar + configurar | Instalar + configurar | Instalar + configurar |
| Almacenamiento | Archivo único | Directorio del servidor | Directorio del servidor | Directorio del servidor |
| Tamaño máx. DB | 281 TB | Ilimitado | 256 TB | Ilimitado |
| Escrituras concurrentes | Limitadas (bloqueo de archivo) | Excelente (MVCC) | Bueno (bloqueos por fila) | Bueno (bloqueos por documento) |
| Soporte SQL | La mayor parte de SQL92 | SQL completo + extensiones | SQL completo | MQL (no SQL) |
| Tipos de datos | 5 clases | 40+ tipos | 30+ tipos | Tipos BSON |
| Búsqueda full-text | Extensión FTS5 | tsvector integrado | FULLTEXT integrado | Índice de texto integrado |
| Soporte JSON | Extensión JSON1 | JSONB nativo | JSON nativo | Nativo (es JSON) |
| Replicación | No integrada | Streaming + lógica | Primario-réplica | Replica sets |
| Mejor para | Embebido, local, prototipos | Web apps, analítica | Web apps, CMS | Esquema flexible, documentos |
| Librería Python | sqlite3 (incluida) | psycopg2 | mysql-connector | pymongo |
| Hosting requerido | No | Sí | Sí | Sí |
| Coste para empezar | Gratis, cero overhead | Gratis, pero requiere servidor | Gratis, pero requiere servidor | Gratis, pero requiere servidor |
La decisión es sencilla: usa SQLite cuando tu aplicación sea el único proceso que escribe en la base de datos. Usa PostgreSQL o MySQL cuando necesites escrituras concurrentes multiusuario, replicación o acceso por red. Usa MongoDB cuando tus datos sean orientados a documentos y no encajen bien en un esquema relacional.
Preguntas frecuentes
¿SQLite es adecuado para producción?
Sí. SQLite se usa en producción en Android, iOS, los principales navegadores web, software de vuelo de Airbus y muchísimas aplicaciones de escritorio. Maneja hasta 281 terabytes y millones de filas. No es adecuado para aplicaciones web con alta concurrencia y muchas escrituras con muchos usuarios simultáneos, pero para la mayoría de escenarios funciona muy bien.
¿Cómo compruebo qué versión de SQLite está usando Python?
Usa sqlite3.sqlite_version para comprobar la versión de la librería SQLite y sqlite3.version para la versión del módulo Python. Por ejemplo: import sqlite3; print(sqlite3.sqlite_version) podría mostrar 3.45.1.
¿Pueden varios procesos leer la misma base de datos SQLite?
Sí. Varios procesos pueden leer la misma base de datos SQLite de forma simultánea. Las escrituras adquieren un bloqueo exclusivo sobre el archivo. Con el modo WAL activado, los lectores no bloquean a los escritores y los escritores no bloquean a los lectores. Aun así, solo un escritor puede operar a la vez.
¿Cómo hago una copia de seguridad de una base de datos SQLite de forma segura?
Usa la API de backup de sqlite3: source.backup(dest). También puedes copiar el archivo de base de datos de forma segura cuando no haya escrituras en curso. Nunca copies el archivo mientras haya una transacción de escritura activa, ya que eso puede corromper el backup.
import sqlite3
source = sqlite3.connect("myapp.db")
backup = sqlite3.connect("myapp_backup.db")
source.backup(backup)
backup.close()
source.close()¿Cómo almaceno fechas y horas en SQLite?
SQLite no tiene un tipo nativo DATE o DATETIME. Almacena fechas como TEXT en formato ISO 8601 ('2026-02-18' o '2026-02-18T14:30:00'). Las funciones de fecha integradas de SQLite (date(), time(), datetime(), strftime()) funcionan con ese formato. Alternativamente, almacena timestamps Unix como valores INTEGER.
¿SQLite soporta cifrado?
El SQLite open-source estándar no incluye cifrado. SQLite Encryption Extension (SEE) es un producto comercial de pago. Alternativas gratis incluyen SQLCipher (open source) y pysqlcipher3 para integración con Python.
¿Cómo evito SQL injection en Python sqlite3?
Usa siempre consultas parametrizadas con placeholders ? o :name. Nunca construyas strings SQL usando f-strings, .format() o formateo % con input de usuario. El módulo sqlite3 gestiona el escape automáticamente cuando pasas los parámetros por separado.
Conclusión
El módulo sqlite3 de Python te ofrece una base de datos relacional completa sin configuración. Para aplicaciones locales, prototipos, scripts de análisis de datos y pruebas, SQLite es difícil de superar. Los patrones clave para recordar: usa siempre consultas parametrizadas para prevenir SQL injection, usa context managers para un manejo limpio de conexiones, activa el modo WAL para mejor concurrencia y usa executemany para operaciones masivas.
Para flujos de análisis de datos, la combinación de SQLite y pandas es muy potente: carga datos con to_sql(), consúltalos con read_sql() y visualiza resultados con PyGWalker (opens in a new tab). Cuando tu proyecto supere los límites de concurrencia de SQLite, migrar a PostgreSQL suele requerir cambios mínimos de código, ya que la interfaz DB-API 2.0 es consistente entre drivers de bases de datos.