---
name: sql-optimizacion
description: Optimización SQL. Índices, EXPLAIN ANALYZE, problema N+1, query planning, particionamiento de tablas, materialización de vistas y patrones de queries eficientes.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para diseño inicial de esquema de base de datos (entidades, relaciones, normalización) — ese es el dominio de `postgresql-experto` o `dbml-experto`; este skill asume que el esquema ya existe y necesita optimización."
  - "No cargar para migrar queries entre ORM diferentes (SQLAlchemy ↔ Django ORM) — las semánticas son distintas; cargar `fastapi-experto` o `django-experto` según el ORM del proyecto."
  - "No cargar para problemas de conexión a base de datos, autenticación o configuración de PostgreSQL — esos son problemas de infraestructura de BD, no de optimización de queries."
  - "No cargar cuando el problema de rendimiento está en la lógica de aplicación (bucles en Python, serialización Pydantic) y no en las queries SQL — usar profiling de la aplicación antes de optimizar SQL."
evolvable: true  # default para skill estandar
---
# Optimización SQL

## Cuándo NO cargar

- El esquema de BD no existe todavía y la tarea es diseñarlo — cargar `postgresql-experto` o `dbml-experto`.
- El ORM está generando queries y la tarea es entender cómo el ORM específico las genera — cargar `fastapi-experto` o `django-experto`.
- El problema de lentitud está en el código de aplicación, no en las queries — profiling primero, SQL después.

## Proceso de optimización

No optimices a ciegas. Siempre:
1. **Identificar** el query lento (logs, pg_stat_statements).
2. **Medir** con EXPLAIN ANALYZE antes de cambiar nada.
3. **Hipótesis** sobre la causa (falta de índice, seq scan, join ineficiente).
4. **Cambiar** una cosa a la vez.
5. **Medir de nuevo** para confirmar la mejora.

---

## EXPLAIN ANALYZE — leer el plan de ejecución

```sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT f.id, f.folio, c.nombre
FROM facturas f
JOIN clientes c ON f.cliente_id = c.id
WHERE f.estatus = 'emitida'
  AND f.fecha >= '2026-01-01'
ORDER BY f.fecha DESC
LIMIT 50;
```

### Cómo leer el plan

- **cost=inicio..total**: estimación del planificador (ignorar valores absolutos).
- **actual time=inicio..total**: tiempo real en milisegundos.
- **rows**: filas procesadas.
- **Buffers: shared hit=N read=M**: N páginas desde cache, M desde disco.
- **Seq Scan**: señal de índice faltante.
- **Rows Removed by Filter**: filas descartadas — oportunidad de índice.

---

## Índices

### Tipos de índices en PostgreSQL

```sql
-- B-Tree (default): igualdad, rangos, ORDER BY
CREATE INDEX idx_facturas_fecha ON facturas(fecha);
CREATE INDEX idx_facturas_cliente_fecha ON facturas(cliente_id, fecha DESC);

-- Parcial: solo indexa un subconjunto (más pequeño y rápido)
CREATE INDEX idx_facturas_pendientes ON facturas(fecha)
WHERE estatus IN ('borrador', 'pendiente');

-- Funcional: indexa resultado de función
CREATE INDEX idx_clientes_nombre_lower ON clientes(lower(nombre));

-- GIN: para arrays, JSONB y búsqueda de texto completo
CREATE INDEX idx_productos_etiquetas ON productos USING GIN(etiquetas);

-- GiST: para geometría, rangos, pg_trgm
CREATE INDEX idx_productos_nombre_trgm ON productos
USING GiST(nombre gist_trgm_ops);
```

### Cuándo crear un índice

**Crear** cuando: columna frecuente en WHERE/JOIN/ORDER BY, cardinalidad alta, tabla >10k filas, lecturas > escrituras.

**No crear** cuando: tabla pequeña, cardinalidad baja (booleano), columna con updates constantes.

### Índices compuestos: el orden importa

```sql
-- Índice en (empresa_id, estatus, fecha) puede usarse para:
-- WHERE empresa_id = ?                                    OK
-- WHERE empresa_id = ? AND estatus = ?                    OK
-- WHERE empresa_id = ? AND estatus = ? AND fecha > ?      OK
-- WHERE estatus = ?                                       NO (no empieza con primera columna)
```

---

## Problema N+1

```python
# MAL: N+1 en SQLAlchemy
facturas = session.execute(select(Factura)).scalars().all()
for factura in facturas:
    print(factura.cliente.nombre)  # 1 SELECT por factura

# BIEN: eager loading con selectinload
facturas = session.execute(
    select(Factura).options(selectinload(Factura.cliente))
).scalars().all()
```

### Detectar N+1 en tests

```python
def test_lista_facturas_no_genera_n_mas_1(db):
    with count_queries() as contador:
        facturas = obtener_facturas_con_clientes(db)
        _ = [f.cliente.nombre for f in facturas]
    assert contador.count <= 2  # Máximo 2: facturas + clientes
```

---

## CTEs, Window Functions, Particionamiento y Vistas materializadas

Para ejemplos completos de CTEs (incluyendo recursivos), window functions (RANK, LAG, acumulados), particionamiento por rango de fecha y vistas materializadas con refresh concurrente, ver [recursos/patrones-sql-avanzados.md](recursos/patrones-sql-avanzados.md).

---

## Anti-patrones SQL

