---
name: postgresql-experto
description: PostgreSQL avanzado. JSONB, arrays, tipos personalizados, búsqueda de texto completo, window functions, CTEs recursivos, Row Level Security y funciones almacenadas.
version: "1.2.0"
evolved: true
evolved-from: "1.1.0"
evolved-at: "2026-05-20"
evolved-by: "aprender"
evolved-note: "4 gotchas nuevos consolidados SIGM L-152/L-153/L-156 + SIGAF 2026-05-15: race condition SELECT-then-write fuera de transaction (CONFIRMADO x4), UPDATE WHERE col1 sin dimensiones de scoping, drift triple Literal↔validator↔ENUM, partial unique index con WHERE requiere predicado puro (no subqueries)"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para optimización de queries SQL (EXPLAIN ANALYZE, índices, partitioning) — para optimización cargar `sql-optimizacion`."
  - "No cargar para MongoDB, Redis o bases de datos NoSQL — este skill es específico de PostgreSQL; para Redis cargar `redis-experto`, para MongoDB cargar `mongodb-experto`."
  - "No cargar para ORM de SQLAlchemy o migraciones con Alembic — los ORM tienen patrones propios; para FastAPI/SQLAlchemy cargar `fastapi-experto`."
  - "No cargar para bases de datos MySQL, SQLite o SQL Server — las extensiones JSONB, RLS y funciones almacenadas son específicas de PostgreSQL."
evolvable: true  # default para skill estandar
---
# PostgreSQL Experto

## Cuándo NO cargar

- La tarea es optimización de queries: EXPLAIN ANALYZE, índices B-Tree, particionamiento — cargar `sql-optimizacion`.
- La base de datos es MongoDB, Redis, DynamoDB u otra NoSQL — para Redis cargar `redis-experto`, para MongoDB cargar `mongodb-experto`.
- El trabajo es con ORM de SQLAlchemy o migraciones Alembic — para FastAPI/SQLAlchemy cargar `fastapi-experto`.
- La base de datos es MySQL, SQLite o SQL Server — JSONB, RLS y `gen_random_uuid()` son específicos de PostgreSQL.

## JSONB — Datos semiestructurados

JSONB almacena JSON en formato binario descompuesto, lo que permite:
- Indexación eficiente con GIN.
- Queries directos sobre campos anidados.
- Operators potentes (`@>`, `?`, `#>>`).

### Operadores JSONB esenciales

```sql
-- Crear tabla con columna JSONB
CREATE TABLE productos (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    nombre TEXT NOT NULL,
    metadata JSONB NOT NULL DEFAULT '{}'
);

-- Operadores de acceso
SELECT metadata -> 'marca' AS marca;           -- Retorna JSON: "Dell"
SELECT metadata ->> 'marca' AS marca;          -- Retorna texto: Dell
SELECT metadata #>> '{specs, ram}' AS ram;     -- Ruta anidada como texto: 16

-- Contención @> : ¿el objeto contiene este subconjunto?
SELECT * FROM productos
WHERE metadata @> '{"marca": "Dell"}';

-- Existencia de clave ?
SELECT * FROM productos
WHERE metadata ? 'descuento';
```

### Índices para JSONB

```sql
-- GIN genérico: cubre todos los campos y operadores @>, ?, ?|, ?&
CREATE INDEX idx_productos_metadata ON productos USING GIN(metadata);

-- GIN específico para una ruta (más pequeño, más rápido)
CREATE INDEX idx_productos_marca ON productos
USING GIN((metadata -> 'marca'));

-- B-Tree en campo JSONB específico (para igualdad y rangos)
CREATE INDEX idx_productos_ram ON productos
((metadata #>> '{specs, ram}'));
```

### Modificar JSONB

```sql
-- Agregar/modificar campo
UPDATE productos
SET metadata = metadata || '{"precio": 29999.00}'::jsonb
WHERE id = ?;

-- Eliminar campo
UPDATE productos
SET metadata = metadata - 'descuento'
WHERE id = ?;

-- Modificar campo anidado
UPDATE productos
SET metadata = jsonb_set(metadata, '{specs, ram}', '32', false)
WHERE id = ?;
```

---

## Búsqueda de Texto Completo (Full-Text Search)

