The alphabet is not the encoding¶
Level: 201 · for interface work
One line: A converter's Base32 menu offers four entries and prints four different strings for one input, but three of them differ by nothing except which thirty-two symbols were used — while the fourth, Crockford's, is a notation for numbers, zero-extends the opposite end, and gives a different answer for any input that is not a whole number of five-bit pieces.
The menu that started this¶
A converter offers Base32 (RFC 3548, RFC 4648) · Base32hex (RFC 4648) · z-base-32 · Crockford's Base32 · Custom, and prints, for The quick brown fox jumps over the lazy dog.:
Seventy-one characters. Pick a different entry from the same menu and seventy-one different characters come back. Nothing on screen says whether those are four encodings or four spellings of one, and the answer decides whether "we base32 the field" is a complete statement in an interface agreement.
It is four spellings of one — with an exception that is the real subject of this page.
Two questions, and only one of them is about bits¶
Binary to text is about the first question: how wide a piece the scheme cuts the bits into. Four bits is base16, five is base32, six is base64, and the width fixes the cost — a byte becomes 2, 1.6 or 1.33 characters and there is nothing to decide.
This page is about the second: which thirty-two symbols stand for the pieces, and in what order. At width five the family has four published answers, and unlike the width question this one is not arithmetic. Each of the four was chosen by someone thinking about a person reading a code off a screen and typing it somewhere else, and the differences between them are consequences of that, not of the bits.
Three of the four are one tr¶
The same five-bit pieces, written four ways:
| the symbols, in order | value 0 is | value 26 is | |
|---|---|---|---|
| RFC 4648 base32 ↗ | ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 |
A |
2 |
| base32hex (RFC 4648 §7) ↗ | 0123456789ABCDEFGHIJKLMNOPQRSTUV |
0 |
Q |
| Crockford's ↗ | 0123456789ABCDEFGHJKMNPQRSTVWXYZ |
0 |
R |
| z-base-32 ↗ | ybndrfg8ejkmcpqxot1uwisza345h769 |
y |
s |
Thirty-two in, thirty-two out, one for one. So the conversion between any two of them is a character substitution and nothing more — a str.translate in Python, a single tr in a shell pipe — and it is exactly reversible, because no bit moved. That is the test for whether two schemes are really the same encoding: if a 32-character tr converts between them, they are.
It also says what cannot be done that way. Base32 to base64 is not a tr, at any price, because those two cut the bits into different-sized pieces; the only route across is to decode back to bytes and encode again. The alphabet is a rename. The packing is the encoding.
The order of the symbols is a feature¶
Look again at the value-26 column. Standard base32 writes 0 as A and 26 as 2, and in ASCII 2 sorts before A — so the moment a five-bit piece crosses 26, the order of the encoded text stops matching the order of the bytes.
base32hex writes 0–31 as 0–9 then A–V, which is already ascending in ASCII, and RFC 4648 says in §7 ↗ that this is the point of it: "encoded data maintains its sort order when the encoded data is compared bit-wise".
Tested over all 65,536 two-byte strings, base32hex preserves the order and standard base32 does not, first failing here:
That is worth a database index, a sorted log, or a range query — and it is invisible on any comparison table that ranks these schemes by bits per character. DNSSEC's NSEC3 records are the standing example: RFC 5155 ↗ specifies "the 'Base 32 Encoding with Extended Hex Alphabet' as specified in RFC4648" and says why in the same document — "this order is the same as the canonical DNS name order" — so a resolver can walk the chain by comparing the text it already has.
One caveat, and it is sort's, not base32hex's. RFC 4648's promise is about a bit-wise comparison. sort gives you that only in the C locale; in any other it applies the locale's collation, which is a different order and can put the same two strings the other way round. So the property survives a database index on a binary collation and a LC_ALL=C sort, and is not guaranteed by anything else — see tr and sort work a byte at a time.
Crockford's Base32 is a notation for numbers¶
Here the "just a rename" story stops being true, and the reason is in the first sentence of the specification ↗: "Base 32 is a textual 32-symbol notation for expressing numbers" — not byte strings. On a value that does not fill its last piece it says: "zero-extend the number to make its bit-length a multiple of 5".
A number is zero-extended at the high end. RFC 4648 pads the low end of the last group. Same bits, opposite ends, so two bytes give two answers:
b'Hi' = 0x4869 = 0100100001101001
RFC 4648, pad the right 01001 00001 10100 10000 -> 91MG
Crockford, extend the left 00000 10010 00011 01001 -> 0J39
Both are honestly described as "Crockford's Base32 of those two bytes". A converter with an alphabet menu hands you the first, because it is renaming an RFC 4648 string it already had. ULID ↗, which says "Crockford's Base32 is used" for a 128-bit value in 26 characters, is built on the second.
The two readings agree exactly when there is nothing to extend — when the bit count is already a multiple of five, which for whole bytes means a length divisible by 5. The Python run checks that at every length from 1 to 15 (exhaustively at 1 and 2), and the pattern is clean: at a multiple of 5, every case agrees; otherwise the only string that agrees is the one made of zero bytes, where it makes no difference which end you extend.
So a three-byte value has no agreed Crockford spelling, and two libraries can both be right about it. If a format uses Crockford, it has to say which reading — and the honest way is to say what ULID says: a number, of a declared width.
And a number has no leading zeros. 00 00 41, 00 41 and 41 are three different byte strings and one number, 65, so the number reading cannot round-trip a length. That is the other half of why ULID pins 26 characters: 26 × 5 = 130 bits for a 128-bit value, with the width declared rather than inferred.
The letters that are missing¶
| drops | the stated reason | |
|---|---|---|
| Crockford's ↗ | I L O U |
I and L read as 1, O as 0; U "to reduce the chance of accidental obscenity" |
| z-base-32 ↗ | 0 l v 2 |
the same confusions, plus v/u and 2/z |
z-base-32 then goes further and permutes what is left, so the symbols that turn up most often are the ones its author judged easiest to read, say and remember. Neither list is about bits. Both are about transcription by a human being, which is why these alphabets are the ones you find on recovery codes, licence keys and anything a support agent reads down a phone.
The standard alphabet made no such allowance, so its decoders have to. Python's has since the module was written:
map01 has to be told which letter a typed 1 meant, because in the standard alphabet I and L are both live symbols, worth 8 and 11, and the string does not say which was intended. Its default is None — the docs say "For security purposes" — so 0 and 1 are rejected outright unless you ask for the repair. A repair at the decoder is strictly weaker than an alphabet that cannot be mistyped.
And on base32hex the repair is not weaker but impossible, which is the clearest way to see that these are choices and not styles: b32hexdecode has no map01 at all, because 0, 1, I, L and O are all five live symbols in the extended-hex alphabet, worth 0, 1, 18, 21 and 24. The alphabet that fixed the sorting problem re-introduced the transcription one.
One caveat on z-base-32. Its specification ↗ is written over bits, not bytes: an encoder that knows the exact bit length may stop short of the byte boundary, which RFC 4648 has no equivalent for. Anything a tool prints for a byte string is its octet-mode reading, and in octet mode it is the plain rename it appears to be.
The command is not there¶
base64 is on every machine this library targets. base32 is not, and neither is anything that speaks the other three alphabets.
| tool | macOS 26 | ubuntu:24.04 |
|---|---|---|
base64 ↗ |
/usr/bin/base64 (FreeBSD) |
/usr/bin/base64 (GNU coreutils) |
base32 ↗ |
absent — no /usr/bin/base32 |
/usr/bin/base32 (GNU coreutils) |
basenc ↗ (does base32hex) |
absent | present |
| anything for Crockford or z-base-32 | absent | absent |
Measured 2026-09-07, extending the row Binary to text already records for the same two tools. The part worth adding is why it is easy to miss: on the Mac the name base32 resolved perfectly well — to Homebrew's GNU coreutils 9.11, shadowing an absence. A developer with coreutils installed cannot tell by typing the command, only by asking where it came from. The tool names above link the GNU coreutils manual ↗, which documents the ubuntu:24.04 column and not the other one: macOS's base64 is a FreeBSD program with its own man page and its own flags.
The failure is at least loud (command not found), unlike the five ways base64 disagrees with itself across the two builds. But it is why the shell example below converts with tr over constants instead of calling an encoder — and tr is the better demonstration anyway, since a rename is exactly what it does.
What this page does not cover¶
Two things that belong to the width question, not the alphabet question, and are worked through on Binary to text: that decode is many-to-one — the unused bits of the last group are supposed to be zero but RFC 4648 §3.5 ↗ only says a decoder "MAY chose to reject" a string where they are not, so two different strings decode to the same bytes and comparing the encoded form is a bug; and that base58 and base62, which look like neighbours on a bits-per-character table, are big-integer division over the whole message rather than a re-cut, so they are not in this family at all. Crockford's number reading, above, is that same big-integer idea arriving inside a scheme that is.
In Python¶
Verified output of base32_alphabets_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. THE SAME FORTY-FOUR BYTES, FOUR TIMES
------------------------------------------------------------------------
the text 'The quick brown fox jumps over the lazy dog.'
its bytes 44 = 352 bits, which is 70.4 five-bit pieces
RFC 4648 KRUGKIDROVUWG2ZAMJZG653OEBTG66BANJ2W24DTEBXXMZLSEB2GQZJANRQXU6JAMRXWOLQ
base32hex AHK6A83HELKM6QP0C9P6UTRE41J6UU10D9QMQS3J41NNCPBI41Q6GP90DHGNKU90CHNMEBG
Crockford AHM6A83HENMP6TS0C9S6YXVE41K6YY10D9TPTW3K41QQCSBJ41T6GS90DHGQMY90CHQPEBG
z-base-32 ktwgkedtqiwsg43ycj3g675qrbug66bypj4s4hdurbzzc3m1rb4go3jyptozw6jyctzsqmo
Four strings of 71 characters, no two alike, one input. A dropdown
that offers all four calls them all Base32, and a reader is left to
assume they are four encodings. They are one encoding written in
four alphabets, and the four differ in nothing else -- with one
caveat and one outright exception, in sections 6 and 4.
The caveat is z-base-32. Its specification is written over BITS,
not bytes: an encoder that knows the exact bit length may stop
short. The line above is its octet-mode reading, which is the one
a tool handed a byte string can give -- and in octet mode it is
exactly the rename it looks like.
padded KRUGKIDROVUWG2ZAMJZG653OEBTG66BANJ2W24DTEBXXMZLSEB2GQZJANRQXU6JAMRXWOLQ= 72 characters
unpadded KRUGKIDROVUWG2ZAMJZG653OEBTG66BANJ2W24DTEBXXMZLSEB2GQZJANRQXU6JAMRXWOLQ 71 characters
RFC 4648 pads to a multiple of 8 characters; Crockford and z-base-32
define no padding at all. So the '=' is a fifth difference, and the
character count alone -- 71, not 72 -- tells you the padding is off.
2. THREE OF THE FOUR ARE ONE str.translate AWAY
------------------------------------------------------------------------
RFC 4648 of b'cat' MNQXI
.translate(RFC -> base32hex) CDGN8
.translate(RFC -> Crockford) CDGQ8
.translate(RFC -> z-base-32) cpoze
and straight back again MNQXI recovered: True
A rename is invertible and loses nothing, because no bit moved. The
bytes were cut into five-bit pieces once; each scheme then writes
the SAME pieces with a different set of thirty-two symbols. That is
why one `tr` in a shell pipe converts between them -- see the
terminal section on the page.
3. THE ORDER OF THE THIRTY-TWO SYMBOLS IS A FEATURE, NOT A STYLE
------------------------------------------------------------------------
RFC 4648 gives a reason for base32hex, and it is a property of the
ordering alone: 'encoded data maintains its sort order when the
encoded data is compared bit-wise' (section 7).
Tested over every two-byte string -- all 65,536 of them:
sorting the base32 text == sorting the bytes False
sorting the base32hex text == sorting the bytes True
the first place standard base32 gets it wrong:
0033 -> AAZQ====
0034 -> AA2A==== but 0033 < 0034
The cause is ASCII, not base32. Standard base32 spells value 0 as
'A' and value 26 as '2', and '2' sorts BEFORE 'A', so the text order
and the byte order disagree the moment a value crosses 26.
base32hex spells 0..31 as 0-9 then A-V, which is already ascending
in ASCII, so the two orders can never disagree.
That is worth a database index, and it is invisible on any table
that lists these schemes by bits-per-character.
4. CROCKFORD'S BASE32 IS A NOTATION FOR NUMBERS
------------------------------------------------------------------------
Its specification opens: 'Base 32 is a textual 32-symbol notation
for expressing NUMBERS' -- not byte strings. And on a short value it
says: 'zero-extend the number to make its bit-length a multiple of
5'. A number is zero-extended at the HIGH end. RFC 4648 pads the
LOW end of the last group. Same bits, opposite ends.
the bytes b'Hi' = 4869 = 0100100001101001
RFC 4648: pad the right 01001 00001 10100 10000 -> 91MG
Crockford: extend left 00000 10010 00011 01001 -> 0J39
Two strings. Both are 'Crockford's Base32 of these two bytes'. A
converter that shows you an alphabet dropdown gives you the first;
ULID, which specifies Crockford's Base32 for a 128-bit number,
is built on the second.
The two readings agree exactly when there is nothing to extend --
when the bit count is already a multiple of 5, i.e. when the byte
count is a multiple of 5. Checked, by length:
bytes % 5 tested agreed verdict
1 1 256 1 only the all-zero string
2 2 65536 1 only the all-zero string
3 3 125 1 only the all-zero string
4 4 625 1 only the all-zero string
5 0 3125 3125 all of them
6 1 5209 1 only the all-zero string
7 2 4112 1 only the all-zero string
8 3 4112 1 only the all-zero string
9 4 4104 1 only the all-zero string
10 0 4097 4097 all of them
11 1 4097 1 only the all-zero string
12 2 4097 1 only the all-zero string
13 3 4097 1 only the all-zero string
14 4 4097 1 only the all-zero string
15 0 4097 4097 all of them
Lengths 1 and 2 are exhaustive -- every one of the 256 and the
65,536; the rest sweep a fixed five-byte symbol set. The pattern is
not a sample artefact, it is the arithmetic above: a value that is
not a whole number of five-bit pieces has no agreed spelling, and
the one string that survives every length is the one made of
zeros, where it makes no difference which end you extend.
5. AND A NUMBER HAS NO LEADING ZEROS
------------------------------------------------------------------------
000041 chunks 00042 as a number 21
0041 chunks 010G as a number 21
41 chunks 84 as a number 21
Three different byte strings; one number, 65. The chunked reading
keeps the length because it is encoding BYTES; the number reading
cannot, because 065 and 65 are the same number. Any format that
uses the number reading has to declare a width, and ULID does:
ULID 128 bits, canonically 26 characters
26 x 5 130 bits -- 2 more than the value has
its two fields 48-bit time in 10 chars (50 bits), 80-bit random in 16 (80 bits)
80 is a multiple of 5 and 48 is not, which is why only the
timestamp half needs the zero-extension at all.
6. WHICH LETTERS ARE MISSING, AND WHAT A DECODER DOES ABOUT IT
------------------------------------------------------------------------
Crockford drops I L O U
z-base-32 drops 0 2 l v
Crockford's reasons are I and L looking like 1, O looking like 0,
and U 'to reduce the chance of accidental obscenity'. z-base-32
drops a different four (0, l, v, 2) and then PERMUTES the rest, so
that the symbols a person meets most often are the ones easiest to
read and say. Neither list is about bits. Both are about a human
reading a code off a screen and typing it somewhere else.
The standard alphabet made no such allowance, so its decoders have
to. Python's has carried the repair since the module was written:
b32encode(b'cat') MNQXI===
lower case, casefold=True b'cat'
someone typed 1 for I and 0 for O MNQX1===
b32decode(..., casefold=True, map01=b'I') b'cat'
map01 has to be told which letter the digit 1 meant, because in the
standard alphabet both I and L are live symbols with different
values -- the ambiguity Crockford's alphabet removes by not having
them. A repair at the decoder is a strictly weaker fix than an
alphabet that cannot be mistyped.
In the terminal¶
Verified output of base32_alphabets_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
1. THE COMMAND YOU CANNOT ASSUME IS THERE
------------------------------------------------------------------------
macOS 26 ships /usr/bin/base64 -- the FreeBSD one -- and there is
no /usr/bin/base32 beside it. On the machine this was measured the
name `base32` resolved to Homebrew's GNU coreutils 9.11 instead.
ubuntu:24.04 has both, out of the one coreutils package.
So a pipeline that reaches for base32 runs on a developer's Mac and
on CI and fails on a colleague's -- with `command not found`, which
at least stops rather than guessing. base32hex, Crockford and
z-base-32 have no command on either platform: GNU's `basenc` covers
base32hex, and nothing at all covers the other two.
That is why the rest of this script is tr.
2. A RENAME IS ONE tr
------------------------------------------------------------------------
RFC 4648 KRUGKIDROVUWG2ZAMJZG653OEBTG66BANJ2W24DTEBXXMZLSEB2GQZJANRQXU6JAMRXWOLQ
base32hex AHK6A83HELKM6QP0C9P6UTRE41J6UU10D9QMQS3J41NNCPBI41Q6GP90DHGNKU90CHNMEBG
Crockford AHM6A83HENMP6TS0C9S6YXVE41K6YY10D9TPTW3K41QQCSBJ41T6GS90DHGQMY90CHQPEBG
z-base-32 ktwgkedtqiwsg43ycj3g675qrbug66bypj4s4hdurbzzc3m1rb4go3jyptozw6jyctzsqmo
and back KRUGKIDROVUWG2ZAMJZG653OEBTG66BANJ2W24DTEBXXMZLSEB2GQZJANRQXU6JAMRXWOLQ
Four names for one encoding. Each tr is 32 characters in and 32
out, one for one, so no bit has moved and the round trip is exact.
Compare that with converting base32 to base64, which cannot be a
tr at all: those two cut the bits into different-sized pieces, so
the only way across is to decode to bytes and encode again.
3. THE SAME FOUR VALUES, SORTED TWICE
------------------------------------------------------------------------
bytes base32 base32hex
0011 AAIQ==== 008G====
0033 AAZQ==== 00PG====
0034 AA2A==== 00Q0====
00ff AD7Q==== 03VG====
sorted as base32 text: AA2A==== AAIQ==== AAZQ==== AD7Q====
sorted as base32hex text: 008G==== 00PG==== 00Q0==== 03VG====
Read the first list against the table: sorting the base32 text
puts 0034 first and 0011 second, scrambling four values that were
already in order. The base32hex list is still in byte order, and
RFC 4648 section 7 says that is the whole reason base32hex exists.
The '=' padding sorts too, which is a second reason a base32 string
makes a poor sort key: it is shorter than the alphabet question and
just as easy to miss.
4. WHAT tr CANNOT DO
------------------------------------------------------------------------
a Crockford typo AHM6A83HENMP6TS0
the same, misread by eye AHM6A83HENMP6TSO
The last character is the letter O where the data had the digit 0.
Crockford's alphabet has no O, so a decoder is entitled to repair
it -- and tr can do exactly that, one substitution:
tr 'IiLlOo' '111100' AHM6A83HENMP6TS0
That repair is only available because the alphabet left those
letters out. Run the same tr over an RFC 4648 string and it
destroys it: I, L and O are all live symbols there, standing for
8, 11 and 14, and nothing in the string says which was meant.
If you are coming from Python or ABAP¶
Python. base64.b32encode / b32decode are RFC 4648, and b32hexencode / b32hexdecode (3.10 and later) are the sorting variant — note that the decoders take the alphabet as a different function, not as a flag, so switching alphabets is a call-site change and grep can find it. For Crockford or z-base-32 there is nothing in the stdlib; str.translate(str.maketrans(RFC, OTHER)) over an RFC 4648 string is the whole implementation of the chunked reading, and a PyPI package is worth it only for the check symbol. Two habits: decode with casefold=True for anything a human typed, and if you reach for map01, write down in the same line why the digit is ambiguous — the argument reads like a formality and is not one.
ABAP (Not machine-checked — CI cannot run ABAP.) There is no base32 in the standard library the way cl_http_utility=>encode_x_base64( ) gives you base64, so a base32 field on an interface means either an add-on or your own routine over xstring. If you write one, write it against RFC 4648 and say so in the interface agreement, because "base32" alone does not distinguish the four alphabets on this page — and if the partner says Crockford, ask whether their value is a number or a byte string, since that decides the answer for every length not divisible by five. The same discipline as a code page: the tool does not carry the agreement, the document does. See SAP code pages.
Try it¶
cd 03_Encodings/base32_alphabets/examples
python3 base32_alphabets_py.py
bash base32_alphabets_sh.sh
Without the machine: a partner's API returns an identifier as AHM6A83HENMP6TS0, their documentation says "Crockford Base32", and your decoder returns bytes that are one short of what their example shows. Say which two things could disagree here, which question you would put to the partner, and what you would ask them to add to the specification so the next implementer does not have to ask.
Practice¶
Which of the four is a different encoding? A converter offers standard Base32 (RFC 4648), base32hex, z-base-32 and Crockford's. Feed all four the bytes 01 02 03 04 — 32 bits, which is not a whole number of five-bit pieces.
Three of the four will produce strings you can convert between with a 32-entry lookup table. Say which one you cannot, and why — the answer is about which end gets padded, and about whether the thing being encoded is a byte string or a number.
Answers
Verified output of base32_alphabets_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.
input 01 02 03 04 32 bits, and 32 is not a multiple of 5
RFC 4648 standard AEBAGBA=
RFC 4648 base32hex 0410610=
z-base-32 symbols yrbygby
The first three are ONE encoding with three symbol tables. Same bit
cutting, same padding, same length -- you can convert between them with
a 32-entry lookup and nothing else. The alphabet is a presentation
choice: base32hex sorts in the same order as the data, z-base-32 drops
the characters people mistype.
CROCKFORD IS THE ODD ONE, AND HERE IS THE PROOF
as a NUMBER 16909060 = 0x1020304
Crockford (base 32 of that number) G40R4
RFC 4648 of the same bytes AEBAGBA=
They are not re-tablings of each other, and the reason is which end gets
padded. RFC 4648 cuts the BIT STREAM from the left and zero-fills the
last group on the RIGHT, then marks it with '='. Crockford is a notation
for an INTEGER, so leading zeros are meaningless and any padding is on
the LEFT -- there is no '=' because there is no partial group, only a
smaller number.
So the two agree exactly when the input is a whole number of five-bit
pieces and disagree otherwise:
0000000000 40 bits, 0 left over -> same shape
01020304 32 bits, 2 left over -> DIFFERENT ANSWERS
Which is why 'Base32' on a converter menu is not a specification. Ask
which alphabet, and ask whether the thing being encoded is a byte string
or a number -- those are two different questions and only one of them is
about symbols.
See also¶
- Binary to text — the width question, the padding, and why decode is many-to-one
- Escaping into ASCII — the other reason text gets encoded twice, and the one scheme of the four there that names its charset
- Hex is a shorthand — base16, where the pieces divide the byte evenly and none of this arises
translateis a table, keyed by ordinal ↗ — the method the rename is built on: why the table is adictof ordinals, and why one pass is what makes an alphabet swap exactly reversibletrandsortwork a byte at a time — the tool that performs the rename, and why the sort-order promise needsLC_ALL=C