```sql
-- MAL: SELECT * en producción
SELECT * FROM facturas WHERE empresa_id = ?;
-- BIEN: seleccionar solo lo necesario
SELECT id, folio, fecha, monto_total, estatus FROM facturas WHERE empresa_id = ?;

-- MAL: función en WHERE impide uso de índice
WHERE YEAR(fecha) = 2026
-- BIEN: rango que usa el índice
WHERE fecha >= '2026-01-01' AND fecha < '2027-01-01'

-- MAL: OR con columnas distintas (difícil de indexar)
WHERE empresa_id = ? OR cliente_id = ?
-- BIEN: UNION ALL (cada parte usa su índice)
SELECT id FROM facturas WHERE empresa_id = ?
UNION ALL
SELECT id FROM facturas WHERE cliente_id = ?

-- MAL: LIKE con wildcard al inicio (no usa B-tree)
WHERE nombre LIKE '%acme%'
-- BIEN: pg_trgm o full text search
WHERE nombre ILIKE '%acme%' -- con índice GiST/pg_trgm
```

---

## Gotcha: SQL en contextos de agentes y MCP servers

Cuando un agente o MCP server ejecuta SQL dinámico (queries construidas por IA,
herramientas de exploración de datos, interfaces NL-to-SQL), aplicar protección
de solo lectura obligatoria:

### Defensa en profundidad (3 capas)

```python
# Capa 1: Regex de rechazo — bloquear mutaciones antes de enviar al servidor
import re
PATRON_MUTACION = re.compile(
    r'\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|REVOKE)\b',
    re.IGNORECASE
)
if PATRON_MUTACION.search(query):
    raise ValueError(f"Query de mutación bloqueada: {query[:100]}")

# Capa 2: Transacción read-only — el servidor rechaza mutaciones escapadas
async with conexion.begin() as txn:
    await conexion.execute(text("SET TRANSACTION READ ONLY"))
    resultado = await conexion.execute(text(query))

# Capa 3: Timeout — limitar queries costosas de agentes
# statement_timeout protege contra queries que escanean toda la tabla
await conexion.execute(text("SET statement_timeout = '30s'"))
```

### Por qué las 3 capas

- Solo regex: un `SELECT ... INTO` o CTE con `DELETE` podría escapar.
- Solo `READ ONLY`: la query llega al servidor, consume recursos antes de fallar.
- Solo timeout: permite mutaciones lentas que corrompen datos.

Las 3 capas juntas dan protección robusta para ejecución de SQL no confiable.

## Gotchas / Errores comunes no obvios

- **`CREATE INDEX` en tabla con millones de filas bloquea escrituras hasta que el índice termina**: el `CREATE INDEX` por defecto adquiere un `ShareLock` sobre la tabla completa, bloqueando INSERTs, UPDATEs y DELETEs durante la construcción. Causa: sin `CONCURRENTLY`, PostgreSQL construye el índice en una sola transacción con lock. Solución: usar `CREATE INDEX CONCURRENTLY nombre_idx ON tabla(columna)` — tarda el doble pero no bloquea escrituras; no se puede usar dentro de una transacción explícita.
- **`EXPLAIN` muestra Seq Scan aunque existe el índice correcto**: el planner de PostgreSQL elige Seq Scan cuando estima que retornará más del ~10-15% de las filas de la tabla. Causa: si la columna indexada tiene baja selectividad (ej: un campo booleano con 50/50 distribución), el Seq Scan es más eficiente que usar el índice. Solución: verificar con `EXPLAIN (ANALYZE, BUFFERS)` si el Seq Scan es realmente lento — si la tabla cabe en memoria (`shared_hit` alto), el Seq Scan puede ser genuinamente más rápido que el índice.
- **Índice compuesto `(a, b)` no se usa en query que filtra solo por `b`**: los índices compuestos en PostgreSQL solo se usan si la query incluye el prefijo del índice. Causa: el índice `(a, b)` es equivalente a tener una guía telefónica ordenada por apellido+nombre — buscar solo por nombre no aprovecha el orden. Solución: crear un índice separado en `(b)` si se necesita filtrar por `b` solo, o reorganizar el índice compuesto con la columna más selectiva primero.
- **Query con `LIMIT 50` es lenta aunque hay índice**: la query tiene un `ORDER BY` en una columna diferente a la columna indexada, forzando un sort de todos los resultados antes del LIMIT. Causa: PostgreSQL necesita ordenar todos los rows que cumplen el WHERE antes de aplicar el LIMIT — el índice ayuda al filtro pero no al sort. Solución: crear un índice que incluya tanto la columna del WHERE como la columna del ORDER BY: `CREATE INDEX ON tabla(columna_where, columna_orderby DESC)`.
- **Columna derivada de función determinista NO es fuente de verdad para comparaciones — es caché**: persistir `nombre_normalizado` y usar `WHERE nombre_normalizado = :n` para deduplicación crea bug latente; cuando se mejora la función de normalización, los valores almacenados quedan stale hasta backfill. Causa: se trata el resultado cacheado como si fuera invariante, pero la función que lo produce puede evolucionar. Solución: dos caminos válidos (no mezclar): (1) **Re-cómputo on-the-fly** — cargar candidatos por scope acotado y aplicar la función en la capa de aplicación al vuelo; funciona con cualquier versión de la función. (2) **Backfill automático con migración** — al cambiar la función, generar migración que re-normalice todas las filas y resuelva colisiones con sufijo `-DUPE-{id}` para revisión manual. La función canónica es la única fuente de verdad — la columna cache es optimización opcional, no contrato.
