Décodage Base64 en SQL : un guide complet
Ouvrez n'importe quelle base de données de production assez longtemps et vous croiserez le déguisement. Un avatar arrivé sous forme de mur de lettres dans une exportation JSON. Un JWT garé dans une colonne varchar à côté d'un identifiant utilisateur. Un certificat que quelqu'un a décidé d'envoyer en chaîne de caractères, parce que le format de transfert n'avait pas de binaire. Quelque part dans une table, vos données portent des lettres, et votre travail est de les retirer sans quitter la base de données.
En SQL, ce travail a une propriété très réconfortante : une fois que vous savez quel décodeur votre dialecte parle, tout le travail se réduit à un appel de fonction. Le format lui-même est déjà expliqué en détail sur la page d'accueil (64 caractères imprimables, chaque groupe de quatre représentant trois octets en entrée, au plus deux signes = de padding dans le groupe final), alors cet article ne répète pas le cours. Deux choses à garder en poche : le base64 est une façon de habiller des octets en texte, pas un cadenas, et le décodage est la direction où les données deviennent plus petites (elles retournent aux trois quarts de leur taille encodée), ce qui est exactement l'inverse de ce pour quoi votre colonne de stockage a été dimensionnée. La vraie histoire est que SQL est une famille de dialectes, et que chaque membre appelle son décodeur par un nom différent et réagit aux mauvaises entrées avec un tempérament totalement différent. Cet article est le tour d'horizon.
Le line-up des décodeurs
Voici qui est de service, et comment chacun se comporte quand l'entrée est n'importe quoi. La colonne « quand ça casse » compte, parce qu'un décodeur qui échoue bruyamment en staging et silencieusement en production, c'est exactement comme ça que des avatars manquants finissent sur le terrain :
| Dialecte | L'appel | Ce qui revient | Quand ça casse | Depuis quand |
|---|---|---|---|---|
| MySQL 8.x / MariaDB 10.x | FROM_BASE64(str) |
chaîne binaire | NULL silencieux |
MySQL 5.6 (2013) |
| PostgreSQL | decode(str, 'base64') |
bytea |
ERROR bruyant avec un indice |
7.2 (2002) |
| SQLite (CLI 3.41+) | base64(str) |
BLOB |
saute ce qu'il ne peut pas lire | 3.41.0 (2023) |
| DuckDB | from_base64(str) |
BLOB |
erreur de conversion | versions modernes |
| ClickHouse 18.16+ | base64Decode(str) |
String |
exception (INCORRECT_DATA) |
18.16.0 (2018) |
| SQL Server 2025+ | BASE64_DECODE(str) |
varbinary |
Msg 9803, trois états | 2025 |
| Oracle | UTL_ENCODE.BASE64_DECODE(raw) |
RAW |
exception PL/SQL | ère 9i |
| Snowflake | BASE64_DECODE_BINARY(str) |
BINARY |
erreur, ou NULL avec la variante TRY_ |
versions actuelles |
Regardez la forme du tableau : le nom de la fonction n'est jamais la partie difficile. La partie difficile, c'est la colonne « quand ça casse », parce que c'est elle qui décide si votre rapport perd silencieusement des lignes ou si votre job par lot s'arrête et demande de l'aide.
MySQL et MariaDB : le décodeur qui hausse les épaules
Les deux serveurs partagent le duo TO_BASE64() / FROM_BASE64(). Le décodeur prend une chaîne et rend une chaîne binaire : une séquence d'octets sans jeu de caractères attenant. Un NULL en entrée donne un NULL en sortie, et voici la première chose à mémoriser : tout le reste qui n'est pas du base64 valide est aussi un NULL, sans avertissement. Le décodeur hausse les épaules, et votre requête continue allègrement son chemin.
SELECT FROM_BASE64('aGVsbG8=') AS restored;
SELECT HEX(FROM_BASE64('aGVsbG8=')) AS as_hex;
SELECT CONVERT(FROM_BASE64('aGVsbG8gd29ybGQ=') USING utf8mb4) AS as_text;
La ligne du milieu mérite un commentaire, parce qu'elle explique un moment de confusion classique. Le client en ligne de commande mysql affiche les chaînes binaires en notation hexadécimale par défaut (un réglage appelé binary-as-hex), si bien qu'un simple SELECT FROM_BASE64('aGVsbG8=') montre 0x68656C6C6F au lieu de hello. Ce n'est ni un bug ni une corruption ; c'est le client qui fait preuve de prudence avec les données binaires. Si vous voulez des lettres, convertissez avec CONVERT(... USING utf8mb4) ou lancez le client avec --binary-as-hex=0 ; l'appel HEX() de la ligne du milieu est la version délibérée de l'hexa que le client vous montre par défaut.
Passons aux règles que le décodeur silencieux applique. Après avoir ignoré les espaces, les caractères restants doivent former un multiple de quatre, chaque caractère doit venir de l'alphabet standard (lettres, chiffres, +, / et =), et le padding ne peut apparaître qu'à la toute fin :
SELECT FROM_BASE64('aGVsbG8gd29ybGQ=') AS ok;
SELECT FROM_BASE64('aGVsbG8gd29ybGQ') AS missing_padding;
SELECT FROM_BASE64('!!!') AS nonsense;
Les trois lignes s'exécutent sans se plaindre, et les lignes deux et trois renvoient NULL. Padding manquant, longueur fausse, caractères venus d'ailleurs : même haussement d'épaules. Les espaces sont l'unique indulgence ; retours à la ligne, retours chariot, tabulations et espaces sont tous ignorés, ce qui est une vraie grâce pour tout ce qui a d'abord transité par un e-mail. L'alphabet URL-safe, lui, reçoit le haussement d'épaules en retour : un tiret bas ne figure pas dans la table standard, donc FROM_BASE64('yv7K_g==') est un NULL alors même que la longueur est un multiple de quatre sans faute. Il faut traduire l'alphabet vous-même avant l'appel, et la section URL-safe ci-dessous montre comment.
Un dernier trait à connaître : le décodeur et l'encodeur sont un duo assorti. L'encodeur découpe sa sortie en lignes de 76 caractères, et le décodeur mange ces retours à la ligne pour le petit-déjeuner. Si une colonne a été remplie par TO_BASE64() dans cette même famille de bases de données, le décodage est un aller-retour parfait. Si elle a été remplie par autre chose, poursuivez votre lecture.
PostgreSQL : le décodeur qui lève la voix
PostgreSQL porte le base64 dans son noyau depuis au moins la version 7.2, en 2002, ce qui en fait le mécanisme base64 le plus ancien de cette famille, de justesse. L'appel est decode(string, 'base64'), et le résultat est bytea, le type binaire natif de la base de données. Le compagnon encode(bytea, 'base64') va dans l'autre sens et n'est mentionné ici que parce que les deux partagent un même contrat de mise en forme : le style RFC 2045, avec des lignes coupées à 76 caractères. Le décodeur, lui, ignore retours chariot, retours à la ligne, espaces et tabulations n'importe où dans l'entrée.
SELECT decode('aGVsbG8gd29ybGQ=', 'base64') AS bytes;
SELECT length(decode('aGVsbG8gd29ybGQ=', 'base64')) AS byte_count;
SELECT convert_from(decode('aMOpbGxv', 'base64'), 'UTF8') AS text;
La troisième ligne est celle à laquelle vous reviendrez sans cesse : convert_from() transforme le bytea en texte dans un encodage nommé, et c'est l'étape du jeu de caractères dont les données binaires ont besoin (on y revient dans sa propre section plus tard). aMOpbGxv revient sous la forme héllo, caractère accentué inclus.
Le point où PostgreSQL se détache du reste du lot, c'est la colonne « quand ça casse ». Une entrée invalide est une erreur sèche, et le message d'erreur vous dit exactement quelle règle a été violée :
- un caractère hors de l'alphabet :
ERROR: invalid symbol "!" found while decoding base64 sequence - un signe de padding au milieu de la chaîne :
ERROR: unexpected "=" while decoding base64 sequence - une entrée tronquée ou un padding manquant :
ERROR: invalid base64 end sequence, avec l'indice Input data is missing padding, is truncated, or is otherwise corrupted. - un tiret bas URL-safe :
ERROR: invalid symbol "_" found while decoding base64 sequence
Pour un travail de nettoyage de données, cette voix est un atout. La requête échoue, vous voyez la ligne, vous corrigez la source. Le revers, c'est qu'une ligne empoisonnée sur un million arrête tout le lot, si bien qu'aux pipelines de production on pré-filtre souvent avec une expression régulière avant d'appeler decode(). Et une petite note d'affichage : psql affiche le bytea en hexa avec le préfixe \x, donc \x68656c6c6f est le même « hello » que le client MySQL montre sous la forme 0x68656C6C6F. Deux dialectes, deux dialectes d'hexa.
SQL Server : l'arrivée en retard
Voici la surprise de toute la famille. SQL Server a livré BASE64_DECODE() en version 2025, disponible en général en novembre 2025. Avant cela, la base de données la plus populaire du monde de l'entreprise n'avait aucun décodeur base64 intégré depuis trente-six ans, et le folklore était riche en astuces de contournement. La fonction moderne est une fonction propre : elle prend une expression varchar(n) ou varchar(max) et renvoie un varbinary (une expression varchar(n) se mappe sur varbinary(8000), et une expression varchar(max) se mappe sur varbinary(max)), avec NULL qui passe tout droit.
SELECT BASE64_DECODE('aGVsbG8gd29ybGQ=') AS bytes;
SELECT CONVERT(VARCHAR(100), BASE64_DECODE('aGVsbG8gd29ybGQ=')) AS text;
SELECT BASE64_DECODE('yv7K_g') AS url_safe_also_works;
Cette troisième ligne est une touche vraiment sympathique : le décodeur accepte les deux alphabets de la RFC 4648, le standard avec + et / et le URL-safe avec - et _, et le padding est optionnel. Il ignore aussi les quatre caractères d'espaces (retour à la ligne, retour chariot, tabulation, espace). Quand il casse, l'erreur est Msg 9803, Level 16 avec le texte Invalid data for type « Base64Decode », et la valeur State vous dit quelle règle vous avez touchée : état 20 pour un caractère qui n'est dans aucun des deux alphabets, état 21 pour des caractères tous valides mais agencés dans une forme que le base64 ne peut pas produire, et état 23 pour un padding qui apparaît trop souvent ou trop tôt.
Si vous êtes coincé sur une version antérieure à 2025, l'astuce de contournement classique emprunte le type XML, qui comprend le base64 depuis l'ère des schémas XML :
SELECT CAST(N'' AS XML)
.value('xs:base64Binary("aGVsbG8=")', 'VARBINARY(MAX)') AS legacy;
Le moteur XML décode la constante en base64 et rend les octets. Ça marche, et c'est ce qu'une génération de développeurs SQL Server a utilisé. Ça a aussi des bords : le type base64Binary est strict sur la forme, donc une chaîne enveloppée en MIME avec des retours à la ligne à l'intérieur ne passera pas le parsing, et vous payez le prix du mécanisme XML pour un travail qu'une seule fonction fait désormais nativement. Traitez-le comme la pièce de musée qu'il est devenu.
SQLite : le dialecte sans décodeur
SQLite est l'ovni de la famille, et comprendre pourquoi vous dit comment l'utiliser. La bibliothèque de base est un petit moteur embarquable, et le base64 n'est pas dans sa liste de fonctions standard. Si une colonne contient du base64, le décodeur doit venir de l'un de quatre endroits : le shell en ligne de commande, une extension chargeable, une fonction personnalisée enregistrée par l'application hôte, ou le pur SQL. Voici chacun d'eux.
La CLI. À partir de la version 3.41.0 (février 2023), le shell en ligne de commande sqlite3 livre une fonction base64(). Elle décode un argument texte en BLOB, ce qui la rend parfaite pour un travail exploratoire directement depuis un terminal :
$ sqlite3 app.db "SELECT hex(base64('aGVsbG8gd29ybGQ='));"
68656C6C6F20776F726C64
Deux tempéraments à connaître. D'abord, elle est tolérante : les caractères qu'elle ne reconnaît pas sont sautés plutôt que signalés, donc base64('!!!') renvoie un BLOB vide au lieu d'une erreur. Super pour la curiosité, dangereux pour l'audit, parce que « vide » et « manquant » ont l'air pareil dans la sortie. Ensuite, la fonction est polymorphe ; un argument BLOB est encodé en texte (avec des lignes de 72 caractères), tandis qu'un argument texte est décodé en BLOB. Le même nom, deux métiers, choisis selon le type de l'argument. Aucun autre décodeur de cette famille ne fait ça, alors relisez deux fois le type de votre entrée.
Le pur SQL. La bibliothèque de base n'a pas de base64, mais elle a les CTE récursifs, l'arithmétique et (depuis la 3.41.0) unhex(), ce qui suffit pour construire un vrai décodeur en quelques dizaines de lignes. La recette : une table d'alphabet de 64 lignes, l'entrée découpée en morceaux de quatre caractères, chaque morceau transformé en nombre de 24 bits, ce nombre découpé en trois octets, et les octets collectés en hexa avant que unhex() ne les transforme en BLOB. La voici, appliquée à une colonne de table :
WITH RECURSIVE
b64(c, v) AS (
SELECT 'A', 0 UNION ALL SELECT 'B', 1 UNION ALL SELECT 'C', 2
UNION ALL SELECT 'D', 3 UNION ALL SELECT 'E', 4 UNION ALL SELECT 'F', 5
UNION ALL SELECT 'G', 6 UNION ALL SELECT 'H', 7 UNION ALL SELECT 'I', 8
UNION ALL SELECT 'J', 9 UNION ALL SELECT 'K', 10 UNION ALL SELECT 'L', 11
UNION ALL SELECT 'M', 12 UNION ALL SELECT 'N', 13 UNION ALL SELECT 'O', 14
UNION ALL SELECT 'P', 15 UNION ALL SELECT 'Q', 16 UNION ALL SELECT 'R', 17
UNION ALL SELECT 'S', 18 UNION ALL SELECT 'T', 19 UNION ALL SELECT 'U', 20
UNION ALL SELECT 'V', 21 UNION ALL SELECT 'W', 22 UNION ALL SELECT 'X', 23
UNION ALL SELECT 'Y', 24 UNION ALL SELECT 'Z', 25 UNION ALL SELECT 'a', 26
UNION ALL SELECT 'b', 27 UNION ALL SELECT 'c', 28 UNION ALL SELECT 'd', 29
UNION ALL SELECT 'e', 30 UNION ALL SELECT 'f', 31 UNION ALL SELECT 'g', 32
UNION ALL SELECT 'h', 33 UNION ALL SELECT 'i', 34 UNION ALL SELECT 'j', 35
UNION ALL SELECT 'k', 36 UNION ALL SELECT 'l', 37 UNION ALL SELECT 'm', 38
UNION ALL SELECT 'n', 39 UNION ALL SELECT 'o', 40 UNION ALL SELECT 'p', 41
UNION ALL SELECT 'q', 42 UNION ALL SELECT 'r', 43 UNION ALL SELECT 's', 44
UNION ALL SELECT 't', 45 UNION ALL SELECT 'u', 46 UNION ALL SELECT 'v', 47
UNION ALL SELECT 'w', 48 UNION ALL SELECT 'x', 49 UNION ALL SELECT 'y', 50
UNION ALL SELECT 'z', 51 UNION ALL SELECT '0', 52 UNION ALL SELECT '1', 53
UNION ALL SELECT '2', 54 UNION ALL SELECT '3', 55 UNION ALL SELECT '4', 56
UNION ALL SELECT '5', 57 UNION ALL SELECT '6', 58 UNION ALL SELECT '7', 59
UNION ALL SELECT '8', 60 UNION ALL SELECT '9', 61 UNION ALL SELECT '+', 62
UNION ALL SELECT '/', 63
),
chunks AS (
SELECT name, b64, (LENGTH(b64) + 3) / 4 AS n
FROM payload
),
seq(name, n, i) AS (
SELECT name, n, 1 FROM chunks
UNION ALL
SELECT name, n, i + 1 FROM seq WHERE i < n
),
vals AS (
SELECT s.name, s.i AS chunk_no, s.n,
COALESCE((SELECT v FROM b64 WHERE c = substr(ch.b64, (s.i - 1) * 4 + 1, 1)), -1) AS v1,
COALESCE((SELECT v FROM b64 WHERE c = substr(ch.b64, (s.i - 1) * 4 + 2, 1)), -1) AS v2,
COALESCE((SELECT v FROM b64 WHERE c = substr(ch.b64, (s.i - 1) * 4 + 3, 1)), -1) AS v3,
COALESCE((SELECT v FROM b64 WHERE c = substr(ch.b64, (s.i - 1) * 4 + 4, 1)), -1) AS v4
FROM seq s
JOIN chunks ch ON ch.name = s.name
),
hexes AS (
SELECT name, chunk_no, n,
(CASE WHEN v1 < 0 THEN 0 ELSE v1 END) * 262144 +
(CASE WHEN v2 < 0 THEN 0 ELSE v2 END) * 4096 +
(CASE WHEN v3 < 0 THEN 0 ELSE v3 END) * 64 +
(CASE WHEN v4 < 0 THEN 0 ELSE v4 END) AS v24,
CASE WHEN v2 >= 0 OR v3 >= 0 THEN 1 ELSE 0 END +
CASE WHEN v3 >= 0 OR v4 >= 0 THEN 1 ELSE 0 END +
CASE WHEN v4 >= 0 THEN 1 ELSE 0 END AS n_bytes
FROM vals
),
acc(name, n, i, hx) AS (
SELECT h.name, h.n, 1,
(CASE WHEN h.n_bytes >= 1 THEN printf('%02X', h.v24 / 65536) ELSE '' END) ||
(CASE WHEN h.n_bytes >= 2 THEN printf('%02X', (h.v24 / 256) % 256) ELSE '' END) ||
(CASE WHEN h.n_bytes >= 3 THEN printf('%02X', h.v24 % 256) ELSE '' END)
FROM hexes h
WHERE h.chunk_no = 1
UNION ALL
SELECT a.name, a.n, a.i + 1,
a.hx || (
SELECT (CASE WHEN h.n_bytes >= 1 THEN printf('%02X', h.v24 / 65536) ELSE '' END) ||
(CASE WHEN h.n_bytes >= 2 THEN printf('%02X', (h.v24 / 256) % 256) ELSE '' END) ||
(CASE WHEN h.n_bytes >= 3 THEN printf('%02X', h.v24 % 256) ELSE '' END)
FROM hexes h
WHERE h.name = a.name AND h.chunk_no = a.i + 1
)
FROM acc a
WHERE a.i < a.n
)
SELECT name, unhex(hx) AS restored
FROM acc
WHERE i = n;
Exécutez-la contre une table avec une colonne b64 et vous obtenez un BLOB par ligne, sans extension, sans code applicatif. L'arithmétique est du base64 pur et simple en vêtements d'entiers : chacun des quatre caractères contribue six bits, les deux caractères du milieu chevauchent une frontière d'octet, et les deux bits bas du dernier caractère sont jetés. C'est l'option la plus lente de cette page (un passage récursif plus une recherche par morceau), alors gardez-la pour les petits payloads et l'archéologie ponctuelle. Pour une application qui tourne longtemps, la réponse honnête est la troisième option : enregistrer une fonction personnalisée d'une ligne depuis le langage hôte (le module sqlite3 de Python le fait en deux lignes avec create_function() et le module standard base64) et laisser le moteur l'appeler comme une fonction native. La quatrième option, les extensions chargeables de la famille sqlean par exemple, existe aussi, mais elle veut dire installer une autre compilation du moteur, ce que la plupart des équipes préfèrent éviter.
DuckDB : strict, petit, à convictions
DuckDB est une base de données analytique avec un vrai type binaire, BLOB, et une famille bien rangée de fonctions blob autour. Le décodeur est from_base64(string), et il est installé à côté de ses amis to_base64(), hex(), md5() et sha256() sur la même page de référence, c'est là que la plupart des utilisateurs DuckDB le rencontrent pour la première fois.
SELECT from_base64('aGVsbG8gd29ybGQ=') AS bytes;
SELECT decode(from_base64('aMOpbGxv')) AS text;
SELECT hex(from_base64('AAEC')) AS padding_optional;
La troisième ligne montre une règle plus amicale que vous ne l'attendriez : quand la longueur est un multiple de quatre, le padding manquant n'est pas un problème, et AAEC se décode tout simplement en les octets 00 01 02. La rigueur apparaît dès que la forme est fausse. DuckDB veut une longueur multiple de quatre, point final, et l'erreur de conversion le dit exactement :
SELECT from_base64('YWJ');
-- Conversion Error: Could not decode string "YWJ" as base64: length must be a multiple of 4
Deux opinions de plus à respecter. D'abord, le décodeur DuckDB ne parle que l'alphabet standard ; un tiret bas n'est pas un caractère qu'il reconnaît, donc les tokens URL-safe doivent être traduits avant leur arrivée (la recette est dans la section URL-safe). Ensuite, il n'a aucune tolérance pour les espaces. Une pièce jointe d'e-mail enveloppée en MIME avec ses retours à la ligne de 76 caractères à l'intérieur échouera, et le correctif est un replace() sur les retours à la ligne et les retours chariot avant l'appel. Et comme il n'y a pas de variante try_ pour adoucir le coup, le modèle doux est un contrôle préalable dans la même requête :
SELECT CASE
WHEN b64 ~ '^[A-Za-z0-9+/]*={0,2}$'
AND MOD(LENGTH(b64), 4) = 0
THEN from_base64(b64)
END AS maybe_bytes
FROM attachments;
L'expression régulière d'abord, le décodeur ensuite : la requête renvoie NULL pour tout ce qui ne peut pas être décodé, et le décodeur ne voit que des entrées bien formées.
ClickHouse : le décodeur de colonne
ClickHouse n'a pas de type binaire séparé ; son String est joyeusement binaire-safe, ce qui veut dire que le décodage « dans une chaîne » est tout le travail et qu'aucune étape de conversion ne suit. La fonction est là depuis la version 18.16.0 (2018) sous le nom base64Decode(), et elle garde un alias façon MySQL, FROM_BASE64(), si bien que les requêtes portées n'ont besoin d'aucune réécriture.
SELECT base64Decode('aGVsbG8gd29ybGQ=') AS text;
SELECT tryBase64Decode('definitely not base64') AS gentle;
SELECT base64URLDecode('aHR0cHM6Ly9jbGlja2hvdXNlLmNvbQ') AS url;
La deuxième ligne, c'est le style maison ClickHouse en action. Le moteur adore son préfixe try : tryBase64Decode() avale l'échec et renvoie une chaîne vide, tandis que le simple base64Decode() lève une exception avec le code INCORRECT_DATA et un message qui nomme la valeur fautive. Choisissez la forme simple quand une mauvaise ligne doit arrêter le pipeline et la forme try quand le rapport doit continuer, et choisissez-le délibérément, pas par accident.
Deux notes de version, parce que ClickHouse bouge vite. Avant 26.7, les espaces dans l'entrée étaient refusés ; depuis 26.7, espace, tabulation, retour à la ligne, retour chariot et saut de page sont tous ignorés, ce qui est le comportement que vous voulez pour tout ce qui a touché un e-mail ou un éditeur de texte. Et le décodeur moderne attend un padding correct sur ses groupes de quatre caractères, donc un token qui a perdu ses signes égaux en chemin sera une exception plutôt qu'un best effort. Si une requête qui marchait en 2023 commence à lever des exceptions en 2026, regardez la version du serveur avant de blâmer les données.
Oracle : du RAW ou rien
Le mécanisme base64 d'Oracle vit dans le package PL/SQL UTL_ENCODE, et il a sa propre personnalité : il prend du RAW et rend du RAW, rien d'autre. Pas de texte en entrée, pas de texte en sortie. VARCHAR2 est des données de caractères avec un jeu de caractères ; RAW est des octets nus ; et le package refuse de faire semblant du contraire. Le modèle qui marche est donc un sandwich à trois couches, cast en raw, décodage, cast retour en texte :
SELECT UTL_RAW.CAST_TO_VARCHAR2(
UTL_ENCODE.BASE64_DECODE(UTL_RAW.CAST_TO_RAW('aGVsbG8gd29ybGQ='))
) AS restored
FROM DUAL;
Chaque étape gagne sa place. UTL_RAW.CAST_TO_RAW() réinterprète les octets du texte en raw (dans le jeu de caractères de la base de données, qui pour un déploiement moderne est d'habitude AL32UTF8, donc votre entrée UTF-8 voyage telle quelle). UTL_ENCODE.BASE64_DECODE() fait le vrai travail. Et UTL_RAW.CAST_TO_VARCHAR2() réinterprète les octets du résultat en texte dans ce même jeu de caractères de base de données. Sautez n'importe quelle couche et vous obtenez une erreur de non-correspondance de types, ce qui est Oracle qui fait son travail de clarté.
Une entrée invalide lève une exception PL/SQL plutôt qu'un NULL discret, donc un décodage par lot devrait vivre à l'intérieur d'un gestionnaire d'exceptions qui journalise la ligne fautive. Le package porte aussi tout un musée de décodeurs cousins : décodage d'en-têtes MIME, quoted-printable, uudecode, encodage de texte, tous de la même ère. Vous utiliserez surtout le duo base64, mais les voisins expliquent pourquoi le package est organisé comme il l'est : Oracle voulait un seul foyer pour « les données portant un costume de transport ».
Un piège de taille à connaître avant de commencer. En SQL simple, une valeur RAW est plafonnée à 2000 octets, donc une valeur base64 qui se décode en plus d'environ 1500 octets raw ne peut tout simplement pas être décodée avec un seul énoncé SELECT. Les payloads plus grands ont besoin d'une boucle PL/SQL qui parcourt le BLOB par morceaux de 2000 (ou moins) octets, décode chaque morceau et recoud les résultats ensemble. C'est à l'ancienne, mais c'est la réponse standard d'Oracle, et c'est l'un de ces endroits où le système de types des années 1990 du langage façonne encore vos requêtes des années 2020.
Snowflake : apportez votre propre alphabet
Snowflake sépare son type binaire (BINARY) de ses types texte, et il vous donne le décodeur le plus configurable de cette famille. Le cheval de trait est BASE64_DECODE_BINARY(input), qui renvoie BINARY, et l'argument optionnel secondaire est une courte chaîne qui redéfinit l'alphabet :
SELECT BASE64_DECODE_BINARY('aGVsbG8gd29ybGQ=') AS bytes;
SELECT TO_VARCHAR(BASE64_DECODE_BINARY('aMOpbGxv'), 'UTF-8') AS text;
SELECT TO_VARCHAR(BASE64_DECODE_BINARY('aHR0cHM6Ly9jbGlja2hvdXNlLmNvbQ', '-_'), 'UTF-8') AS url_safe;
Lisez cet argument d'alphabet attentivement, parce qu'il est positionnel. Jusqu'à trois caractères sont autorisés : les deux premiers écrasent les positions 62 et 63 de l'alphabet (les valeurs par défaut sont + et /), et le troisième écrase le caractère de padding (défaut =). Pour dire « utilisez l'alphabet URL-safe », vous passez '-_'. Pour dire « alphabet URL-safe mais avec du padding en % à la place », vous devez passer les trois caractères, '-_%', alors même que la seule chose que vous voulez vraiment changer est le caractère de padding. Omettez des caractères et vous gardez les valeurs par défaut ; vous ne pouvez pas sauter une position et remplir la suivante.
Deux compagnons complètent le set. BASE64_DECODE_STRING() fait le décodage et la conversion en texte en un seul appel, donc vous pouvez vous passer du TO_VARCHAR() quand le payload est du texte. Et les variantes TRY_, TRY_BASE64_DECODE_BINARY() et TRY_BASE64_DECODE_STRING() renvoient NULL sur une valeur fautive au lieu de lever une erreur, ce qui est la version Snowflake de la forme try de ClickHouse.
Des octets au texte : l'étape du jeu de caractères
Le décodage vous remet des octets. Si le payload est un document, un nom, un fragment JSON, vous lui devez encore une étape : une interprétation en texte dans un jeu de caractères nommé. C'est d'ici que vient « ça s'est décodé mais ça a l'air faux », parce qu'une séquence d'octets ne devient des mots qu'une fois que vous avez dit dans quelle langue d'octets vous lisez. Le tableau est court et mérite d'être mémorisé :
| Dialecte | Octets vers texte | Séquences invalides |
|---|---|---|
| MySQL / MariaDB | CONVERT(bin USING utf8mb4) |
réinterprété ; n'importe quoi en entrée, n'importe quoi en sortie |
| PostgreSQL | convert_from(bytes, 'UTF8') |
lève une erreur |
| SQL Server | CAST(bin AS VARCHAR) |
avec pertes, selon la collation |
| Oracle | UTL_RAW.CAST_TO_VARCHAR2(raw) |
réinterprété dans le jeu de caractères de la base de données |
| DuckDB | decode(blob) |
erreur de conversion |
| ClickHouse | inutile ; String est le texte |
s/o |
| Snowflake | TO_VARCHAR(bin, 'UTF-8') |
lève une erreur |
| SQLite | CAST(blob AS TEXT) |
aucune validation du tout |
L'éventail est large par choix. PostgreSQL et DuckDB valident et refusent, ce qui protège votre code en aval du mojibake. MySQL et Oracle réinterprètent silencieusement, ce qui est rapide mais veut dire que la base de données ne peut pas vous sauver d'un payload Latin-1 qui arrive dans un monde UTF-8. SQLite ne regarde même pas, parce que dans SQLite une valeur TEXT est juste des octets avec une étiquette. La règle pratique : décidez le jeu de caractères avant de décoder, écrivez-le dans la requête en tant que littéral, et testez avec un payload qui contient un caractère non ASCII (le classique aMOpbGxv pour héllo est un bon canari, parce qu'il casse différemment dans chaque mauvais jeu de caractères). Pour les payloads vraiment binaires, sautez cette section entièrement et gardez les octets en octets.
JWT : trois points de base64 dans une colonne
Les JSON Web Tokens sont le base64 le plus courant que vous trouverez installé dans une base de données, parce que les événements d'authentification sont journalisés avec leurs tokens. Un JWT est trois morceaux séparés par des points : un en-tête, un payload et une signature. Les deux premiers sont des objets JSON emballés en base64, et voici le piège qui accroche : les JWT utilisent l'alphabet URL-safe sans padding, pas la forme standard avec padding. Un / ouvrirait un nouveau segment de chemin là où les tokens voyagent souvent, un + serait lu comme un espace dans une chaîne de requête, et les signes égaux de padding seraient une pure cérémonie, donc la spécification (RFC 7515 et RFC 7519) a basculé sur - et _ et a jeté le padding.
Décoder un token en SQL est donc une danse en quatre temps : couper sur les points, revenir des caractères URL-safe à l'alphabet standard, restaurer le padding, décoder et parser le JSON. PostgreSQL, avec son type JSONB, est un endroit confortable pour le faire :
WITH parts AS (
SELECT split_part('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRldiBVc2VyIiwiaWF0IjoxNTE2MjM5MDIyfQ.pDDL2Ljz7cK7vo9Ne4sMFMec1JMhasDUWa82vl-fYsw', '.', 1) AS header_b64,
split_part('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRldiBVc2VyIiwiaWF0IjoxNTE2MjM5MDIyfQ.pDDL2Ljz7cK7vo9Ne4sMFMec1JMhasDUWa82vl-fYsw', '.', 2) AS payload_b64
)
SELECT convert_from(
decode(replace(replace(payload_b64, '-', '+'), '_', '/')
|| CASE MOD(LENGTH(payload_b64), 4)
WHEN 2 THEN '=='
WHEN 3 THEN '='
ELSE '' END,
'base64'),
'UTF8')::jsonb AS claims
FROM parts;
Le résultat est une valeur JSONB que vous pouvez interroger comme n'importe quelle autre colonne, et pour le token ci-dessus elle revient sous la forme {"iat": 1516239022, "sub": "1234567890", "name": "Dev User"}. Extraire un seul claim du résultat, c'est alors simplement claims->>'sub' dans une requête de suivi. La restauration du padding, c'est l'expression CASE : une chaîne base64url dont la longueur manque deux caractères pour un multiple de quatre a besoin de deux signes égaux ; s'il en manque trois, un seul suffit ; et un multiple exact n'en a besoin d'aucun.
Allez un pas plus loin et vous pouvez même vérifier une signature HS256 en SQL, en utilisant l'extension pgcrypto de PostgreSQL pour le HMAC (activez-la une fois avec CREATE EXTENSION IF NOT EXISTS pgcrypto; si elle n'est pas déjà installée). Recalculez la signature sur header.payload avec la clé partagée, formatez-la de la même façon base64url, et comparez :
WITH parts AS (
SELECT split_part('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRldiBVc2VyIiwiaWF0IjoxNTE2MjM5MDIyfQ.pDDL2Ljz7cK7vo9Ne4sMFMec1JMhasDUWa82vl-fYsw', '.', 1) AS header_b64,
split_part('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRldiBVc2VyIiwiaWF0IjoxNTE2MjM5MDIyfQ.pDDL2Ljz7cK7vo9Ne4sMFMec1JMhasDUWa82vl-fYsw', '.', 2) AS payload_b64
)
SELECT rtrim(replace(replace(
encode(hmac((header_b64 || '.' || payload_b64)::bytea,
'sql-secret-key'::bytea, 'sha256'), 'base64'),
'+', '-'),
'/', '_'),
'=') = 'pDDL2Ljz7cK7vo9Ne4sMFMec1JMhasDUWa82vl-fYsw' AS valid
FROM parts;
L'appel hmac() produit le digest, encode(..., 'base64') l'emballage, et les trois opérations de chaîne le réforment dans la forme URL-safe sans padding que le token porte. Pour le token et la clé ci-dessus, la réponse est un joyeux t. Gardez quand même les mises en garde en tête : ceci ne marche que pour les algorithmes HMAC (HS256, HS384, HS512), cela met une clé partagée à l'intérieur d'un énoncé de base de données, et c'est fait pour le rapport, l'audit et le débogage. Tout ce qui gère vraiment l'accès devrait vérifier dans la couche applicative avec une vraie bibliothèque JWT.
Data URLs : l'image dans une chaîne
Le format data URL (RFC 2397) est la façon du web d'inliner un fichier dans un lien : data:image/png;base64, suivi du base64 du fichier. Les navigateurs les collent depuis le presse-papiers, les apps monopage y intègrent de petites images, et chacun de ces flux finit un jour dans une colonne de base de données sous forme de longue valeur texte. Le format est data:{media type}[;{parameters}][;base64],{data}, et la seule partie qui compte pour le décodage est tout ce qui suit la première virgule, parce que c'est là que commence le payload base64.
SELECT uri,
CAST(FROM_BASE64(SUBSTRING(uri, LOCATE(',', uri) + 1)) AS BINARY) AS png_bytes
FROM uploads
WHERE uri LIKE 'data:image/png;base64,%';
C'est tout le travail dans MySQL : trouver la virgule, passer au-delà, décoder, et vous tenez les octets de l'image dans une expression binaire que vous pouvez stocker dans une colonne BLOB ou hacher pour la déduplication. Les autres dialectes changent les fonctions (SUBSTR() et INSTR() chez la plupart, substring() et position() chez les autres) mais la forme est identique.
Trois avertissements. D'abord, tout data URL n'est pas base64 ; un data URL sans le marqueur ;base64 porte du texte en percent-encoding à la place, et le nourrir à un décodeur base64 est une erreur que le filtre LIKE ci-dessus est là pour empêcher. Ensuite, le media type du préfixe est une affirmation, pas un fait ; la même chaîne peut dire image/png et contenir un JPEG. Si le contenu compte, vérifiez les octets magiques du résultat décodé (PNG commence par 89 50 4E 47, JPEG par FF D8). Troisièmement, les data URLs sont gros. Une photo de 4 mégapixels devient une chaîne d'environ 5,5 mégaoctets, ce qui est une conversation de taille de colonne et de mémoire, pas de fonction de chaîne.
Base64 URL-safe : l'alphabet qui voyage
La section 5 de la RFC 4648 a défini un second alphabet pour le base64 parce que l'original a deux caractères qui ont des jobs dans la syntaxe des URL. Le signe plus est la façon dont les paramètres de requête ajoutent des valeurs, la barre oblique est la façon dont les chemins sont séparés, et le signe égal du padding est percent-encodé dès qu'il croise une chaîne de requête. La variante URL-safe échange + contre - et / contre _ (les deux inoffensifs dans les URL), et la spec JWT, par-dessus, jette le padding entièrement. Le résultat voyage à travers les liens, les segments de chemin, les noms de fichiers et les identifiants de fragment sans le moindre signe percent.
Vous le croiserez dans une base de données surtout parce que des tokens et des liens y ont été stockés, pas parce que les données y sont nées. Voici qui peut le traiter nativement et qui a besoin du manuel de deux minutes :
| Dialecte | Décodage URL-safe natif | Notes |
|---|---|---|
| SQL Server 2025+ | BASE64_DECODE() accepte les deux alphabets |
aucune traduction nécessaire du tout |
| ClickHouse 24.6+ | base64URLDecode() |
accepte aussi bien + et / |
| Snowflake | BASE64_DECODE_BINARY(s, '-_') |
alphabet en argument positionnel |
| MySQL / MariaDB | aucun | traduire les caractères, s'attendre à NULL en cas d'échec |
| PostgreSQL | aucun | traduire les caractères, s'attendre à une erreur en cas d'échec |
| Oracle | aucun | traduire les caractères avant le cast RAW |
| DuckDB | aucun (rejette le tiret bas) | traduire les caractères, garder la longueur multiple de 4 |
| SQLite CLI | aucun | traduire les caractères ; le décodeur saute ce qu'il ne connaît pas |
Le manuel, ce sont deux appels REPLACE() plus la restauration du padding, et c'est pareil dans chaque dialecte. Dans PostgreSQL, ça se lit comme ça :
SELECT convert_from(
decode(replace(replace('aGVsbG8', '-', '+'), '_', '/')
|| CASE MOD(LENGTH('aGVsbG8'), 4)
WHEN 2 THEN '=='
WHEN 3 THEN '='
ELSE '' END,
'base64'),
'UTF8') AS text;
Revenez de - à +, revenez de _ à /, ajoutez le padding manquant selon le modulo quatre de la longueur, et le décodeur standard prend le relais à partir de là. L'entrée aGVsbG8 (la forme URL-safe sans padding de « hello ») revient sous la forme du mot lui-même. Les deux erreurs qui reviennent sans cesse sont celles que l'expression CASE empêche : oublier le padding, ce qui fait que les décodeurs stricts rejettent une longueur qui n'est pas un multiple de quatre, et sauter la traduction des caractères, ce qui fait qu'un décodeur qui ne connaît pas l'alphabet URL-safe se grippe sur le tiret bas. Écrivez la traduction une fois, comme une fonction réutilisable dans votre base de données, et tout le problème cesse de revenir.
Fichiers, blobs et grosses choses
Le décodage est la façon dont les fichiers sortent des colonnes, et chaque dialecte a une porte de sortie légèrement différente. Dans DuckDB, l'aller-retour est deux énoncés, l'un pour lire un fichier dans un BLOB, l'autre pour écrire les octets décodés de l'autre côté :
SELECT filename, octet_length(content) AS size
FROM read_blob('/data/uploads/*.png');
Côté lecture : read_blob() est une fonction table qui accepte un nom de fichier, une liste de noms ou un motif glob et rend une colonne filename et une colonne content par fichier. Côté écriture, c'est son propre énoncé : COPY avec le format BLOB écrit des octets bruts, sans guillemets, sans échappement, exactement ce qu'un payload décodé veut.
COPY (SELECT from_base64(b64) FROM attachments WHERE id = 42)
TO '/data/restored/cat.png' (FORMAT BLOB);
La porte de sortie de PostgreSQL, c'est l'API des large objects. Un large object est un stockage binaire côté serveur par morceaux, adressé par un OID, et lo_export() en écrit un dans un fichier sur le serveur de base de données. Cela demande des droits de superutilisateur ou le privilège pg_write_server_files, et la destination doit être un chemin que le processus serveur peut écrire, donc en pratique c'est un travail de scripts de maintenance plutôt que de code applicatif :
SELECT lo_export(12345, '/tmp/attachments/cat.png');
MySQL n'a que la soupape restrictive SELECT ... INTO DUMPFILE (une seule ligne, chemin côté serveur, privilège FILE), et SQL Server n'a pas du tout d'écrivain de fichiers en SQL simple (écrire sur disque est un travail client ou agent, via son outillage d'export), ce qui est un design juste : la base de données stocke les octets, l'application décide où le fichier a sa place. SQLite est à l'autre bout du spectre, où l'application est l'hôte et une colonne BLOB peut être écrite directement sur disque en un appel du langage hôte.
Ensuite il y a les plafonds, qui diffèrent plus que vous ne l'attendriez de bases de données qui toutes prétendent être pareilles :
| Dialecte | Type binaire | Plafond pratique |
|---|---|---|
| PostgreSQL | bytea |
1 Go par valeur |
| MySQL / MariaDB | famille BLOB | max_allowed_packet (64 Mo par défaut dans MySQL 8) |
| SQL Server | varbinary(max) |
2 Go par valeur |
| Oracle | RAW / BLOB |
RAW : 2000 octets en SQL, BLOB : 4 Go avec du morçelage PL/SQL |
| SQLite | BLOB |
ce que le fichier et la mémoire permettent |
| DuckDB | BLOB |
très grand ; la mémoire et le disque décident |
| ClickHouse | String |
la taille de colonne est virtuelle, les lignes sont l'unité |
| Snowflake | BINARY |
8 Mo par valeur par défaut (colonnes BINARY simples) ; jusqu'à 64 Mo avec un BINARY(N) explicite |
La ligne MySQL mérite une histoire, parce que c'est celle qui surprend les gens en production. max_allowed_packet plafonne la taille d'un seul paquet entre client et serveur, et une chaîne base64 fait partie de ce paquet. Une photo de 50 mégaoctets encodée en base64 fait environ une chaîne de 67 mégaoctets, ce qui est plus gros que le défaut de 64 mégaoctets, et le résultat n'est pas une erreur que vous pouvez lire dans la requête : c'est une valeur tronquée ou NULL qui a l'air d'une corruption de données. Si vous déplacez de gros fichiers à travers une colonne MySQL, vérifiez cette limite avant de commencer, et souvenez-vous que c'est la forme encodée, pas les octets bruts, qui compte contre elle.
Enveloppes d'e-mail et lignes MIME
Tout base64 qui a survécu au système d'e-mail porte un souvenir : des retours à la ligne. MIME, l'ensemble des standards qui permet à l'e-mail de porter des pièces jointes binaires (RFC 2045, section 6.8), enveloppe la sortie base64 à 76 caractères et termine les lignes avec un retour chariot et un retour à la ligne. L'enveloppe existe parce que l'ancien réseau d'e-mail ne pouvait pas faire confiance aux lignes plus longues que ça, et le format est porté par habitude depuis. Donc une pièce jointe stockée dans une colonne de base de données est souvent une chaîne base64 avec un retour à la ligne tous les 76 caractères, et la relation de votre décodeur avec ces retours à la ligne décide si le travail est un énoncé ou deux.
| Décodeur | Mange l'enveloppe ? | Si non |
|---|---|---|
MySQL / MariaDB FROM_BASE64() |
oui | - |
PostgreSQL decode() |
oui | - |
SQL Server BASE64_DECODE() |
oui | - |
SQLite CLI base64() |
oui | - |
| ClickHouse 26.7+ | oui | - |
| ClickHouse avant 26.7 | non | enlever les espaces d'abord |
DuckDB from_base64() |
non | enlever les espaces d'abord |
Oracle UTL_ENCODE.BASE64_DECODE() |
non | enlever les espaces dans la couche PL/SQL |
Le correctif « enlever d'abord » est une seule expression, et c'est toujours sûr, parce que les espaces ne font pas partie de l'alphabet base64 : aucun payload légitime ne peut contenir un espace, une tabulation ou un retour à la ligne, donc les enlever ne peut pas détruire d'information. Dans PostgreSQL, l'idiome est un unique regexp_replace() :
SELECT decode(regexp_replace(attachment_b64, '\s', '', 'g'), 'base64')
FROM email_attachments;
Tout caractère d'espace, retours à la ligne compris, disparaît, et le décodeur voit une chaîne continue propre. Exécutez ceci dans DuckDB (avec son replace() sur les deux caractères de retour à la ligne) ou dans un ClickHouse antérieur à 26.7, et la pièce jointe enveloppée se décode exactement comme la non enveloppée.
Payloads d'API, configs et en-têtes d'authentification
Recullez par rapport aux fonctions individuelles et un motif apparaît : le base64 dans une colonne de base de données est presque toujours l'une des trois choses. Un champ dans un document JSON (une image, un certificat, un fichier qu'une API a décidé d'inliner). Une valeur de configuration (un secret ou un identifiant qu'un outil préfère en base64, parce que le base64 tient sur une ligne d'un fichier YAML sans guillemets, sans retours à la ligne et sans backslashes). Ou un artefact d'authentification (un en-tête d'auth Basic, un token stocké, un blob de session). Voici chacun avec sa forme de décodage.
Champs JSON. Le JSON est arrivé en texte, le champ est une chaîne, et le base64 se cache dedans. Extrayez le champ avec la fonction JSON de votre dialecte, puis décodez. Dans MySQL, toute la chaîne est une seule expression :
SELECT event_id,
CAST(FROM_BASE64(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.image'))) AS BINARY) AS image_bytes
FROM api_events
WHERE JSON_TYPE(JSON_EXTRACT(payload, '$.image')) = 'STRING';
PostgreSQL fait pareil avec JSONB, où le champ sort en texte avec l'opérateur ->> et decode() prend le relais. La garde JSON_TYPE de la dernière ligne compte plus qu'elle n'en a l'air : elle tient le décodeur à l'écart des lignes où le champ est un nombre, un objet imbriqué ou manquant, et dans MySQL ces lignes contribueraient sinon un NULL silencieux à votre décompte de « combien d'événements avaient une image ».
En-têtes d'authentification. Un en-tête d'auth Basic est la chaîne littérale Basic suivie du base64 de username:password. Le décoder en SQL, c'est un sous-ensemble et un découpage, et c'est exactement pour ça que les gens le font (d'habitude pour auditer quels utilisateurs frappent quels endpoints, pas pour vérifier le mot de passe, que la base de données ne devrait jamais voir en clair) :
SELECT request_id,
SUBSTRING_INDEX(CAST(FROM_BASE64(SUBSTRING(header_value, 7)) AS CHAR), ':', 1) AS username,
SUBSTRING_INDEX(CAST(FROM_BASE64(SUBSTRING(header_value, 7)) AS CHAR), ':', -1) AS secret
FROM http_log
WHERE header_name = 'Authorization'
AND header_value LIKE 'Basic %';
SUBSTRING(header_value, 7) épluche le préfixe Basic , le décodeur restaure le texte d'origine, et les deux appels SUBSTRING_INDEX() le coupent sur les deux-points, première partie pour l'utilisateur, dernière partie pour le secret. Dans PostgreSQL, la même requête utilise substring() et split_part().
Valeurs de configuration. La direction décodage ici, c'est le travail d'audit : quelqu'un a stocké un secret en base64 dans une table de config (une habitude héritée de Kubernetes, où les valeurs de secret sont en base64 au repos), et vous voulez voir ce qu'il y a vraiment dedans, ou vous construisez l'export qu'un nouvel environnement va consommer. La forme est un SELECT par valeur, et l'étape du jeu de caractères s'applique si la valeur est du texte :
SELECT name,
CONVERT(FROM_BASE64(value) USING utf8mb4) AS plaintext
FROM app_config
WHERE name LIKE '%_secret%';
Traitez ce résultat avec les égards qu'il mérite. Vous venez de transformer des secrets stockés en sortie de requête visible ; assurez-vous que le compte qui exécute la requête a les droits qu'il devrait, que le résultat n'est pas copié dans un log, et que l'habitude du base64 dans la config reçoive un deuxième regard. Le base64 est un transport, pas un coffre-fort, et une requête d'audit est le moment où ça devient évident.
Les pièges qui mordent
Chaque piège de cette liste est un piège qui a coûté une après-midi dans au moins une base de code, et chacun d'eux est spécifique à la façon dont les dialectes SQL gèrent le base64, pas au base64 lui-même.
- Le NULL silencieux. MySQL et MariaDB décodent une mauvaise entrée en
NULLsans se plaindre. Dans un rapport qui joint sur la valeur décodée, ces lignes disparaissent tout simplement, et la différence entre « 0 lignes » et « 0 lignes parce que 14 d'entre elles étaient empoisonnées » est invisible jusqu'à ce que quelqu'un demande pourquoi le décompte ne colle pas. Si votre décodeur est du genre discret, comptez vos NULLs exprès. - La règle du multiple de quatre, appliquée inégalement. Une chaîne dont la longueur n'est pas un multiple de quatre n'est pas du base64, mais les dialectes ne s'accordent pas sur ce qu'il faut faire : PostgreSQL lève une erreur, DuckDB lève une erreur de conversion, ClickHouse lève une exception, MySQL renvoie
NULL, et la CLI SQLite décode silencieusement ce qu'elle peut. Le même fichier de données produit cinq résultats différents sur cinq bases de données, et c'est pour ça que « ça marchait sous Postgres » n'est pas un test. - Le décalage d'alphabet. Un token URL-safe (JWT, lien, nom de fichier) nourri à un décodeur d'alphabet standard : SQL Server l'accepte,
base64URLDecode()de ClickHouse l'accepte, Snowflake l'accepte avec le bon argument, et tout le monde renvoie soitNULL, soit lève une erreur, ou, dans le cas de la CLI SQLite, jette silencieusement le tiret bas et vous remet les mauvais octets. Le cas des mauvais octets est le plus méchant, parce que le résultat a l'air plausible. - L'enveloppe MIME. Une entrée enveloppée vers un décodeur qui ne mange pas les retours à la ligne (DuckDB, ClickHouse avant 26.7, Oracle) échoue, et l'échec a souvent l'air de « les 76 derniers caractères sont de la paille » plutôt que « il y a un retour à la ligne dedans », parce que l'erreur pointe le caractère après la coupure.
- Le tour d'affichage. Le client mysql affiche le binaire en hexa, psql affiche le bytea en hexa
\x, Snowflake affiche BINARY en hexa, et Oracle affiche RAW en hexa. Quatre clients, quatre notations hexa, une très humaine erreur de conclure que les données sont corrompues parce que l'écran montre des chiffres. Convertissez toujours explicitement avant de lire le résultat avec vos yeux. - Le padding au mauvais endroit. Un signe égal n'est légal qu'à la fin, un ou deux d'entre eux. Une chaîne comme
YQ==BQ==est deux groupes valides portant un seul costume, et les décodeurs stricts la rejettent pendant que les tolérants la décodent en quelque chose que personne n'a demandé. Si vous voyez un jour du padding au milieu d'une valeur stockée, l'encodeur qui l'a écrite est cassé, et corriger les données est un travail à part unique. - La surprise du jeu de caractères. Le décodage réussit, le texte revient, et les accents sont faux. Les octets étaient bien ; l'interprétation ne l'était pas. C'est le
CONVERT(... USING latin1)qui aurait dû êtreutf8mb4, leCAST(bin AS VARCHAR)qui a tourné sous une collation qui avale les séquences invalides, leCAST(blob AS TEXT)dans SQLite qui ne vérifie jamais. Épinglez le jeu de caractères en tant que littéral dans la requête et testez avec un canari accentué. - Les plafonds. La limite RAW de 2000 octets d'Oracle dans les énoncés SQL, le
max_allowed_packetde MySQL qui taxe la taille encodée, le plafond bytea de 1 Go de PostgreSQL, la longueur BINARY par défaut de 8 Mo de Snowflake. Chacun est documenté, chacun est découvert en production, et chacun est un contrôle de taille que vous auriez pu écrire avant que les données ne soient grosses. - Faire confiance aux octets décodés. Le base64 peut porter n'importe quoi, y compris une chaîne pleine de guillemets. Décoder n'est pas assainir. Tout ce que vous faites du texte décodé (le comparer, le journaliser, le concaténer dans un autre énoncé) a toujours besoin des protections habituelles, et une requête paramétrée est toujours une requête paramétrée après un aller-retour base64.
Comment rester du bon côté
- Décidez d'abord le type, pas la fonction. Le payload est binaire ou texte ? Le binaire va dans BLOB/bytea/varbinary et y reste. Le texte passe par l'étape du jeu de caractères avec un encodage explicite. La moitié de toute la douleur base64 en SQL, c'est un payload binaire qui s'est égaré dans une colonne texte (ou l'inverse) et qui est en train d'être interprété.
- Validez avant de décoder, ou décodez doucement. Une expression régulière sur l'alphabet plus un contrôle du modulo quatre de la longueur ne coûte rien et transforme une erreur qui arrête le lot en un
NULLque vous pouvez compter. Là où le dialecte offre une forme try (tryBase64Decodede ClickHouse,TRY_BASE64_DECODE_BINARYde Snowflake), utilisez-la pour le rapport et gardez la forme stricte pour les pipelines qui ne doivent pas deviner. - Vérifiez la version du dialecte, pas seulement de la base de données. ClickHouse 26.7 a changé le traitement des espaces, SQL Server 2025 est la première version avec la fonction du tout, la CLI SQLite a besoin de 3.41, et les attentes de padding de ClickHouse se sont durcies avec le temps. « C'est ClickHouse » n'est pas une spécification ; « c'est ClickHouse 24.8 », si.
- Documentez l'alphabet de chaque colonne. Une colonne qui peut contenir à la fois du base64 standard et du URL-safe est une colonne qui va confondre le prochain développeur. Si les données viennent de JWT, dites-le dans le commentaire du schéma ; si elles viennent de pièces jointes MIME, dites-le aussi. Le choix du décodeur est une propriété de la colonne, pas de la requête.
- Stockez les octets, encodez à la limite. Si vous contrôlez le schéma, une colonne BLOB plus l'encodage dans la couche API bat une colonne texte base64 pour le stockage, pour l'indexation et pour chaque requête future. Le base64 dans la colonne est une taxe de compatibilité, et les taxes se paient de préférence une fois, à la frontière.
- Faites un aller-retour avec un canari. Avant de faire confiance à une nouvelle voie de décodage, poussez un payload connu à travers l'encodage et le décodage dans la même base de données et comparez. Le canari devrait contenir un caractère non ASCII (pour exercer l'étape du jeu de caractères), une longueur qui laisse une queue de padding (pour exercer les règles de padding) et, pour les voies URL-safe, un
-ou un_quelque part (pour exercer la traduction d'alphabet). - Gardez les secrets hors du texte de la requête. La vérification de JWT avec pgcrypto met une clé partagée dans l'énoncé ; les audits de config mettent des secrets en clair dans le résultat. Les deux sont des travaux légitimes, mais ils méritent un compte restreint, un log propre et une revue, pas une chaîne de connexion de production et un
SELECT * INTO OUTFILE.
Une brève histoire du déballage en SQL
Le format base64 lui-même est plus vieux que la partie utile d'Internet. Il a été standardisé pour MIME au milieu des années 1990 (RFC 2045, section 6.8, qui a rendu obsolète la RFC 1521, la spécification de corps de message MIME de 1993 qui portait l'encodage), et le nom n'est qu'un décompte : l'alphabet a 64 caractères. La variante URL-safe est arrivée avec la RFC 4648 en 2006, et la spec JWT en 2015 a fait de cette variante la seule que vous voyez vraiment dans les colonnes de tokens. Mais les bases de données ont chacune rencontré le format à leur propre rythme, et le rythme vous dit quelque chose sur l'âme de chacune.
2002. PostgreSQL 7.2 liste déjà base64 comme format de premier ordre de encode() et decode() - contemporain du UTL_ENCODE d'Oracle de l'ère 9i, et le support base64 le plus ancien de cette famille, de justesse. Une base de données avec un vrai type binaire et un argument de format y est arrivée tôt, parce que la réponse était à une valeur d'enum.
Début des années 2000. Le package UTL_ENCODE d'Oracle apparaît à l'ère 9i, portant le base64 à côté des fonctions d'en-tête MIME, quoted-printable et uuecode. C'est du RAW en entrée et du RAW en sortie, ce qui est très Oracle, et il a gardé cette forme depuis un quart de siècle.
2013. MySQL 5.6 ajoute TO_BASE64() et FROM_BASE64(), et MariaDB 10.0 porte les deux dans le fork. Le duo encode avec des lignes de 76 caractères et décode avec tolérance aux espaces, un set assorti qui n'a pas changé depuis une douzaine de versions majeures.
2018. ClickHouse 18.16 livre base64Decode() avec son alias façon MySQL, parce que le monde columnar importait des workloads qui portaient déjà du base64 dans leurs schémas de logs.
2023. SQLite 3.41.0 ajoute base64() et son frère base85 au shell en ligne de commande en tant que fonctions définies par l'application. La bibliothèque de base, fidèle à elle-même, ne reçoit rien ; le shell, qui est là où les humains piquent vraiment dans les bases SQLite, reçoit l'outil.
2025. SQL Server 2025, disponible en général en novembre 2025, ajoute BASE64_DECODE() et BASE64_ENCODE() à T-SQL après une absence de trente-six ans. Les notes de version les traitent comme une fonctionnalité modeste ; la communauté les traite comme un sauvetage.
Le motif est net une fois qu'on le voit. Les bases de données avec un vrai type binaire et un argument de format (PostgreSQL, et à sa manière Oracle) ont eu le base64 le jour où le besoin était évident. Le reste (MySQL, SQL Server) l'a traité comme une commodité de chaîne et l'a planifié en conséquence. Et le moteur embarquable (SQLite) le considère encore comme le travail de l'application hôte, avec la CLI comme exception amicale.
Des choses qui vous feront sourire
- SQL Server a passé de 1989 à 2025 sans décodeur base64, et la réponse de la communauté était une fonction XML appelée
xs:base64Binary()dans unCAST(N'' AS XML). Une génération entière de requêtes d'entreprise a décodé des tokens à travers le parseur XML, parce que le parseur XML comprenait le base64 depuis 2001 et que le moteur SQL, non. - La
base64()de la CLI SQLite est le seul polymorphe de cette famille : passez-lui un BLOB et elle encode, passez-lui du texte et elle décode. La fonction change de métier selon le type de son argument, ce qui est un petit acte de télépathie SQL et un vrai piège pour les non avertis. - L'encodeur PostgreSQL enveloppe à 76 caractères exactement comme le standard MIME de 1996, sauf qu'il termine les lignes avec un retour à la ligne seul au lieu du retour chariot et retour à la ligne du standard. Vingt ans après la spec, un caractère de moins. Le décodeur ignore les deux, donc la révolte est invisible à moins que vous ne diffiez la sortie.
- Dans le client
mysql,SELECT FROM_BASE64('aGVsbG8=')affiche0x68656C6C6F. Pas parce que les données sont en hexa, et pas parce que quelque chose cloche, mais parce que le client a décidé, en votre nom, que les chaînes binaires devaient s'afficher en hexa. Le réglage s'appellebinary-as-hex, et il a convaincu des milliers de développeurs que leur décodeur est cassé. - Le type RAW au niveau SQL d'Oracle est plafonné à 2000 octets, donc un certificat de 3 kilo-octets ne peut même pas être collé dans un énoncé SQL en tant que littéral RAW. Le décodage doit se faire en PL/SQL, en morceaux, avec une boucle. La limite date des années 1990 ; la boucle est toujours la réponse recommandée.
- Snowflake affiche les valeurs
BINARYen hexa dans chaque jeu de résultats, donc un décodage parfaitement réussi de « hello » arrive à votre écran sous la forme68656C6C6F. Deux dialectes, deux affichages hexa, un même sentiment d'inquiétude. - ClickHouse garde l'alias
FROM_BASE64()à côté de son natifbase64Decode(), une petite courtoisie pour les réfugiés MySQL qui sont arrivés avec des requêtes qui, sinon, ne tourneraient pas. - Toute la famille partage un fait silencieux : le base64 est une taxe de 33 pour cent en sortant et un remboursement de 25 pour cent en entrant, et aucun des huit décodeurs ici ne vous le dira sans qu'on le lui demande. Le format est un costume ; la garde-robe est gratuite ; la confection est ce dont cet article parle.
Continuez
Cet article a parlé d'enlever le déguisement : la fonction dans chaque dialecte, son tempérament, et les payloads (JWT, data URLs, e-mails enveloppés, champs JSON, valeurs de config, en-têtes d'auth) qui le portent. L'autre direction est un animal à part, avec son propre lot de surprises : quels encodeurs enveloppent leur sortie à 76 caractères et lesquels non, comment produire la forme URL-safe sans padding que les tokens attendent, l'arithmétique des tailles qui décide la largeur de votre colonne, et ce que veut dire le trou de 36 ans de SQL Server pour quiconque est encore sur une version plus ancienne. Tout cela, de TO_BASE64() à BASE64_ENCODE(), est couvert en profondeur dans l'article lié sur l'encodage Base64 en SQL, lié depuis cette page. Décodez ici, encodez là-bas, et tout l'aller-retour tient dans une après-midi.
Dernière mise à jour : 2026-09-08
Article associé : Encodage Base64 en SQL : un guide complet