```sql
-- Configurar columna de búsqueda (tsvector)
ALTER TABLE productos ADD COLUMN busqueda_ts tsvector
    GENERATED ALWAYS AS (
        to_tsvector('spanish', coalesce(nombre, '') || ' ' || coalesce(descripcion, ''))
    ) STORED;

-- Índice GIN para búsqueda rápida
CREATE INDEX idx_productos_busqueda ON productos USING GIN(busqueda_ts);

-- Query de búsqueda con ranking
SELECT nombre, ts_rank(busqueda_ts, query) AS relevancia
FROM productos,
     to_tsquery('spanish', 'laptop & (dell | hp)') AS query
WHERE busqueda_ts @@ query
ORDER BY relevancia DESC
LIMIT 20;
```

---

## Row Level Security (RLS)

RLS permite que PostgreSQL aplique filtros automáticos de seguridad a nivel de fila.

```sql
-- Habilitar RLS en la tabla
ALTER TABLE facturas ENABLE ROW LEVEL SECURITY;
ALTER TABLE facturas FORCE ROW LEVEL SECURITY;

-- Política: cada usuario ve solo sus facturas
CREATE POLICY facturas_por_empresa ON facturas
    FOR ALL
    TO app_user
    USING (empresa_id = current_setting('app.empresa_id')::uuid);

-- Política separada para admins (pueden ver todo)
CREATE POLICY facturas_admin ON facturas
    FOR ALL
    TO app_admin
    USING (true);

-- En la aplicación: establecer el contexto antes de cada query
SET LOCAL app.empresa_id = '550e8400-e29b-41d4-a716-446655440000';
```

---

Para arrays nativos, tipos personalizados (ENUM, compuestos, dominios),
funciones almacenadas PL/pgSQL, configuración de performance, y extensiones útiles,
ver [recursos/referencia-completa.md](recursos/referencia-completa.md).

## Gotchas / Errores comunes no obvios

**`SET LOCAL app.empresa_id` para RLS no persiste fuera de una transacción**: `SET LOCAL` establece la variable solo para la transacción actual — si se ejecuta fuera de `BEGIN/COMMIT`, tiene el mismo efecto que `SET` (sesión completa). En aplicaciones con pooling (PgBouncer en transaction mode), la sesión se reutiliza entre conexiones y la variable puede persistir de una petición anterior. Causa: el pool de conexiones reutiliza sessions sin limpiar el estado. Fix: usar SIEMPRE `SET LOCAL` dentro de una transacción explícita (`async with session.begin()`) y verificar que el pool esté en transaction mode.

**RLS NO se aplica a superusuarios — verificar siempre con rol app-only**: `FORCE ROW LEVEL SECURITY` hace que las policies apliquen al OWNER de la tabla, pero los superusuarios SIEMPRE bypassean RLS. En imágenes oficiales (`postgis/postgis`, `postgres`), el rol que se crea por defecto (con `POSTGRES_USER`) es superusuario. Caso real: `SET app.tenant_id = COAT` mostraba 20 predios del tenant MINA porque la query corría como `sigm` superuser. Fix: para verificar aislamiento RLS o exponer al backend de producción, crear un rol app-only sin BYPASSRLS:
```sql
CREATE ROLE sigm_app NOLOGIN;
GRANT USAGE ON SCHEMA <s> TO sigm_app;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA <s> TO sigm_app;
SET ROLE sigm_app;  -- ahora RLS aplica
```
NUNCA usar el rol propietario del schema (suele ser superuser) como rol de aplicación.

**UUIDs deterministas en seeds DEBEN usar SOLO chars hex (0-9, a-f)**: la convención común de "prefijo legible" (`vut00000-...` para valores unitarios terreno, `z0000000-...` para zonas, `tv000000-...` para tipos vialidad) produce strings inválidos como UUID. PostgreSQL rechaza con `invalid input syntax for type uuid: "vut00000-..."`. Detectado en sesión 2026-05-04 con 30+ UUIDs inválidos cascadeando errores en seeds dependientes. Fix: usar dígitos hex como prefijo discriminador (`30000000-` zonas, `40000000-` manzanas, `b0000000-` colonias) o usar `gen_random_uuid()` y resolver vínculos via subqueries (`(SELECT id FROM ... WHERE clave = 'X')`).

