---
title: `set_config(…, true)` ne remet pas NULL : l'erreur 22P02 qui n'apparaît que sur une connexion réutilisée
description: Après set_config(…, true), le réglage revient à '' et non NULL : ''::uuid lève 22P02 sur une connexion réutilisée. Reproduction, correctif NULLIF, test.
category: SaaS & Product
format: article
pillar: developpement-web
tags: postgresql, rls, set_config, pooler, sqlx
author: Charlie Dallier-Wood
publishedAt: 2026-11-07
canonical: https://sycode.fr/blog/set-config-pooler-uuid-vide
---
Après `set_config('app.org_id', …, true)`, le réglage ne revient pas à `NULL` quand la transaction se termine : sur une connexion qui a déjà servi, `current_setting('app.org_id', true)` rend une chaîne vide, et `''::uuid` lève l'erreur `22P02`. Le correctif tient en une expression : `NULLIF(current_setting('app.org_id', true), '')::uuid`.

Le 19 mai 2026, le jour où j'ai ouvert le dépôt d'[asap.cool](/case-studies/asap-cool), le signup ne marchait qu'une fois par connexion. Le premier inscrit sur une connexion neuve passait. Le suivant, sur la même connexion rendue par le pool, prenait un 500 avec une erreur Postgres `22P02`. Le correctif est le commit `e0078122`, et c'est devenu la règle du projet : partout où un réglage de contexte est lu, il passe par `NULLIF`.

Petite précision avant de commencer : la doc PostgreSQL que je cite est celle de la version 18.6, lue le 26 octobre 2026, et la doc PgBouncer est lue le même jour. Si tu lis ça dans six mois, revérifie.

## Le symptôme : un 500 qui dépend de la connexion que tu reçois

`22P02` est, dans l'annexe des codes d'erreur, `invalid_text_representation`. Ici, c'est un cast `uuid` qui reçoit une chaîne vide. Ce qui rend le bug sournois, c'est qu'il ne dépend pas de ton code : il dépend de la connexion que le pool te rend. Neuve, tout passe. Réutilisée, ça casse. Un test d'intégration qui ouvre une connexion neuve à chaque fois ne le voit jamais, et à l'inscription, c'est l'écran que le futur client voit en premier.

Je n'ai pas gardé les logs du 19 mai. Le 5 novembre 2026, j'ai donc reproduit le cas avec sqlx 0.8.6, la version d'asap.cool, sur PostgreSQL 18.6 : un pool limité à une connexion, une transaction qui pose le contexte avec `set_config(…, true)`, puis une requête qui lit `current_setting('app.org_id', true)::uuid`. Voici ce que sqlx renvoie, avec le code tiré de l'erreur :

```text
error returned from database: invalid input syntax for type uuid: ""
code Some("22P02")
```

Les deux guillemets vides à la fin du message sont tout le diagnostic.

## Ce que `set_config` laisse derrière lui

