Les bases de PL/pgSQL
Une requête SQL répond à une question. Mais pour calculer une moyenne élève par élève, décider d'une mention ou refuser une note hors de l'échelle, il faut des variables, des conditions et des boucles. C'est le rôle de PL/pgSQL, le langage procédural de PostgreSQL : le code s'exécute dans le serveur, au plus près des données.
Après cette fiche, tu sais
- écrire un bloc anonyme
DOet nommer ses parties : DECLARE, BEGIN, EXCEPTION, END ; - déclarer des variables et des constantes, leur donner une valeur avec
:=ouSELECT … INTO; - choisir entre IF et CASE, et entre les boucles LOOP, WHILE et FOR ;
- afficher un message ou lever une erreur avec
RAISE, et déjouer les pièges du NULL.
Le code va vers les données
SQL est un langage déclaratif : une requête décrit le résultat voulu, et le serveur choisit comment l'obtenir. Entre deux requêtes, SQL n'a ni variable, ni boucle, ni « si… alors ». PL/pgSQL (Procedural Language/PostgreSQL) ajoute ces outils et regroupe calculs et requêtes à l'intérieur du serveur : pas d'allers-retours supplémentaires entre le client et le serveur, pas de résultats intermédiaires à transférer, des requêtes analysées une seule fois.1
Le code PL/pgSQL se range dans un bloc anonyme (DO, exécuté une fois), une fonction, une procédure ou un déclencheur.1 Cette fiche travaille avec le bloc anonyme ; les autres ont leur fiche dans la série.
eleves(id, prenom, classe) : 1 Léa 2M3, 2 Noah 2M3, 3 Zoé 2M3, 4 Elias 2M1. notes(eleve_id, branche, note), notes de 1 à 6 : Léa 5.5 et 5, Noah 3 et 4.5, Zoé 6 et 5.5, Elias 4. Tous les résultats indiqués en commentaire ont été obtenus avec ces données dans PostgreSQL 18.Quatre allers-retours, ou un seul
Fais défiler : l'application cherche les élèves de 2M3 dont la moyenne est sous 4, d'abord requête par requête, puis avec un bloc PL/pgSQL.
Sans PL/pgSQL : une requête à la fois
GROUP BY et HAVING avg(note) < 4 trouverait la même liste en un seul aller-retour. PL/pgSQL devient utile quand la logique a besoin de variables, de conditions et de boucles qu'une requête seule n'exprime pas.DECLARE, BEGIN, END : l'anatomie d'un bloc
Tout code PL/pgSQL est rangé dans un bloc. Le plus simple à lancer est le bloc anonyme : la commande DO l'exécute une seule fois, sans rien enregistrer dans la base.3
DO $$
DECLARE
v_prenom TEXT := 'Léa'; -- une variable et sa valeur de départ
BEGIN
RAISE NOTICE 'Bonjour %', v_prenom;
END $$;
NOTICE: Bonjour Léa
Exécute un bloc anonyme, traité comme le corps d'une fonction sans paramètre qui ne renvoie rien. Langage par défaut : plpgsql. DO n'existe pas dans la norme SQL.3
Le corps est une chaîne délimitée par des dollars : les apostrophes qu'elle contient n'ont pas à être doublées. On peut l'étiqueter : $corps$ … $corps$.3
Chaque déclaration et chaque instruction se termine par ;. Un bloc placé dans un autre bloc prend aussi un ; après son END.1
-- jusqu'à la fin de la ligne, /* … */ sur plusieurs lignes. Les mots-clés ignorent la casse : begin vaut BEGIN.1
Dans un bloc PL/pgSQL, BEGIN et END servent seulement à regrouper des instructions : ils ne démarrent ni ne terminent aucune transaction, contrairement aux commandes SQL BEGIN et COMMIT.1
Un bloc complet
Ce bloc compte les élèves. S'il ne trouve pas la table, il ne plante pas : sa section EXCEPTION prend le relais.
DO $$
DECLARE
v_nb INTEGER;
BEGIN
SELECT count(*) INTO v_nb FROM eleves;
RAISE NOTICE '% élèves', v_nb;
EXCEPTION
WHEN undefined_table THEN
RAISE NOTICE 'Table eleves absente';
END $$;
NOTICE: 4 élèves
Le bloc éclaté
Fais défiler : les cinq parties du bloc se séparent et se nomment, puis on ne garde que les parties obligatoires.
Un bloc complet
Reconstruis le bloc
Les six lignes du premier bloc ont été mélangées : remets-les dans l'ordre.
Variables, constantes et types
Une variable se déclare dans la section DECLARE : un nom, un type SQL, et éventuellement une valeur de départ. On lui donne ensuite une valeur avec := (le signe = est aussi accepté).2
nom [CONSTANT] type [NOT NULL] [:= valeur];
Exemple : trois articles à 12.50 CHF, avec le taux normal de la TVA suisse, 8,1 %.5
DO $$
DECLARE
c_tva CONSTANT NUMERIC := 0.081; -- taux normal de la TVA suisse
v_prix NUMERIC := 12.50;
v_qte INTEGER := 3;
v_total NUMERIC; -- pas de valeur : NULL
BEGIN
v_total := v_prix * v_qte; -- 37.50
v_total := round(v_total * (1 + c_tva), 2); -- 40.54
RAISE NOTICE 'Total TTC : % CHF', v_total;
END $$;
NOTICE: Total TTC : 40.54 CHF
La mémoire du bloc
Fais défiler : le bloc s'exécute ligne par ligne. En dessous, chaque variable naît, change de valeur, puis disparaît avec le bloc.
Le bloc démarre
L'expression de droite est calculée par le moteur SQL, puis rangée dans la variable de gauche. Elle doit donner une seule valeur.2
Une variable déclarée sans valeur vaut NULL, la valeur « inconnue » de SQL.1
La valeur fixée à la déclaration ne change plus pendant le bloc. Toute affectation est refusée : variable "c_tva" is declared CONSTANT.1
Affecter NULL provoque une erreur ; la variable doit donc recevoir une valeur de départ non nulle.1
Quel type choisir ?
| Type | Contient | Exemple |
|---|---|---|
INTEGER | un entier | 3 |
NUMERIC | un nombre exact : montants, notes | 12.50 |
TEXT | du texte | 'Léa' |
BOOLEAN | vrai ou faux | true |
DATE | une date du calendrier | '2026-10-01' |
Deux raccourcis évitent de recopier les types des tables : %TYPE reprend le type d'une colonne, %ROWTYPE déclare une ligne entière, dont on lit les champs avec un point.1
DO $$
DECLARE
v_prenom eleves.prenom%TYPE; -- le type de la colonne prenom
v_eleve eleves%ROWTYPE; -- une ligne entière de la table
BEGIN
SELECT * INTO v_eleve FROM eleves WHERE id = 3;
v_prenom := v_eleve.prenom;
RAISE NOTICE '% est en %', v_prenom, v_eleve.classe;
END $$;
NOTICE: Zoé est en 2M3
Après v_total NUMERIC;, l'instruction v_total := v_total + 10; laisse v_total à NULL, car NULL + 10 donne NULL ; RAISE affiche alors <NULL>. On initialise donc sommes et compteurs : v_total NUMERIC := 0;
Entre deux entiers, / tronque le résultat : 7 / 2 vaut 3. Pour obtenir 3.5, l'un des deux doit être un NUMERIC : v_a::numeric / v_b.3
Que va afficher ce bloc ?
Quatre blocs courts, tous exécutés dans PostgreSQL 18. Lis-les comme le ferait le serveur.
Lire la base dans des variables
Dans un bloc, une requête qui renvoie des lignes doit dire où ranger son résultat : c'est la clause INTO. Les colonnes du SELECT remplissent les variables dans l'ordre.2
DO $$
DECLARE
v_prenom eleves.prenom%TYPE;
v_moyenne NUMERIC;
BEGIN
SELECT e.prenom, round(avg(n.note), 2)
INTO v_prenom, v_moyenne
FROM eleves e JOIN notes n ON n.eleve_id = e.id
WHERE e.id = 2
GROUP BY e.prenom;
RAISE NOTICE '% : moyenne %', v_prenom, v_moyenne;
END $$;
NOTICE: Noah : moyenne 3.75
Que se passe-t-il si la requête ne trouve rien, ou trouve trop ? Fais défiler : cinq requêtes, cinq réactions de PostgreSQL.
WHERE id = 2 trouve exactement une ligne : v_prenom reçoit 'Noah' et la variable spéciale FOUND passe à vrai.2
WHERE id = 9 ne trouve rien. Aucune erreur : v_prenom passe à NULL et FOUND à faux. En Oracle PL/SQL, le même SELECT INTO lèverait l'exception NO_DATA_FOUND.4
Trois élèves en 2M3 : sans STRICT, PostgreSQL garde la première ligne et ignore les autres. Sans ORDER BY, « la première » n'est pas définie.2
Avec INTO STRICT, il faut exactement une ligne. Trois lignes : erreur TOO_MANY_ROWS, message query returned more than one row.
Aucune ligne avec STRICT : erreur NO_DATA_FOUND, message query returned no rows. Une section EXCEPTION peut rattraper l'une ou l'autre.2
DO $$
DECLARE
v_prenom TEXT;
BEGIN
SELECT prenom INTO v_prenom FROM eleves WHERE id = 9;
IF NOT FOUND THEN
RAISE NOTICE 'Aucun élève avec l''id 9';
END IF;
END $$;
NOTICE: Aucun élève avec l'id 9
Dans un bloc, SELECT prenom FROM eleves; est refusé : query has no destination for result data. Pour exécuter une requête en ignorant son résultat, on remplace SELECT par PERFORM.2
Après un UPDATE, un INSERT ou un DELETE, GET DIAGNOSTICS donne le nombre de lignes traitées par la dernière commande.2
DO $$
DECLARE
v_n INTEGER;
BEGIN
UPDATE eleves SET classe = '3M3' WHERE classe = '2M3';
GET DIAGNOSTICS v_n = ROW_COUNT;
RAISE NOTICE '% élève(s) passent en 3M3', v_n;
END $$;
NOTICE: 3 élève(s) passent en 3M3
IF et CASE : choisir un chemin
IF teste ses conditions dans l'ordre et exécute la première branche vraie ; les conditions suivantes ne sont même pas testées. ELSIF (qu'on peut aussi écrire ELSEIF) ajoute un test, ELSE attrape tout le reste, END IF; ferme la structure.2
DO $$
DECLARE
v_note NUMERIC := 4.5;
v_mention TEXT;
BEGIN
IF v_note >= 5.5 THEN
v_mention := 'très bien';
ELSIF v_note >= 5 THEN
v_mention := 'bien';
ELSIF v_note >= 4 THEN
v_mention := 'suffisant';
ELSE
v_mention := 'insuffisant';
END IF;
RAISE NOTICE 'Note % : %', v_note, v_mention;
END $$;
NOTICE: Note 4.5 : suffisant
La première branche vraie
Fais défiler : la note monte de 1 à 6 et la branche exécutée suit. Puis on inverse l'ordre des tests, pour voir le piège.
Tests du plus exigeant au moins exigeant
Si v_note >= 4 est testé en premier, une note de 5.5 s'arrête là : « suffisant ». On teste du plus exigeant au moins exigeant.
Une comparaison avec NULL donne NULL, et une condition NULL n'est pas vraie : si v_note vaut NULL, IF v_note >= 4 passe dans le ELSE, sans erreur.
CASE
CASE choisit une branche selon une valeur (CASE simple : CASE v_classe WHEN '2M1' THEN …) ou selon des conditions (CASE recherché). Si aucune branche ne convient et qu'il n'y a pas de ELSE, PostgreSQL lève l'erreur CASE_NOT_FOUND.2
DO $$
DECLARE
v_note NUMERIC := 3.5;
v_statut TEXT;
BEGIN
CASE
WHEN v_note >= 4 THEN v_statut := 'réussi';
WHEN v_note < 4 THEN v_statut := 'échoué';
END CASE;
RAISE NOTICE 'Statut : %', v_statut;
END $$;
NOTICE: Statut : échoué
Les deux branches semblent couvrir toutes les notes. Pourtant, avec v_note à NULL, aucune n'est vraie : le bloc s'arrête sur l'erreur case not found. Un ELSE règle le problème.
LOOP, WHILE, FOR : répéter
Trois boucles pour une même somme, 1 + 2 + 3 + 4 + 5 = 15.
DO $$
DECLARE
v_i INTEGER := 1;
v_somme INTEGER := 0;
BEGIN
LOOP -- 1. boucle simple
v_somme := v_somme + v_i;
v_i := v_i + 1;
EXIT WHEN v_i > 5; -- sans EXIT : boucle infinie
END LOOP;
RAISE NOTICE 'LOOP : %', v_somme;
v_i := 1; v_somme := 0;
WHILE v_i <= 5 LOOP -- 2. tant que la condition est vraie
v_somme := v_somme + v_i;
v_i := v_i + 1;
END LOOP;
RAISE NOTICE 'WHILE : %', v_somme;
v_somme := 0;
FOR i IN 1..5 LOOP -- 3. i est déclaré automatiquement
v_somme := v_somme + i;
END LOOP;
RAISE NOTICE 'FOR : %', v_somme;
END $$;
NOTICE: LOOP : 15 NOTICE: WHILE : 15 NOTICE: FOR : 15
Répète sans fin. EXIT ou EXIT WHEN condition en sort ; CONTINUE WHEN condition passe au tour suivant.2
Teste la condition avant chaque tour : si elle est fausse dès le départ, la boucle ne tourne pas.2
Compte d'une borne à l'autre, par pas de 1 ou avec BY. La variable de boucle est déclarée automatiquement comme entier et n'existe que dans la boucle.2
Parcourt les lignes d'un SELECT, une par tour, dans une variable RECORD ou %ROWTYPE.2
La trace d'une boucle FOR
Fais défiler : la boucle de 1 à 5 tour après tour, puis à rebours avec REVERSE, puis un tour sur deux avec BY 2.
FOR i IN 1..5
FOR i IN REVERSE 1..5 compte de 5 à 1.4 PostgreSQL attend la borne haute en premier, REVERSE 5..1 ; écrit REVERSE 1..5, la boucle n'y fait aucun tour, et aucune erreur n'est levée.2Parcourir le résultat d'une requête
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN SELECT prenom, classe FROM eleves ORDER BY prenom LOOP
RAISE NOTICE '% (%)', r.prenom, r.classe;
END LOOP;
END $$;
NOTICE: Elias (2M1) NOTICE: Léa (2M3) NOTICE: Noah (2M3) NOTICE: Zoé (2M3)
Pour FOR i IN 1..5, i est déclaré tout seul. Pour FOR r IN SELECT …, r doit être déclarée (RECORD ou %ROWTYPE), sinon le bloc est refusé : loop variable of loop over rows must be a record variable or list of scalar variables.
En PostgreSQL, le reste d'une division s'écrit i % 2 ou mod(i, 2). CONTINUE WHEN i MOD 2 = 0; provoque une erreur de syntaxe.3
Combien de tours ?
Cinq débuts de boucle, en PostgreSQL. Combien de fois le corps de la boucle s'exécute-t-il ?
Afficher un message, lever une erreur
RAISE envoie un message de niveau DEBUG, LOG, INFO, NOTICE ou WARNING, ou lève une erreur avec EXCEPTION, le niveau par défaut. Chaque % du texte est remplacé par l'argument suivant, %% affiche un % ; s'il manque un argument, le bloc est refusé avant même de s'exécuter.3
Par défaut, le client reçoit INFO, NOTICE et WARNING, mais pas DEBUG ni LOG : c'est le réglage client_min_messages, qui vaut NOTICE.3
DO $$
DECLARE
v_note NUMERIC := 7;
BEGIN
IF v_note NOT BETWEEN 1 AND 6 THEN
RAISE EXCEPTION 'Note % hors de l''échelle 1 à 6', v_note;
END IF;
RAISE NOTICE 'Note acceptée';
END $$;
ERROR: Note 7 hors de l'échelle 1 à 6
L'erreur arrête le bloc : « Note acceptée » ne s'affiche jamais. Pour rattraper une erreur, le bloc reçoit une section EXCEPTION, qui nomme les erreurs attendues.2
DO $$
DECLARE
v_prenom TEXT;
BEGIN
SELECT prenom INTO STRICT v_prenom FROM eleves WHERE id = 9;
RAISE NOTICE 'Trouvé : %', v_prenom;
EXCEPTION
WHEN no_data_found THEN
RAISE NOTICE 'Aucun élève avec l''id 9';
END $$;
NOTICE: Aucun élève avec l'id 9
Accepté ou refusé par PostgreSQL ?
Chaque ligne est placée dans un bloc DO par ailleurs correct, où les variables sont déclarées et où c_tva est une constante. Toutes ont été testées dans PostgreSQL 18. Range chacune.
Sept bonnes pratiques
- préfixer les noms :
v_pour les variables,c_pour les constantes ; ils ne se confondent plus avec les colonnes ; - initialiser sommes et compteurs (
:= 0) : NULL contamine les calculs ; - déclarer avec
%TYPE: la variable suit le type de la colonne s'il change ; - écrire
STRICTou testerFOUNDquand l'absence d'une ligne compte ; - ordonner les tests d'un IF du plus exigeant au moins exigeant, et prévoir un
ELSEdans chaque CASE ; - donner à chaque LOOP une sortie claire (
EXIT WHEN) ; - choisir NUMERIC pour l'argent et les notes, et se méfier de la division entière.
Trois exercices corrigés
Avec les tables eleves et notes de la fiche. Tous les corrigés ont été exécutés dans PostgreSQL 18.
1Écris un bloc qui affiche la table de multiplication de 7, de « 7 x 1 = 7 » à « 7 x 10 = 70 ».Voir le corrigé
DO $$
BEGIN
FOR i IN 1..10 LOOP
RAISE NOTICE '7 x % = %', i, 7 * i;
END LOOP;
END $$;
FOR convient : on connaît le nombre de tours. La variable i n'a pas besoin d'être déclarée.
2Avec une boucle WHILE, calcule 10! (1 × 2 × … × 10) et affiche le résultat.Voir le corrigé
DO $$
DECLARE
v_n INTEGER := 10;
v_resultat BIGINT := 1;
BEGIN
WHILE v_n > 1 LOOP
v_resultat := v_resultat * v_n;
v_n := v_n - 1;
END LOOP;
RAISE NOTICE '10! = %', v_resultat;
END $$;
NOTICE: 10! = 3628800
Le produit démarre à 1, pas à NULL ni à 0. BIGINT voit loin : avec INTEGER, 13! (6 227 020 800) provoque déjà l'erreur integer out of range.
3Ce bloc doit compter les élèves dont la moyenne est sous 4. Il contient trois erreurs : trouve-les.Voir le corrigé
DO $$
DECLARE
v_nb INTEGER;
BEGIN
FOR r IN SELECT e.prenom, avg(n.note) AS moyenne
FROM eleves e JOIN notes n ON n.eleve_id = e.id
GROUP BY e.prenom LOOP
IF r.moyenne < 4
v_nb := v_nb + 1;
END IF;
END LOOP;
RAISE NOTICE '% élève(s) sous 4', v_nb;
END $$;
1) r n'est pas déclarée : il faut r RECORD; dans DECLARE. 2) Il manque THEN après la condition. 3) v_nb vaut NULL au départ : le résultat afficherait <NULL>. Version corrigée :
DO $$
DECLARE
r RECORD;
v_nb INTEGER := 0;
BEGIN
FOR r IN SELECT e.prenom, avg(n.note) AS moyenne
FROM eleves e JOIN notes n ON n.eleve_id = e.id
GROUP BY e.prenom LOOP
IF r.moyenne < 4 THEN
v_nb := v_nb + 1;
END IF;
END LOOP;
RAISE NOTICE '% élève(s) sous 4', v_nb;
END $$;
NOTICE: 1 élève(s) sous 4
La fiche en huit lignes
Teste-toi
Sources
- PostgreSQL 18 Documentation, PL/pgSQL : Overview (avantages du langage), Structure of PL/pgSQL (blocs, point-virgule, BEGIN sans transaction), Declarations (NULL par défaut, CONSTANT, NOT NULL, %TYPE, %ROWTYPE, RECORD, variable de boucle). vue d'ensemble, structure, déclarations (postgresql.org)
- PostgreSQL 18 Documentation, PL/pgSQL : Basic Statements (affectation, SELECT INTO et STRICT, FOUND, PERFORM, GET DIAGNOSTICS) et Control Structures (IF, CASE, LOOP, WHILE, FOR, REVERSE, interception des erreurs). instructions de base, structures de contrôle (postgresql.org)
- PostgreSQL 18 Documentation : DO, Lexical Structure (chaînes entre dollars), Mathematical Functions and Operators (division entière, %, mod), PL/pgSQL Errors and Messages (RAISE) et client_min_messages. DO, syntaxe, opérateurs, RAISE, client_min_messages (postgresql.org)
- Oracle, Database PL/SQL Language Reference 19c : « Block », « FOR LOOP Statement » (REVERSE), « SELECT INTO Statement » (NO_DATA_FOUND). bloc, FOR LOOP, SELECT INTO (docs.oracle.com)
- Administration fédérale des contributions (AFC), Taux de TVA en Suisse : taux normal de 8,1 % depuis le 1er janvier 2024. estv.admin.ch
Fiche écrite et sourcée en octobre 2026. Tout le code a été exécuté dans PostgreSQL 18.