**Enum types: nunca presumir variantes — consultar `enum_range` antes de seedear**: PostgreSQL crea tipos enum con variantes específicas que pueden diferir del nombre de dominio. Ejemplos reales: `tipo_suelo` no tiene `'rústico'` (con acento) sino `'rural'`; `tipo_estatus_medidor` no tiene `'funcionando'` sino `'funcional'`. Fix: antes de incluir un valor literal en seed o INSERT, ejecutar:
```sql
SELECT enum_range(NULL::<schema>.<tipo>);
-- {funcional,descompuesto,sin_medidor,retirado}
```
Si el valor que necesitas no existe, agregar la variante con `ALTER TYPE ... ADD VALUE` en una migración separada — no improvisar en seeds.

**GIN index en columna JSONB no se usa con el operador `->>` en una cláusula WHERE**: `WHERE metadata ->> 'marca' = 'Dell'` extrae texto y compara — este operador no usa el índice GIN. Solo los operadores `@>`, `?`, `?|`, `?&` usan el índice GIN. Causa: `->>` convierte JSONB a texto y la comparación es una operación de texto sin índice JSONB. Fix: para búsquedas de igualdad con índice, usar `WHERE metadata @> '{"marca": "Dell"}'` que sí usa el índice GIN, o crear un índice de expresión B-Tree sobre `(metadata ->> 'marca')`.

**`FORCE ROW LEVEL SECURITY` no protege al usuario dueño de la tabla (superuser/owner)**: el propietario de la tabla y los superusuarios de PostgreSQL bypasean RLS por defecto aunque esté `FORCE` habilitado. Causa: `FORCE` aplica a todos los usuarios excepto al dueño de la tabla y a superusuarios. Fix: si la aplicación conecta como el dueño de la tabla, cambiar el rol de conexión a un rol de aplicación sin privilegios de dueño (`GRANT CONNECT ON DATABASE ... TO app_user`), no usar el usuario `postgres` para conexiones de aplicación.

**`tsvector` `GENERATED ALWAYS AS STORED` no actualiza cuando cambia la configuración de idioma**: si la columna `busqueda_ts` fue generada con `to_tsvector('spanish', ...)` y luego se cambia el contenido con texto en otro idioma, las palabras no se stemmizan correctamente — pero la columna no se regenera. Causa: la columna generada se recalcula solo cuando cambia la fila, no cuando cambia la lógica de la expresión. Fix: si se cambia la expresión de la columna generada, hacer `ALTER TABLE ... DROP COLUMN busqueda_ts; ALTER TABLE ... ADD COLUMN ...` para regenerar todos los valores.

**SELECT-then-INSERT/UPDATE FUERA de `conn.transaction()` produce race condition** (CONFIRMADO x4 en sesiones SIGM 2026-05-20): patrón `SELECT id FROM X WHERE clave=$1` seguido de `INSERT/UPDATE X` sin envolver en transacción permite a otra petición concurrente insertar entre el SELECT y el write — viola unicidad lógica aunque haya UNIQUE constraint (la 2da petición recibe error pero después de hacer trabajo). Casos: doble VIGENTE en valuación, doble documento/orden con mismo folio, subdivisión/fusión/traslado de predios concurrentes. Fix obligatorio:
```python
async with self._conn.transaction():
    # SELECT con lock explícito
    row = await self._conn.fetchrow(
        "SELECT id, version FROM tabla WHERE clave=$1 FOR UPDATE",
        clave,
    )
    if row is None:
        await self._conn.execute(
            "INSERT INTO tabla (clave, ...) VALUES ($1, ...)",
            clave, ...,
        )
    else:
        # Si requiere CAS: WHERE clave=$1 AND version=$N en el UPDATE
        await self._conn.execute(
            "UPDATE tabla SET ... WHERE id=$1",
            row["id"],
        )
```
Alternativas:
- `INSERT ... ON CONFLICT (clave) DO NOTHING/UPDATE` cuando la lógica es upsert puro.
- `SELECT FOR UPDATE` para bloquear filas existentes durante el chequeo.
- CAS en `WHERE version=$N` cuando el lock es muy costoso y se prefiere reintento.

Regla de auditoría: cualquier endpoint con `SELECT ... WHERE` seguido de `INSERT`/`UPDATE` sobre la misma tabla DEBE estar dentro de `async with self._conn.transaction():`. El UNIQUE constraint NO sustituye el lock — es defensa de último recurso.