La [doc](https://www.postgresql.org/docs/current/functions-admin.html) dit de `set_config(setting_name, new_value, is_local)` que si `is_local` vaut `true`, la nouvelle valeur « only apply during the current transaction ». Et de `current_setting(setting_name, missing_ok)` qu'avec `missing_ok` à `true`, un réglage qui n'existe pas renvoie `NULL`. J'attendais donc `NULL` en dehors de la transaction. Sur une connexion qui avait déjà posé le réglage, j'ai obtenu `''`. Dans un `psql`, ça tient en quelques lignes :

```sql
BEGIN;
SELECT set_config('app.org_id', '11111111-1111-1111-1111-111111111111', true);
COMMIT;

-- même connexion, nouvelle transaction
SELECT current_setting('app.org_id', true) IS NULL AS est_null,
       current_setting('app.org_id', true) = ''    AS est_vide;
SELECT current_setting('app.org_id', true)::uuid;
```

Sur PostgreSQL 18.6, les deux dernières requêtes répondent :

```text
 est_null | est_vide 
----------+----------
 f        | t

ERROR:  invalid input syntax for type uuid: ""
```

Dans une session neuve, sans le premier bloc, la même requête répond `est_null` à `t` et `est_vide` à `NULL` : le réglage n'existe pas, et le cast d'un `NULL` passe. C'est toute la différence entre le premier et le deuxième inscrit.

## Le correctif : `NULLIF`, partout

```sql
NULLIF(current_setting('app.org_id', true), '')::uuid
```

`NULLIF` transforme la chaîne vide en `NULL`, et un `uuid` `NULL` ne fait plus lever le cast. Dans une policy, la comparaison avec `NULL` n'est pas vraie, donc la ligne n'est pas renvoyée : la doc dit que les lignes dont l'expression ne renvoie pas `true` ne sont pas traitées. Sans contexte, la requête lit zéro ligne au lieu de tomber. Le cast dans une policy est d'ailleurs un endroit où une exception coûte cher : elle ne filtre pas, elle fait tomber la requête, et c'est ce que j'ai vu en 500.

## Derrière un pooler : `SET` ou `SET LOCAL`

Le cas du 19 mai n'avait pas de pooler externe : c'était un pool côté client, qui recycle des connexions. Un pooler comme PgBouncer ajoute un second niveau. En mode transaction, une connexion serveur n'est attribuée à un client que le temps d'une transaction, et la [doc de PgBouncer](https://www.pgbouncer.org/features.html) classe `SET`/`RESET` parmi les fonctions que ce mode ne supporte jamais : il casse « a few session-based features of PostgreSQL ». Côté PostgreSQL, un `SET` simple, une fois la transaction validée, [persiste jusqu'à la fin de la session](https://www.postgresql.org/docs/current/sql-set.html), alors que `SET LOCAL` ne dure que jusqu'à la fin de la transaction, validée ou non.

Le contexte se pose donc dans la même transaction que la requête qu'il protège, avec `set_config(…, true)`, l'équivalent de `SET LOCAL`. C'est ce que fait `OrgTx` sur asap.cool, qui pose le réglage dans son constructeur. Et ça ne dispense pas du `NULLIF`, comme le 19 mai l'a montré.

Pour le voir, j'ai monté le 5 novembre 2026 un PgBouncer 1.26.1 en `pool_mode = transaction` devant PostgreSQL 18.6, avec une seule connexion serveur, et j'ai ouvert deux clients `psql` l'un après l'autre. Le client A fait un `SET` simple, le client B lit le réglage :

```text
-- client A
SET app.org_id = '11111111-1111-1111-1111-111111111111';

-- client B, autre connexion cliente, même processus serveur (pid 233)
 pid_b |               vu_par_b               
-------+--------------------------------------
   233 | 11111111-1111-1111-1111-111111111111
```

B lit l'organisation de A. Avec une policy qui lit ce réglage, B voit les lignes de A, sans erreur. Même essai après redémarrage du pooler, avec `SET LOCAL` dans une transaction côté A :

```text
-- client B, même processus serveur (pid 246)
 pid_b | est_null | est_vide 
-------+----------+----------
   246 | f        | t
```

Plus de fuite, mais la chaîne vide revient : c'est exactement l'état qui a cassé mon signup. Le `SET LOCAL` protège l'isolation, le `NULLIF` protège la requête.

## Un test qui rejoue deux requêtes sur la même connexion

Pour voir l'erreur et la correction dans le même script, il faut deux transactions sur la même connexion et un rôle soumis à la RLS (un superuser contourne la policy, donc le cast ne s'exécute pas) :

```sql
-- exécuté par un superuser
CREATE ROLE demo_app LOGIN;
GRANT CREATE, USAGE ON SCHEMA public TO demo_app;
SET ROLE demo_app;

CREATE TABLE notes (id serial primary key, org_id uuid, body text);
INSERT INTO notes (org_id, body) VALUES ('11111111-1111-1111-1111-111111111111', 'A');
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes FORCE ROW LEVEL SECURITY;
CREATE POLICY isolation ON notes
  USING (org_id = current_setting('app.org_id', true)::uuid);   -- version fragile

BEGIN;
SELECT set_config('app.org_id', '11111111-1111-1111-1111-111111111111', true);
SELECT count(*) FROM notes;     -- 1
COMMIT;

BEGIN;
SELECT count(*) FROM notes;     -- ERROR:  invalid input syntax for type uuid: ""
COMMIT;                         -- la transaction a échoué : psql répond ROLLBACK
```

Les valeurs en commentaire sont celles de PostgreSQL 18.6. Remplace ensuite la policy par la version avec `NULLIF` : la seconde transaction renvoie `0`.

```sql
ALTER POLICY isolation ON notes
  USING (org_id = NULLIF(current_setting('app.org_id', true), '')::uuid);
```

Dans une suite de tests applicative, la même idée tient en limitant le pool à une connexion et en enchaînant deux transactions, la seconde sans contexte : le test échoue tant qu'un cast est resté sans `NULLIF`. C'est ce que fait la reproduction sqlx plus haut, avec son pool d'une connexion.

## Ce que cet incident dit de la RLS

L'isolation tient ici à un paramètre de session, et un paramètre vide n'est pas un paramètre absent. C'est pour ça que, le 12 juillet 2026, j'ai remplacé la fonction `set_org_context` par `OrgTx`, qui pose le contexte dans son constructeur, comme je le raconte dans [l'article sur l'architecture](/blog/choisir-architecture-technique-saas). Reste à vérifier que le rôle qui exécute tout ça est bien soumis à la RLS, ce que fait [l'article sur le rôle propriétaire](/blog/rls-contournee-par-le-role), avec les requêtes à lancer.
