Antipatrones de PostgreSQL con contexto | Nicolás Garzón
Un antipatrón no es una palabra prohibida ni una regla absoluta. Es una decisión que parece útil en pequeño, pero falla al ignorar semántica, cardinalidad, concurrencia, seguridad u operación.
Las recomendaciones sobre PostgreSQL suelen degradarse cuando se convierten en slogans:
Texto
Copiar “nunca uses JSONB”
“siempre crea índices”
“usa serializable”
“una replica te protege”La pregunta correcta no es si una técnica es buena o mala, sino:
Texto
Copiar ¿qué problema resuelve?
¿qué garantía ofrece?
¿qué coste introduce?
¿cómo falla?
¿qué evidencia justificaría cambiarla?Cada antipatrón se presenta con cuatro partes:
Forma ingenua.
Por qué falla.
Alternativa habitual.
Cuándo la decisión original puede ser válida.
SQL
Copiar price text ,
created_at text ,
metadata text
Pierde validación de tipo.
Ordena números y fechas como texto.
Obliga a casts.
Dificulta índices y constraints.
Alternativa: numeric, timestamptz, jsonb, enums/checks o tablas relacionadas según el dominio.
Cuándo sí: identificadores externos realmente opacos o texto sin estructura útil.
Sin FKs ni tipos fuertes.
Actualizaciones reescriben documentos.
Policies e índices se complican.
El contrato queda implícito.
Alternativa: modelo híbrido; columnas para lo estable, JSONB para metadata variable.
Cuándo sí: eventos heterogéneos, payloads externos o datos temporales de exploración con límites claros.
SQL
Copiar tag_ids bigint [ ]
Sin FK por elemento.
Sin atributos de relación.
Dificulta deletes, joins y unicidad.
Alternativa: tabla puente.
Cuándo sí: valores atómicos pequeños sin identidad ni integridad referencial.
Por qué falla: representación binaria aproximada y acumulación de redondeos.
Alternativa: numeric(p,s) o integer de unidad mínima con reglas explícitas.
Cuándo sí: mediciones científicas o aproximadas donde error flotante es aceptable.
Problema: guardar una hora local como instante o viceversa.
timestamptz para instantes.
timestamp para una hora civil sin zona.
date para fecha.
Documenta zona de negocio.
Todas las queries deben filtrar.
FKs siguen viendo la fila.
Únicos necesitan partial indexes.
Retención y privacidad se complican.
Alternativa: usarlo solo cuando recuperación/historial lo exige; archivar o eliminar realmente cuando corresponda.
Referencias inválidas silenciosas.
Deletes y migraciones peligrosas.
La aplicación debe reconciliar todo.
Alternativa: FKs, índices de soporte y acciones referenciales intencionales.
Cuándo sí: ingestión distribuida o staging donde la inconsistencia es deliberada, temporal y monitorizada.
Existen scripts, jobs, imports y otros servicios.
Dos requests concurrentes pueden superar una validación previa.
Alternativa: backend para UX + constraints para integridad.
Un default rellena ausencia; no valida que el valor sea correcto ni que todas las filas existentes lo tengan.
Por qué falla: lógica imperativa, más difícil de optimizar y probar.
Alternativa: NOT NULL, CHECK, UNIQUE, FK o exclusion constraint siempre que puedan expresar la regla.
Expone columnas nuevas.
Aumenta transferencia.
Acopla mapping y permisos.
Alternativa: proyección explícita.
Cuándo sí: exploración, tooling interno controlado o EXISTS donde el contenido no se utiliza.
Por qué falla: oculta cardinalidad incorrecta y añade sort/hash.
Alternativa: comprender claves, usar EXISTS o preagregar.
SQL
Copiar WHERE id NOT IN ( SELECT nullable_id FROM . . . ) Un NULL puede volver las comparaciones UNKNOWN.
Alternativa: NOT EXISTS o garantizar NOT NULL.
No define qué filas son “las primeras”.
Alternativa: orden total con desempate único.
Lee y descarta filas anteriores.
Cambios concurrentes producen saltos/duplicados.
Alternativa: keyset para navegación secuencial grande.
Cuándo sí: datasets pequeños, backoffice o salto arbitrario de página con coste aceptable.
SQL
Copiar WHERE ( $1 IS NULL OR status = $1 ) Puede producir generic plans pobres y baja sargabilidad.
Alternativa: queries diferenciadas o SQL dinámico estructural con valores parametrizados.
Por qué falla: muchos viajes de red y snapshots distintos.
Alternativa: batch, joins, prefetch o dataloader.
Pero una mega-query con múltiples one-to-many también puede multiplicar filas; optimiza la forma del contrato.
Un CTE puede inlinearse o materializarse. Es herramienta de estructura, no acelerador automático.
Más WAL y writes.
Más cache ocupada.
Menos HOT updates.
Vacuum y backups mayores.
Alternativa: diseñar desde queries reales y selectividad.
Para gran parte de una tabla, leer páginas secuencialmente puede ser óptimo.
Evalúa buffers, filas y tiempo, no el nombre del nodo.
(tenant_id, created_at) no equivale a (created_at, tenant_id).
Diseña equality prefixes, ranges, orden y INCLUDE.
status con tres valores puede no ayudar. Un partial index para estados activos puede ser mejor.
Acopla writes y consultas a una función. Requiere volatility correcta y mantenimiento tras cambios.
Puede ser enorme y caro. Usa expression index si solo consultas una key.
Las estadísticas se reinician y un índice puede ser necesario para tareas mensuales, FKs o incidentes. Observa un ciclo representativo.
Reconstruir sin diagnosticar consume I/O y espacio. Úsalo por bloat medido, collation change o corrupción.
Texto
Copiar SELECT stock
→ calcular en aplicación
→ UPDATEAlternativa: update condicional atómico, version column o row lock.
Retiene conexión, snapshot y locks mientras depende de red.
Alternativa: transacciones locales cortas + outbox/saga/idempotencia.
La lectura ya pudo cambiar. Bloquea antes o usa una sola sentencia.
40001 es parte del contrato. Debe repetirse toda la transacción.
Unique violation o input inválido no se corrigen repitiendo. Clasifica SQLSTATE y limita retries.
Produce una vista incompleta deliberada. Es apropiado para workers, no para reportes o integridad.
Si un writer no lo adquiere, la garantía desaparece. Documenta namespace, scope y orden.
Causa deadlocks. Adquiere recursos en orden estable.
Una sola sentencia ya es atómica. Añade bloque explícito solo cuando coordina varias operaciones.
Una operación condicional puede modificar cero filas sin error.
PostgreSQL no puede deshacer email, pago o HTTP. Usa coordinación explícita.
Aumentan complejidad y pueden dejar estado de aplicación incoherente.
Produce bloat, peores scans y riesgo de wraparound.
Alternativa: ajustar por tabla con métricas.
Reescribe y toma lock fuerte. Vacuum normal reutiliza espacio; FULL es recuperación específica.
DELETE libera lógicamente; el archivo suele conservar espacio reutilizable.
Aumenta tamaño y scans. Úsalo en tablas con updates donde HOT aporta valor.
Un read-only idle transaction puede impedir limpieza.
Aumentan write amplification y reducen HOT.
Ignora RAM, storage, conexiones y workload.
Alternativa: baseline, una hipótesis y medición.
Demasiados backends compiten por CPU, cache, locks y memoria.
Alternativa: pool limitado y backpressure.
Se aplica por operación y potencialmente por worker/conexión. Puede multiplicar memoria.
Alternativa: estimar concurrencia y usar SET LOCAL en operaciones conocidas.
Ignora OS cache y memoria de procesos.
Puede comprometer recuperación. Solo en datos desechables y con riesgo explícito.
Sirve para diagnóstico, no como solución permanente.
Si la query está mal modelada o falta un índice, estadísticas frescas no lo corrigen.
Coste de handshake y exceso de sesiones.
100 pods × 20 conexiones = 2000 backends.
La transacción pertenece a una sesión.
Prepared statements, temp tables, LISTEN y settings pueden no sobrevivir como espera la app.
Puede perder precisión. Define codecs y tipos de dominio.
Un request muerto continúa consumiendo recursos.
La base termina reflejando limitaciones del toolkit, no invariantes reales.
Produce N+1, joins inflados, selects amplios y migrations peligrosas.
La aplicación “cree” que existe integridad, pero la base no la protege.
Varias instancias pueden competir; DDL fuerte bloquea tráfico.
Queries críticas, locking, CTEs, JSONB y EXPLAIN requieren comprender el motor.
El ORM es útil cuando reduce boilerplate sin ocultar garantías.
Una inyección o bug puede alterar schema y bypass policies.
Alternativa: roles separados y least privilege.
Parámetros para values y whitelist/quoting seguro para identifiers.
Facilita object shadowing y search-path attacks.
Puede ejecutar objetos controlados por el caller con privilegios elevados.
RLS protege filas, no rate limits, workflows, secretos ni todos los owners/bypass roles.
Cifra pero puede aceptar al servidor equivocado. Usa verificación apropiada.
Convierte observabilidad en fuga de datos.
Contiene toda la información sensible aunque producción esté protegida.
Replica DELETE, DROP y corrupción lógica.
Alternativa: backups históricos + PITR + restore drills.
Es una esperanza, no una capacidad.
Depende del modo, acknowledgements, topología y desastre.
Puede crear dos primaries y split-brain.
Retiene WAL hasta llenar disco.
Los objetivos deben demostrarse con drills.
Rolling versions pueden seguir usando la columna antigua.
Alternativa: expand-contract.
Genera WAL, locks, bloat y lag.
Puede bloquear writes; evalúa CONCURRENTLY.
Rompe reproducibilidad entre entornos.
El servidor puede iniciar con objetos incompatibles o índices inválidos lógicamente.
Después de writes en destino existen dos historias.
Una query de 100 ms ejecutada un millón de veces puede costar más que una de 10 s diaria.
La espera puede estar en locks, I/O, pool o cliente.
Ejecuta la escritura y triggers.
Sin versiones, application_name y timestamps es difícil correlacionar regresiones.
Usa ventanas, tendencias y síntomas de usuario.
¿Qué invariante protege?
¿Qué cardinalidad produce?
¿Qué ocurre bajo dos ejecuciones concurrentes?
¿Qué volumen y distribución existen?
¿Cuál es el coste de escritura?
¿Cómo se monitorea?
¿Cómo se restaura o revierte?
¿Qué alternativa es más simple?
Requisito: reservar stock.
Texto
Copiar SELECT quantity
→ request llama API externa
→ UPDATE quantitySQL
Copiar UPDATE inventory
SET reserved_quantity = reserved_quantity + $1
WHERE branch_id = $2
AND product_id = $3
AND quantity - reserved_quantity >= $1
RETURNING reserved_quantity; Después, registra outbox y coordina el servicio externo fuera de una transacción larga.
La corrección combina query, concurrencia, transacción y operación; no es solo “poner un lock”.
Un antipatrón es contextual.
Las reglas absolutas suelen ocultar trade-offs.
Integridad debe vivir tan cerca del dato como sea razonable.
Rendimiento se diagnostica con planes, waits y workload.
Concurrencia se prueba con múltiples sesiones.
Backup, HA y seguridad necesitan operación continua.
El criterio central es garantizar el dominio bajo fallo y cambio.
¿Por qué un sequential scan no es automáticamente malo?
¿Cuándo JSONB sí es una buena decisión?
¿Qué riesgo tiene aumentar conexiones sin presupuesto global?
¿Por qué una replica no sustituye un backup?
¿Qué convierte una recomendación en antipatrón?
Ver respuestas
Porque leer gran parte de una tabla secuencialmente puede costar menos que saltar entre índice y heap.
Cuando la estructura es realmente variable o externa y no necesita integridad relacional central.
Multiplica backends, memoria y contención hasta reducir throughput.
Porque replica inmediatamente errores y no ofrece historial independiente.
Aplicarla sin considerar semántica, workload, concurrencia, fallo y trade-offs.
Modelo mental completo de PostgreSQL conecta todas estas decisiones en un solo recorrido verificable.