**`UPDATE ... WHERE columna1 = $1` sin filtrar dimensiones adicionales des-actualiza filas hermanas**: SIGM NEM-VA-03 (2026-05-20). `UPDATE valuacion SET es_vigente=false WHERE predio_id=$1` cuando el predio tiene múltiples ejercicios fiscales des-vigentaba TODOS los ejercicios, no solo el actual. El bug pasó UNIQUE constraint (cada fila era válida individualmente) pero rompió integridad de dominio (debe haber 1 VIGENTE por (predio, ejercicio), no 1 VIGENTE por predio). Fix:
```sql
-- MAL
UPDATE valuacion SET es_vigente=false WHERE predio_id=$1;

-- BIEN
UPDATE valuacion SET es_vigente=false
WHERE predio_id=$1 AND ejercicio=$2;
```
Regla de auditoría: antes de aceptar un UPDATE, listar TODAS las dimensiones de dominio que definen "una fila lógica" (tenant_id, ejercicio, tipo, estatus, ...) y verificar que el WHERE incluye cada dimensión que aplica al scope del cambio. Las dimensiones implícitas (`es_vigente=true`, `activo=true`) también cuentan — un UPDATE que apaga `es_vigente` debe filtrar por la dimensión que define la unicidad de "vigente".

**Drift triple Pydantic `Literal` ↔ validator de schema ↔ ENUM PostgreSQL**: SIGM NEM-VA-11 (2026-05-20). Tres universos incompatibles del mismo concepto: `Literal["A", "B", ..., "F"]` (6 valores) en el schema Pydantic, validator personalizado aceptando 8 valores, ENUM en PostgreSQL declarado con 12 valores. Cast `$N::schema.tipo_enum` falla en BD cuando llega un valor permitido por el Literal pero ausente del ENUM. Fix: **PostgreSQL ENUM como única fuente de verdad** cuando existe. Validar al arrancar la app:
```python
from sqlalchemy import text

async def validar_enum_alineado(session, schema: str, enum_name: str, pydantic_values: set[str]):
    rows = await session.execute(
        text(f"SELECT enum_range(NULL::{schema}.{enum_name})")
    )
    pg_values = set(rows.scalar().strip("{}").split(","))
    if pydantic_values != pg_values:
        raise RuntimeError(
            f"Drift ENUM {enum_name}: pydantic={pydantic_values} pg={pg_values}"
        )
```
O generar el `Literal` Pydantic desde el ENUM PostgreSQL al startup (más estricto pero requiere conexión a BD para arrancar). Tests obligatorios: para cada ENUM, un test que compare `enum_range` contra el `Literal` del schema. La regla simple: si existe ENUM en BD, NO declarar Literal en código — derivarlo. Si NO existe ENUM en BD, NO declarar Literal en código — usar string libre con validator que consulta tabla de catálogo.

**Partial unique index requiere predicado puro sobre columnas de la propia tabla**: SIGAF DT-TRANSICION-UC 2026-05-15. Intento inicial: `CREATE UNIQUE INDEX ... WHERE estatus_destino_id != (SELECT id FROM cat_estatus_acto WHERE clave='CANCELADO')`. PostgreSQL rechaza el predicado en partial index porque no permite subqueries. Workaround común: resolver el UUID en `upgrade()` con `SELECT` y embeber el literal — frágil porque en BD limpia (CI) la tabla está vacía → query retorna None → el índice se crea SIN filtro WHERE → seeds posteriores fallan al insertar lo que el índice ahora rechaza. Fix correcto:
```sql
-- 1. Agregar columna discriminadora en la tabla (boolean o enum)
ALTER TABLE transicion_estatus_acto
    ADD COLUMN es_automatica BOOLEAN NOT NULL DEFAULT FALSE;

-- 2. Backfill condicional desde el join con la tabla externa
UPDATE transicion_estatus_acto t
SET es_automatica = TRUE
FROM cat_estatus_acto c
WHERE t.estatus_destino_id = c.id AND c.clave = 'CANCELADO';

-- 3. Partial index con predicado puro sobre columnas propias
CREATE UNIQUE INDEX uq_transicion_origen_automatica
ON transicion_estatus_acto (estatus_origen_id)
WHERE es_automatica = TRUE;
```
Regla:
- El `WHERE` de un partial index DEBE ser sobre columnas de la propia tabla. NUNCA subquery a otra tabla.
- Si la lógica requiere distinguir filas por relación con otro catálogo, agregar columna discriminadora con backfill desde el JOIN.
- Toda migración que dependa de datos de catálogo DEBE ser idempotente para BD vacía. NO confiar en que el seed corra antes — en CI fresh corre después.
