Mantener un campo derivado coherente con sus orígenes es una de las trampas clásicas en bases de datos: cualquier UPDATE puede dejar el cálculo desfasado y la solución habitual termina siendo un trigger frágil que la siguiente persona del equipo desconoce. Las columnas generadas del estándar SQL:2003 resuelven el problema declarativamente: la base de datos garantiza que el valor siempre refleja la fórmula declarada en el esquema.
Definición básica
PostgreSQL admite columnas generadas con la cláusula GENERATED ALWAYS AS (expresion) STORED. La palabra STORED indica que el valor se persiste físicamente en la fila; PostgreSQL solo soporta el modo STORED, no el VIRTUAL de MySQL.
CREATE TABLE pedidos_lineas (
id BIGSERIAL PRIMARY KEY,
pedido_id BIGINT NOT NULL,
producto_id BIGINT NOT NULL,
cantidad INT NOT NULL CHECK (cantidad > 0),
precio_unitario NUMERIC(10,2) NOT NULL,
descuento_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
importe NUMERIC(12,2) GENERATED ALWAYS AS (
cantidad * precio_unitario * (1 - descuento_pct / 100)
) STORED
);
La columna importe se recalcula automáticamente cuando se inserta o se modifica cualquiera de sus dependencias. No se puede asignar manualmente: cualquier INSERT que intente darle un valor explícito recibe un error.
INSERT INTO pedidos_lineas (pedido_id, producto_id, cantidad, precio_unitario, descuento_pct)
VALUES (1, 100, 3, 50.00, 10);
SELECT cantidad, precio_unitario, descuento_pct, importe FROM pedidos_lineas;
-- 3 | 50.00 | 10.00 | 135.00
Una columna generada es de solo lectura. Solo cambia si modificas las columnas dependientes. Esto evita el clásico bug de "actualicé la cantidad pero olvidé recalcular el importe".
Restricciones de la expresión
La expresión debe ser inmutable desde el punto de vista del planificador. Esto significa que no puede contener:
- Funciones marcadas como
VOLATILE(por ejemploNOW()oRANDOM()). - Subqueries que consulten otras tablas.
- Referencias a otras columnas generadas (sí, pero deben declararse antes en orden).
- Llamadas a funciones definidas por el usuario que no estén marcadas como
IMMUTABLE.
Para timestamps de creación necesitas un DEFAULT NOW() en una columna normal, no una columna generada.
CREATE TABLE clientes (
id BIGSERIAL PRIMARY KEY,
nombre TEXT NOT NULL,
email TEXT NOT NULL,
email_lower TEXT GENERATED ALWAYS AS (LOWER(email)) STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_clientes_email_lower ON clientes (email_lower);
El índice único sobre email_lower garantiza unicidad case-insensitive sin replicar la lógica LOWER() en cada consulta. Para esta misma necesidad existe la extensión citext, pero las columnas generadas funcionan sin extensiones y son portables.
Caso clásico: tsvector para full-text search
El uso más extendido de columnas generadas es la materialización de un tsvector para búsqueda de texto completo. Antes de PostgreSQL 12 había que mantener el campo con un trigger; ahora basta con declararlo:
CREATE TABLE articulos (
id BIGSERIAL PRIMARY KEY,
titulo TEXT NOT NULL,
cuerpo TEXT NOT NULL,
busqueda_fts TSVECTOR GENERATED ALWAYS AS (
setweight(to_tsvector('spanish', coalesce(titulo, '')), 'A') ||
setweight(to_tsvector('spanish', coalesce(cuerpo, '')), 'B')
) STORED
);
CREATE INDEX idx_articulos_fts ON articulos USING GIN (busqueda_fts);
Cualquier modificación de titulo o cuerpo actualiza busqueda_fts y el índice GIN se mantiene coherente. La consulta resulta tan simple como:
SELECT id, titulo, ts_rank_cd(busqueda_fts, query) AS relevancia
FROM articulos, websearch_to_tsquery('spanish', 'postgresql avanzado') AS query
WHERE busqueda_fts @@ query
ORDER BY relevancia DESC
LIMIT 20;
El
setweightpermite ponderar el título por encima del cuerpo, de modo que las coincidencias en el título suban más en el ranking.
Indexar columnas generadas
Las columnas generadas se pueden indexar como cualquier otra: B-tree, GIN, GiST, BRIN. El planificador las trata como columnas reales y aprovecha el índice para evaluar predicados sobre la expresión derivada.
CREATE TABLE eventos (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
payload JSONB NOT NULL,
tipo TEXT GENERATED ALWAYS AS (payload->>'tipo') STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_eventos_tipo ON eventos (tipo);
CREATE INDEX idx_eventos_user_tipo ON eventos (user_id, tipo);
Esta técnica permite consultas rápidas sobre campos JSON específicos sin necesidad de índices funcionales, que requieren expresiones idénticas en cada query y son más frágiles ante cambios.
Comparativa rápida con otros motores
| Motor | Sintaxis | Modos | Indexable |
|-------|----------|-------|-----------|
| PostgreSQL | GENERATED ALWAYS AS (expr) STORED | Solo STORED | Sí |
| MySQL 8 | GENERATED ALWAYS AS (expr) [VIRTUAL\|STORED] | Ambos | Sí (también virtuales) |
| Oracle | GENERATED ALWAYS AS (expr) | Solo virtual | Sí, mediante function-based index |
| SQL Server | AS expr [PERSISTED] | Persistido o no | Sí |
En PostgreSQL no existen columnas virtuales todavía: cualquier columna generada ocupa espacio en disco. Si la expresión es muy cara y se consulta poco, una vista o un índice funcional suelen ser preferibles.
Modificación del esquema
Cambiar la expresión de una columna generada no es directo. PostgreSQL no permite ALTER COLUMN ... SET GENERATED ALWAYS AS (nueva_expr). La operación requiere eliminar y recrear la columna, lo que conlleva reescribir toda la tabla:
ALTER TABLE pedidos_lineas DROP COLUMN importe;
ALTER TABLE pedidos_lineas ADD COLUMN importe NUMERIC(12,2)
GENERATED ALWAYS AS (cantidad * precio_unitario) STORED;
En tablas grandes esto es una operación bloqueante. La estrategia recomendada es declarar la expresión bien la primera vez y, si requiere evolucionar, usar el patrón expand-contract: añadir la nueva columna importe_v2, backfillear, redirigir lecturas, eliminar la antigua.
Casos donde no usar columnas generadas
- Valores con efectos secundarios o que dependan del contexto de sesión: usa triggers.
- Cálculos costosos que se consultan rara vez: una vista o cómputo en consulta cuesta menos en disco.
- Datos derivados de otras tablas: PostgreSQL no soporta subqueries en la expresión.
- Necesidad de auditoría histórica de los valores: una tabla separada con triggers sigue siendo la opción correcta.
flowchart TB
A[INSERT/UPDATE en pedidos_lineas] --> B{Modifica cantidad,<br/>precio o descuento?}
B -->|Si| C[Recalcular importe]
B -->|No| D[importe sin cambios]
C --> E[Persistir fila completa]
D --> E
E --> F[Indices actualizados]
Las columnas generadas convierten reglas de negocio sencillas en parte del contrato del esquema. Cualquier aplicación que escriba en la tabla, ya sea Java, Python o el propio psql, obtendrá el mismo cálculo sin posibilidad de error.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, SQL es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de SQL
Explora más contenido relacionado con SQL y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Definir columnas generadas con GENERATED ALWAYS AS ... STORED. Sustituir triggers de mantenimiento por expresiones declarativas. Combinar columnas generadas con índices GIN para full-text search. Conocer las restricciones de PostgreSQL frente a otros motores como MySQL u Oracle.
Cursos que incluyen esta lección
Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje