Todo lo que has escrito hasta ahora es una consulta: se envía, se ejecuta y se olvida. Esta lección va de lo contrario — código con nombre que vive dentro de la base de datos y que se invoca como si fuera parte del lenguaje. Aquí acabará viviendo la confirmación de pedido que en 09-03 escribiste a mano, con su BEGIN, sus cuatro sentencias y su COMMIT, y que hasta ahora dependía de que quien la ejecutara no se dejara ningún paso.
Verás la diferencia real entre una función y un procedimiento en PostgreSQL 11+, lo justo de PL/pgSQL para escribir algo útil sin convertir esto en un curso de programación, y tres ejemplos de TiendaVerde en orden de dificultad que terminan en sp_confirmar_pedido. Y, sobre todo, la discusión honesta: qué gana un equipo poniendo lógica en la base de datos, qué pierde, y dónde está la frontera. Porque esta es la herramienta del módulo que más fácil es usar mal.
Contenido
- Función frente a procedimiento
CREATE FUNCTIONconLANGUAGE sql- PL/pgSQL en una tabla de referencia
- Parámetros, tipos de retorno, sobrecarga y borrado
fn_total_pedido: una función escalarfn_ventas_por_categoria: una función que devuelve tablasp_confirmar_pedido: el procedimiento de 09-03- Volatilidad y
SECURITY DEFINER - La discusión honesta: qué poner dentro y qué no
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Función frente a procedimiento
Hasta PostgreSQL 10 solo existían funciones, y se usaban para todo. Desde la 11 hay también procedimientos, y la confusión entre ambos es constante. La tabla que la resuelve:
Función (CREATE FUNCTION) |
Procedimiento (CREATE PROCEDURE) |
|
|---|---|---|
| Se invoca con | SELECT fn(...) o dentro de una consulta |
CALL sp(...), como sentencia suelta |
| Devuelve | Siempre algo: escalar, fila, tabla o void |
Nada (o INOUT) |
¿Puede usarse en un SELECT? |
Sí: es una expresión más | No |
| ¿Controla transacciones? | No. Corre dentro de la del llamante | Sí: puede hacer COMMIT y ROLLBACK |
| Volatilidad declarable | Sí (IMMUTABLE/STABLE/VOLATILE) |
No |
| Para qué sirve | Calcular un valor o devolver un conjunto | Ejecutar un proceso: pasos, lotes, mantenimiento |
La línea divisoria es el control de transacciones. Una función se ejecuta dentro de la transacción de quien la llama; si esa transacción se deshace, todo lo que hizo la función se deshace con ella. Un procedimiento llamado con CALL fuera de una transacción explícita puede confirmar por su cuenta, lo que permite escribir un proceso por lotes que confirma cada mil filas sin mantener una transacción gigante abierta (09-01).
La regla práctica: si devuelve un dato, función; si hace un trabajo, procedimiento.
CREATE FUNCTION con LANGUAGE sql
CREATE FUNCTION con LANGUAGE sqlLa forma más sencilla no necesita ningún lenguaje procedimental: es una consulta con nombre y parámetros.
CREATE OR REPLACE FUNCTION fn_facturacion_pais(p_pais TEXT)
RETURNS NUMERIC LANGUAGE sql STABLE AS $$
SELECT COALESCE(ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2), 0)
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON pe.id = lp.pedido_id
JOIN clientes AS c ON c.id = pe.cliente_id
WHERE c.pais = p_pais;
$$;
SELECT fn_facturacion_pais('España') AS espana,
fn_facturacion_pais('Portugal') AS portugal,
fn_facturacion_pais('Francia') AS francia;| espana | portugal | francia |
|---|---|---|
| 433.70 | 156.48 | 137.77 |
433,70 + 156,48 + 137,77 = 727,95 €: las cifras por país que ya calculaste en 07-04, ahora encapsuladas. Cuatro detalles de sintaxis que se repetirán toda la lección:
$$ ... $$delimita el cuerpo. Es la dollar quoting de 01-03: dentro puedes escribir comillas simples sin escaparlas. Si el cuerpo contiene$$, usa una etiqueta:$fn$ ... $fn$.- Los parámetros se nombran con prefijo (
p_pais) por convención, para que no choquen con nombres de columna. Si un parámetro se llamarapais, PostgreSQL no sabría a qué te refieres en elWHERE. RETURNS NUMERICdeclara el tipo. ConLANGUAGE sql, el resultado es el de la última sentencia.STABLEes la volatilidad (apartado 8).
(Todas las funciones y procedimientos de esta lección son ejemplos: no forman parte del esquema canónico de TiendaVerde de 01-06.)
- PL/pgSQL en una tabla de referencia
LANGUAGE sql no sabe decidir ni repetir. Para eso está PL/pgSQL, el lenguaje procedimental que trae PostgreSQL de serie. Esto es lo esencial —lo justo para escribir algo útil— y no hace falta más para esta lección:
| Elemento | Sintaxis | Nota |
|---|---|---|
| Estructura | DECLARE ... BEGIN ... EXCEPTION ... END; |
El bloque DECLARE es opcional; BEGIN/END no son los de una transacción |
| Declarar | v_total NUMERIC(10,2) := 0; |
Tipo explícito, o %TYPE / %ROWTYPE para copiarlo de una columna o tabla |
| Asignar | v_total := 12.50; |
Con :=. Y SELECT ... INTO v_total para asignar desde una consulta |
| Condicional | IF cond THEN ... ELSIF cond THEN ... ELSE ... END IF; |
Ojo: es ELSIF, no ELSEIF ni ELSE IF |
| Bucle sobre consulta | FOR r IN SELECT ... LOOP ... END LOOP; |
r se declara sola y es del tipo de la fila |
| Otros bucles | LOOP ... EXIT WHEN cond; END LOOP;, WHILE, FOR i IN 1..10 |
|
| Devolver | RETURN expr; · RETURN QUERY SELECT ...; · RETURN NEXT r; |
RETURN QUERY para funciones de conjunto |
| Mensajes | RAISE NOTICE 'stock: %', v_stock; |
% es el marcador de sustitución. Niveles: DEBUG, LOG, NOTICE, WARNING |
| Error | RAISE EXCEPTION 'Sin stock de %', v_id USING ERRCODE = 'P0001'; |
Aborta la transacción |
| Capturar | EXCEPTION WHEN unique_violation THEN ... WHEN OTHERS THEN ... |
|
| Filas afectadas | GET DIAGNOSTICS v_n = ROW_COUNT; |
El UPDATE N de 05-03, ahora en una variable |
| Variables implícitas | FOUND (¿la última consulta encontró algo?), SQLERRM, SQLSTATE |
Dos avisos que ahorran horas. El primero: BEGIN y END en PL/pgSQL delimitan un bloque de código, no una transacción; la palabra coincide y confunde a todo el mundo. El segundo: un bloque con EXCEPTION crea internamente un SAVEPOINT (09-03) y tiene coste, así que no envuelvas en EXCEPTION un bucle de un millón de iteraciones si no lo necesitas.
- Parámetros, tipos de retorno, sobrecarga y borrado
| Aspecto | Formas |
|---|---|
| Modos de parámetro | IN (por omisión), OUT (devuelve por él), INOUT (entra y sale), VARIADIC (número variable) |
| Valor por defecto | p_anio INTEGER DEFAULT 2025 — los que lo tengan deben ir al final |
| Llamada por nombre | fn_ventas_por_categoria(p_anio => 2026), muy legible con varios parámetros |
| Retorno escalar | RETURNS NUMERIC, RETURNS TEXT, RETURNS void |
| Retorno de conjunto | RETURNS SETOF productos (filas de una tabla existente) · RETURNS TABLE(col tipo, ...) (define las columnas ahí mismo) |
| Sobrecarga | Varias funciones con el mismo nombre y distintos parámetros. Se resuelve por tipos |
| Borrado | DROP FUNCTION fn_total_pedido(INTEGER); — hay que dar la firma si está sobrecargada |
RETURNS TABLE(...) frente a RETURNS SETOF record: la primera declara los nombres y tipos de las columnas dentro de la función, y quien la llama escribe simplemente SELECT * FROM fn(...). La segunda obliga a describir la estructura en cada llamada —SELECT * FROM fn(...) AS t(id INT, nombre TEXT)—, lo que es incómodo y frágil. Usa RETURNS TABLE salvo que la forma del resultado dependa de verdad de los argumentos.
Sobre la sobrecarga, una advertencia: es cómoda pero se vuelve traicionera con los tipos. fn(1) y fn(1.0) pueden resolverse a funciones distintas, y DROP FUNCTION fn sin firma da function name "fn" is not unique. Prefiere nombres distintos a sobrecargar, salvo que la sobrecarga sea evidente.
fn_total_pedido: una función escalar
fn_total_pedido: una función escalarEl primer ejemplo, y el que más se usará: el total de un pedido, con la expresión canónica de importe.
CREATE OR REPLACE FUNCTION fn_total_pedido(p_pedido_id INTEGER)
RETURNS NUMERIC(10,2) LANGUAGE plpgsql STABLE AS $$
DECLARE
v_total NUMERIC(10,2);
BEGIN
SELECT COALESCE(ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2), 0)
INTO v_total
FROM lineas_pedido AS lp
WHERE lp.pedido_id = p_pedido_id;
IF v_total IS NULL THEN
RAISE NOTICE 'El pedido % no tiene líneas', p_pedido_id;
RETURN 0;
END IF;
RETURN v_total;
END;
$$;Y aquí está su gracia: se usa como una columna más, dentro de cualquier consulta.
SELECT pe.id, c.nombre || ' ' || c.apellidos AS cliente, pe.estado,
fn_total_pedido(pe.id) AS total_productos,
pe.gastos_envio,
fn_total_pedido(pe.id) + pe.gastos_envio AS total_pedido
FROM pedidos AS pe
JOIN clientes AS c ON c.id = pe.cliente_id
WHERE pe.id IN (1, 8, 12, 20)
ORDER BY pe.id;| id | cliente | estado | total_productos | gastos_envio | total_pedido |
|---|---|---|---|---|---|
| 1 | Lucía Martínez Soler | entregado | 42.10 | 4.95 | 47.05 |
| 8 | Sofia Moreira Costa | entregado | 64.88 | 9.90 | 74.78 |
| 12 | Julien Moreau | entregado | 66.90 | 12.50 | 79.40 |
| 20 | Camille Dubois | pendiente | 22.60 | 12.50 | 35.10 |
Los cuatro totales coinciden con los de 07-04: el pedido 12 es el ticket máximo (66,90 €) y el 20 el mínimo (22,60 €).
Y aquí está el peligro, dicho pronto:
SELECT id, fn_total_pedido(id) FROM pedidosejecuta la función una vez por fila — 20 consultas independientes contralineas_pedido. Es exactamente la subconsulta correlacionada de 07-02, ahora escondida detrás de un nombre bonito. Para un informe de 20 pedidos da igual; para uno de dos millones, es la diferencia entre 200 ms y media hora. Una función escalar dentro de unSELECTmasivo es unJOINdisfrazado, y casi siempre es mejor escribir elJOIN(o usar la vista de 10-01).
fn_ventas_por_categoria: una función que devuelve tabla
fn_ventas_por_categoria: una función que devuelve tablaCon RETURNS TABLE, una función se comporta como una tabla parametrizada — algo a medio camino entre una vista y una consulta suelta, y que las vistas no pueden hacer porque una vista no acepta parámetros.
CREATE OR REPLACE FUNCTION fn_ventas_por_categoria(p_anio INTEGER DEFAULT 2025)
RETURNS TABLE (categoria_id INTEGER, categoria VARCHAR, unidades BIGINT, facturacion NUMERIC)
LANGUAGE plpgsql STABLE AS $$
BEGIN
RETURN QUERY
SELECT cat.id, cat.nombre, SUM(lp.cantidad),
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2)
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON pe.id = lp.pedido_id
JOIN productos AS p ON p.id = lp.producto_id
JOIN categorias AS cat ON cat.id = p.categoria_id
WHERE EXTRACT(YEAR FROM pe.fecha_pedido) = p_anio
GROUP BY cat.id, cat.nombre
ORDER BY 4 DESC;
END;
$$;
SELECT * FROM fn_ventas_por_categoria(2025);| categoria_id | categoria | unidades | facturacion |
|---|---|---|---|
| 1 | Alimentación | 43 | 215.67 |
| 4 | Bebidas | 22 | 146.55 |
| 2 | Cosmética natural | 13 | 128.22 |
| 3 | Hogar sostenible | 10 | 88.58 |
| 5 | Higiene personal | 7 | 24.50 |
Suman 603,52 €, la facturación de 2025. Y con SELECT * FROM fn_ventas_por_categoria(2026) salen solo cuatro categorías —Bebidas 48,73 €, Alimentación 40,60 €, Cosmética natural 28,10 € e Higiene personal 7,00 €—, que suman los 124,43 € de 2026: en los dos primeros meses del año no se ha vendido nada de Hogar sostenible.
Como cualquier tabla, se puede filtrar y unir: SELECT * FROM fn_ventas_por_categoria(2025) WHERE facturacion > 100; devuelve tres filas.
Cuándo función y cuándo vista (10-01): si el resultado no depende de ningún parámetro, vista. Si depende de un argumento —el año, el país, un rango de fechas—, función que devuelve tabla. Y si el parámetro solo sirve para filtrar una columna que la vista ya expone, vista y
WHERE, que el planificador optimiza mejor.
sp_confirmar_pedido: el procedimiento de 09-03
sp_confirmar_pedido: el procedimiento de 09-03Este es el destino que 09-03 y 09-05 anunciaron. La confirmación de un pedido son varias operaciones que deben ir juntas: crear la cabecera, insertar las líneas, descontar el stock comprobando que no quede negativo y marcar el pedido como pagado. Si algo falla, no debe quedar nada a medias.
CREATE OR REPLACE PROCEDURE sp_confirmar_pedido(
p_cliente_id INTEGER,
p_empleado_id INTEGER,
p_metodo_pago VARCHAR,
p_envio NUMERIC,
p_productos INTEGER[], -- ids de producto
p_cantidades INTEGER[], -- cantidades, en el mismo orden
INOUT p_pedido_id INTEGER DEFAULT NULL -- devuelve el id creado
)
LANGUAGE plpgsql
AS $$
DECLARE
v_i INTEGER; v_precio NUMERIC(10,2); v_stock INTEGER; v_nombre TEXT;
BEGIN
IF array_length(p_productos, 1) IS DISTINCT FROM array_length(p_cantidades, 1) THEN
RAISE EXCEPTION 'Las listas de productos y cantidades no tienen el mismo tamaño';
END IF;
-- 1. Cabecera del pedido
INSERT INTO pedidos (cliente_id, empleado_id, fecha_pedido, estado, metodo_pago, gastos_envio)
VALUES (p_cliente_id, p_empleado_id, CURRENT_DATE, 'pendiente', p_metodo_pago, p_envio)
RETURNING id INTO p_pedido_id;
-- 2. Una línea por producto, descontando stock
FOR v_i IN 1 .. array_length(p_productos, 1) LOOP
-- Bloqueo la fila del producto antes de decidir (patrón de 09-05)
SELECT p.precio, p.stock, p.nombre INTO v_precio, v_stock, v_nombre
FROM productos AS p
WHERE p.id = p_productos[v_i] AND p.activo
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'El producto % no existe o está descatalogado', p_productos[v_i];
END IF;
IF v_stock < p_cantidades[v_i] THEN
RAISE EXCEPTION 'Stock insuficiente de "%": quedan % y se piden %',
v_nombre, v_stock, p_cantidades[v_i] USING ERRCODE = 'P0001';
END IF;
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio_unitario, descuento)
VALUES (p_pedido_id, p_productos[v_i], p_cantidades[v_i], v_precio, 0);
UPDATE productos SET stock = stock - p_cantidades[v_i] WHERE id = p_productos[v_i];
RAISE NOTICE 'Línea añadida: % x % a %', p_cantidades[v_i], v_nombre, v_precio;
END LOOP;
-- 3. El pedido queda pagado
UPDATE pedidos SET estado = 'pagado' WHERE id = p_pedido_id;
RAISE NOTICE 'Pedido % confirmado por un total de %', p_pedido_id, fn_total_pedido(p_pedido_id);
END;
$$;La llamada, con el caso feliz y el caso de fallo:
-- 2 unidades de aceite (id 1) y 1 de matcha (id 15) para Lucía, gestionado por Óscar
CALL sp_confirmar_pedido(1, 4, 'tarjeta', 4.95, ARRAY[1, 15], ARRAY[2, 1]);NOTICE: Línea añadida: 2 x Aceite de oliva virgen extra 500 ml a 12.50 NOTICE: Línea añadida: 1 x Té verde matcha ceremonial 30 g a 22.00 NOTICE: Pedido 21 confirmado por un total de 47.00 CALL
-- Ahora pidiendo más velas (id 13, stock 0) de las que hay
CALL sp_confirmar_pedido(1, 4, 'tarjeta', 4.95, ARRAY[13], ARRAY[1]);ERROR: Stock insuficiente de "Velas de cera de soja (pack 2)": quedan 0 y se piden 1 CONTEXT: PL/pgSQL function sp_confirmar_pedido(...) line 34 at RAISE
Y el pedido 22 no existe. Esa es toda la lección del apartado: el RAISE EXCEPTION abortó la transacción, y con ella se deshizo la cabecera que ya se había insertado. La atomicidad de 09-02 está garantizada por construcción, no por la disciplina de quien llama. Un CALL fuera de una transacción explícita se ejecuta en su propia transacción implícita, así que el procedimiento es atómico sin escribir BEGIN ni COMMIT.
Compáralo con lo que tenías en 09-03: allí, la aplicación debía abrir la transacción, ejecutar las cuatro sentencias en el orden correcto, comprobar el UPDATE N del stock y decidir si confirmaba. Cuatro sitios donde equivocarse, multiplicados por cada aplicación que confirme pedidos. Aquí hay uno.
(Este procedimiento modifica los datos. Si lo pruebas, recarga después el script de 01-06 para volver a las 20 filas de pedidos y a los stocks originales.)
Cuándo un procedimiento sí necesita COMMIT
El COMMIT explícito tiene un caso claro: el proceso por lotes. Un procedimiento que recalcula un millón de filas y confirma cada mil evita mantener una transacción de horas —con el coste en VACUUM y bloqueos que explicó 09-01:
LOOP
UPDATE ... WHERE ... LIMIT 1000; -- un lote
EXIT WHEN NOT FOUND;
COMMIT; -- solo posible en un PROCEDURE
END LOOP;Es exactamente lo que una función no puede hacer, y la razón por la que los procedimientos se añadieron al motor.
- Volatilidad y
SECURITY DEFINER
SECURITY DEFINERToda función declara —explícita o implícitamente— cuánto puede fiarse el planificador de ella:
| Categoría | Promete | Ejemplos | Consecuencia |
|---|---|---|---|
IMMUTABLE |
Mismos argumentos → siempre mismo resultado. No lee tablas | lower(x), x * 1.21 |
Se puede precalcular y usar en un índice de expresión |
STABLE |
Mismo resultado dentro de una misma sentencia. Puede leer tablas, no escribir | fn_total_pedido, now() |
Se evalúa una vez por sentencia cuando se puede |
VOLATILE |
Cualquier cosa: puede devolver algo distinto en cada llamada y escribir | random(), todo lo que hace INSERT |
Se evalúa en cada fila, siempre. Es el valor por omisión |
Que VOLATILE sea el valor por omisión es el error silencioso más caro de la lección. Una función que solo lee y no declara nada se evalúa fila a fila y no puede indexarse, así que aquí se cierra la promesa de 08-02: un índice de expresión como CREATE INDEX ix_prod_lower ON productos (lower(nombre)); exige que la función sea IMMUTABLE. Si no lo es, PostgreSQL responde functions in index expression must be marked IMMUTABLE — porque un índice guarda resultados precalculados y solo tiene sentido si esos resultados no cambian.
Marca STABLE toda función de solo lectura e IMMUTABLE toda función de cálculo puro. Y no mientas al planificador: declarar IMMUTABLE algo que lee una tabla produce resultados incorrectos difíciles de diagnosticar.
SECURITY DEFINER. Por omisión una función corre con los permisos de quien la llama (SECURITY INVOKER). ConSECURITY DEFINERcorre con los de quien la creó, lo que permite dar acceso controlado a datos que el usuario no puede leer directamente. Es potente y es una superficie de ataque: mal escrita, con unsearch_pathmanipulable, es una escalada de privilegios de manual. Los permisos, los roles y cómo blindar estas funciones son materia de 11-03.
- La discusión honesta: qué poner dentro y qué no
Esto es lo más valioso de la lección, y donde la mayoría de los tutoriales callan.
| A favor | En contra |
|---|---|
| Una sola ida y vuelta: confirmar un pedido es 1 llamada en lugar de 6 viajes por la red | Difícil de versionar: el código vive en el catálogo, no en tu repositorio, salvo que impongas disciplina |
| La lógica está junto a los datos: sin transferir miles de filas para procesarlas fuera | Difícil de probar: no hay depurador decente ni un ecosistema de testing comparable al de tu lenguaje |
| Atomicidad garantizada por construcción, no por disciplina del llamante | Lógica repartida en dos sitios: al depurar hay que mirar la aplicación y la base de datos |
| Reutilización entre varias aplicaciones y lenguajes contra la misma base | Portabilidad nula: PL/pgSQL, T-SQL y PL/SQL no se parecen. Migrar de motor es reescribir |
| Un único punto de verdad para una regla de negocio crítica | Escala con el servidor de BD, que es la pieza más cara y difícil de replicar |
| Puede restringir lo que hace un usuario a un conjunto de operaciones | Los ORM no las usan bien: se convierten en un camino paralelo (11-05) |
Y el criterio, que es lo que hay que llevarse:
| Ponlo en la base de datos cuando… | Déjalo en la aplicación cuando… |
|---|---|
| Es una regla de integridad que ninguna aplicación debe poder saltarse | Es lógica de presentación, de flujo o de experiencia de usuario |
| Requiere atomicidad sobre varias tablas | Requiere llamar a servicios externos (pasarela de pago, correo, API) |
| Procesa muchas filas y sacarlas fuera sería absurdo | Cambia a menudo y necesita el ciclo de despliegue de la aplicación |
| Lo comparten varias aplicaciones con la misma base | Solo lo usa una aplicación |
| Es mantenimiento de la propia base (limpiezas, agregados, particiones) | Necesita bibliotecas, concurrencia o cálculo que SQL no hace bien |
La postura equilibrada, y la que sostiene la mayoría de los equipos hoy: poco código en la base de datos, y del bueno. Restricciones y CHECK siempre; vistas y funciones de cálculo, sin problema; procedimientos para lo que de verdad exija atomicidad o proceso masivo. Lo que no funciona es ninguno de los dos extremos: ni una aplicación anémica que solo llama a doscientos procedimientos, ni una base de datos tratada como un almacén tonto en la que ninguna regla se puede garantizar.
Nota de dialecto: aquí es donde más divergen los motores.
Motor Lenguaje Peculiaridad PostgreSQL PL/pgSQL (y PL/Python, PL/Perl, PL/v8…) CREATE FUNCTION/CREATE PROCEDURE;CALL; cuerpo entre$$MySQL 8 SQL/PSM Hace falta DELIMITER //antes de crear el procedimiento, porque el;interno cortaría la sentenciaSQL Server T-SQL CREATE PROCEDURE ... AS BEGIN ... END,EXEC,@variables,TRY...CATCHOracle PL/SQL El antecesor de PL/pgSQL: se parecen mucho, pero los paquetes ( PACKAGE) no tienen equivalenteSQLite — No tiene procedimientos ni funciones almacenadas. Solo funciones definidas por la aplicación al abrir la conexión
Errores Comunes y Consejos
- Llamar a un procedimiento con
SELECT. No es una expresión:CALL sp_confirmar_pedido(...). Y al revés,CALLsobre una función da error. - Esperar que un
COMMITdentro de una función funcione. No puede:cannot commit while a subtransaction is activeoinvalid transaction termination. Solo los procedimientos, y solo si elCALLno está dentro de una transacción explícita. - Dejar la volatilidad por omisión. Todo es
VOLATILEsi no dices nada: se reevalúa fila a fila y no sirve para un índice de expresión (functions in index expression must be marked IMMUTABLE). MarcaSTABLElo que solo lee. - Llamar a una función escalar sobre millones de filas. Es una correlacionada disfrazada: una ejecución por fila. Reescríbela como
JOIN, vista o función que devuelva tabla. - Dar a un parámetro el mismo nombre que a una columna. La ambigüedad se resuelve a favor del parámetro y el
WHEREdeja de filtrar. Prefijop_siempre. - Confundir el
BEGINde PL/pgSQL con el de una transacción. Delimita un bloque de código. La transacción es la del llamante. - Usar
EXCEPTION WHEN OTHERS THEN NULL. Silencia todos los errores, incluidos los que jamás debiste silenciar. Captura excepciones concretas y vuelve a lanzar lo que no sepas tratar. - Escribir
ELSEIFoELSE IF. En PL/pgSQL esELSIF. - Consejo: guarda el código en el repositorio, en ficheros
.sqlconCREATE OR REPLACE, y despliégalo con las migraciones. Una función que solo existe en producción no existe. - Consejo: empieza por
LANGUAGE sql. Si no necesitas decidir ni repetir, no uses PL/pgSQL: la versión SQL pura es más corta, más rápida y el planificador puede integrarla en la consulta que la llama. - Consejo:
RAISE NOTICEes tu depurador. No hay mucho más, y conRAISE NOTICE 'v_stock = %', v_stock;en los puntos clave se resuelve el 90 % de los problemas.
Ejercicios
Ejercicio 1
Escribe fn_puntuacion_media(p_producto_id), que devuelva la puntuación media de un producto redondeada a dos decimales, o NULL si no tiene reseñas. (1) Elige LANGUAGE sql o plpgsql y justifica. (2) Declara la volatilidad correcta. (3) Úsala en una consulta que liste los 20 productos con su media, y comprueba que el aceite (producto 1) sale con 5.00, el arroz con 4.50 y once productos con NULL.
Ejercicio 2
Sobre sp_confirmar_pedido, responde razonando y comprueba después. (1) Si el segundo producto de la lista no tiene stock, ¿queda insertada la primera línea? ¿Y la cabecera del pedido? ¿Por qué? (2) ¿Qué pasaría si quitaras el FOR UPDATE del SELECT sobre productos y dos clientes confirmaran a la vez la última unidad de matcha? (3) ¿Podrías convertirlo en función en lugar de procedimiento? ¿Qué perderías?
Ejercicio 3
El equipo discute dónde poner cuatro reglas de TiendaVerde. Decide, con el criterio del apartado 9, si van en la base de datos o en la aplicación, y con qué herramienta exacta:
- "El precio de venta nunca puede ser negativo."
- "Al confirmar un pedido hay que enviar un correo de confirmación al cliente."
- "Cada noche hay que recalcular el ranking de productos más vendidos del mes."
- "Un cliente no puede tener más de tres pedidos pendientes a la vez."
Soluciones
Solución 1
CREATE OR REPLACE FUNCTION fn_puntuacion_media(p_producto_id INTEGER)
RETURNS NUMERIC(3,2) LANGUAGE sql STABLE AS $$
SELECT ROUND(AVG(r.puntuacion), 2) FROM resenas AS r WHERE r.producto_id = p_producto_id;
$$;
SELECT p.id, p.nombre, fn_puntuacion_media(p.id) AS media
FROM productos AS p ORDER BY media DESC NULLS LAST, p.id;| id | nombre | media |
|---|---|---|
| 1 | Aceite de oliva virgen extra 500 ml | 5.00 |
| 15 | Té verde matcha ceremonial 30 g | 5.00 |
| 2 | Arroz integral ecológico 1 kg | 4.50 |
| 6 | Crema facial de aloe vera 50 ml | 4.50 |
(4 primeras de 20 filas; siguen el detergente y el cepillo con 4.00, el tomate y las bolsas con 3.00, la kombucha con 2.00 y once productos con NULL.)
1. LANGUAGE sql: no hay ninguna decisión ni bucle, solo una consulta. Es más corta y el planificador puede integrarla en la consulta que la llama, cosa que con plpgsql no ocurre. 2. STABLE: lee tablas, así que no puede ser IMMUTABLE; y no escribe, así que declararla VOLATILE sería desperdiciar optimizaciones. 3. El AVG sobre un conjunto vacío devuelve NULL sin necesidad de ningún IF — y aquí NULL es lo correcto: "no hay opiniones" no es lo mismo que "cero estrellas" (04-03).
Solución 2
1. No queda nada: ni la línea, ni la cabecera. El RAISE EXCEPTION aborta la transacción entera, y como el CALL se ejecutó en su propia transacción implícita, se deshace todo lo hecho desde el principio del procedimiento — incluida la primera línea, que ya estaba insertada, y el UPDATE que ya había descontado su stock. Es la atomicidad de 09-02 aplicada sin que nadie tenga que acordarse de escribir ROLLBACK. Sí queda consumido el valor de la secuencia de pedidos.id, por lo de 09-05: nextval no es transaccional.
2. Sería la actualización perdida de 09-04, exactamente. Sin FOR UPDATE, ambas sesiones leerían stock = 1, ambas pasarían el IF v_stock < cantidad, y ambas insertarían su línea: se venderían dos unidades de una. El CHECK (stock >= 0) de la tabla salvaría los muebles en este caso concreto —el segundo UPDATE fallaría al intentar dejar el stock en −1—, pero eso es suerte, no diseño. Con FOR UPDATE, la segunda sesión espera, relee stock = 0 y lanza su excepción de stock insuficiente, que es el mensaje correcto para el cliente.
3. Sí, y funcionaría igual de bien en este caso, porque toda la lógica cabe en una sola transacción: bastaría CREATE FUNCTION ... RETURNS INTEGER devolviendo el id del pedido, y ejecutarla con SELECT. Lo que perderías es la capacidad de hacer COMMIT intermedios, irrelevante aquí pero decisiva si algún día el procedimiento tuviera que confirmar mil pedidos por lotes. Se gana algo a cambio: una función se puede usar dentro de una consulta. La elección honesta es la del apartado 1: esto hace un trabajo, no calcula un valor, así que procedimiento.
Solución 3
| # | Regla | Dónde | Herramienta |
|---|---|---|---|
| 1 | Precio no negativo | Base de datos | Un CHECK (precio >= 0) (ya está en el esquema de 01-06). Ni función ni procedimiento: es integridad pura y la restricción declarativa siempre gana |
| 2 | Correo de confirmación | Aplicación | Es una llamada a un servicio externo. Dentro de una transacción sería desastroso: si la transacción se deshace, el correo ya está enviado y no se puede desenviar (09-01) |
| 3 | Recalcular el ranking cada noche | Base de datos | Una vista materializada con REFRESH programado (10-01), o un procedimiento llamado desde cron. Procesa muchas filas y sacarlas fuera sería absurdo |
| 4 | Máximo tres pedidos pendientes | Depende | Un CHECK no puede (consulta otra tabla). Si es una regla inviolable, va dentro: trigger (10-05) o el propio sp_confirmar_pedido. Si es una política comercial que cambia cada trimestre, mejor en la aplicación |
La cuarta es la interesante y no tiene respuesta única: la pregunta correcta no es "¿se puede?" sino "¿qué pasa si alguien se la salta?". Si la respuesta es "datos corruptos", va en la base de datos; si es "una experiencia de compra rara", va en la aplicación.
Conclusión
Código que vive en la base de datos, con sus dos caras:
- Función frente a procedimiento: una función devuelve un valor, se llama con
SELECT, se puede usar dentro de una consulta y no controla transacciones; un procedimiento se llama conCALL, no devuelve nada (salvo porINOUT) y sí puede hacerCOMMIT/ROLLBACK. Si devuelve un dato, función; si hace un trabajo, procedimiento. LANGUAGE sqlbasta para encapsular una consulta con parámetros, y es la primera opción. PL/pgSQL añadeDECLARE,IF/ELSIF,FOR ... IN SELECT,RETURN QUERY,RAISE NOTICE/EXCEPTIONy el bloqueEXCEPTION WHEN— con el aviso de que suBEGINno es el de una transacción.RETURNS TABLE(...)convierte una función en una tabla parametrizada, que es lo que una vista no puede ser. Si no hay parámetros, vista; si los hay, función.- Los tres ejemplos:
fn_total_pedido(escalar, con los tickets de 42,10, 64,88, 66,90 y 22,60 €),fn_ventas_por_categoria(tabla, 603,52 € en 2025 y 124,43 € en 2026) ysp_confirmar_pedido, donde vive por fin la lógica de 09-03: inserta, descuenta stock conFOR UPDATE, lanzaRAISE EXCEPTIONsi falta y deja la base intacta si algo falla. - La volatilidad importa:
VOLATILEes el valor por omisión y significa "reevalúame en cada fila";IMMUTABLEes requisito para un índice de expresión (08-02) ySTABLEes lo correcto para casi toda función de solo lectura.SECURITY DEFINERda poder y abre una superficie de ataque (11-03). - Y la discusión honesta: una llamada en lugar de seis viajes, lógica junto a los datos, atomicidad garantizada y reutilización, frente a versionado difícil, pruebas incómodas, lógica en dos sitios, portabilidad nula y escalado atado al servidor de BD. La postura sensata es poco código en la base de datos, y del bueno.
Queda una pregunta que este esquema no responde: ¿y si la lógica tiene que ejecutarse sin que nadie la llame? Un procedimiento hay que invocarlo, y basta con que una aplicación escriba directamente en la tabla para saltárselo entero. En la lección siguiente, triggers, verás código que se dispara solo cuando algo pasa: BEFORE o AFTER de un INSERT, UPDATE o DELETE, con NEW y OLD, y con la capacidad de modificar o cancelar la operación en curso. Con él construirás la auditoría de cambios de precio, mantendrás al día un total desnormalizado, impedirás que un cliente reseñe un producto que no ha comprado —la regla que 09-02 dejó fuera del alcance de un CHECK— y descontarás stock automáticamente. Y verás sus peligros con el mismo detalle, porque un trigger es la única pieza del curso capaz de hacer que un UPDATE haga algo que tú no escribiste.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
