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

  1. Función frente a procedimiento
  2. CREATE FUNCTION con LANGUAGE sql
  3. PL/pgSQL en una tabla de referencia
  4. Parámetros, tipos de retorno, sobrecarga y borrado
  5. fn_total_pedido: una función escalar
  6. fn_ventas_por_categoria: una función que devuelve tabla
  7. sp_confirmar_pedido: el procedimiento de 09-03
  8. Volatilidad y SECURITY DEFINER
  9. La discusión honesta: qué poner dentro y qué no
  10. Errores Comunes y Consejos
  11. Ejercicios
  12. Conclusión

  1. 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? : es una expresión más No
¿Controla transacciones? No. Corre dentro de la del llamante : 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.

  1. CREATE FUNCTION con LANGUAGE sql

La 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 llamara pais, PostgreSQL no sabría a qué te refieres en el WHERE.
  • RETURNS NUMERIC declara el tipo. Con LANGUAGE sql, el resultado es el de la última sentencia.
  • STABLE es 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.)

  1. 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.

  1. 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 llamadaSELECT * 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.

  1. fn_total_pedido: una función escalar

El 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 pedidos ejecuta la función una vez por fila — 20 consultas independientes contra lineas_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 un SELECT masivo es un JOIN disfrazado, y casi siempre es mejor escribir el JOIN (o usar la vista de 10-01).

  1. fn_ventas_por_categoria: una función que devuelve tabla

Con 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.

  1. sp_confirmar_pedido: el procedimiento de 09-03

Este 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.

  1. Volatilidad y SECURITY DEFINER

Toda 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). Con SECURITY DEFINER corre 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 un search_path manipulable, es una escalada de privilegios de manual. Los permisos, los roles y cómo blindar estas funciones son materia de 11-03.

  1. 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 sentencia
SQL Server T-SQL CREATE PROCEDURE ... AS BEGIN ... END, EXEC, @variables, TRY...CATCH
Oracle PL/SQL El antecesor de PL/pgSQL: se parecen mucho, pero los paquetes (PACKAGE) no tienen equivalente
SQLite 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, CALL sobre una función da error.
  • Esperar que un COMMIT dentro de una función funcione. No puede: cannot commit while a subtransaction is active o invalid transaction termination. Solo los procedimientos, y solo si el CALL no está dentro de una transacción explícita.
  • Dejar la volatilidad por omisión. Todo es VOLATILE si 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). Marca STABLE lo 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 WHERE deja de filtrar. Prefijo p_ siempre.
  • Confundir el BEGIN de 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 ELSEIF o ELSE IF. En PL/pgSQL es ELSIF.
  • Consejo: guarda el código en el repositorio, en ficheros .sql con CREATE 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 NOTICE es tu depurador. No hay mucho más, y con RAISE 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:

  1. "El precio de venta nunca puede ser negativo."
  2. "Al confirmar un pedido hay que enviar un correo de confirmación al cliente."
  3. "Cada noche hay que recalcular el ranking de productos más vendidos del mes."
  4. "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 con CALL, no devuelve nada (salvo por INOUT) y sí puede hacer COMMIT/ROLLBACK. Si devuelve un dato, función; si hace un trabajo, procedimiento.
  • LANGUAGE sql basta para encapsular una consulta con parámetros, y es la primera opción. PL/pgSQL añade DECLARE, IF/ELSIF, FOR ... IN SELECT, RETURN QUERY, RAISE NOTICE/EXCEPTION y el bloque EXCEPTION WHEN — con el aviso de que su BEGIN no 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) y sp_confirmar_pedido, donde vive por fin la lógica de 09-03: inserta, descuenta stock con FOR UPDATE, lanza RAISE EXCEPTION si falta y deja la base intacta si algo falla.
  • La volatilidad importa: VOLATILE es el valor por omisión y significa "reevalúame en cada fila"; IMMUTABLE es requisito para un índice de expresión (08-02) y STABLE es lo correcto para casi toda función de solo lectura. SECURITY DEFINER da 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

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados