ULID is identifier usually used in database, as alternative to UUID or normal integer. One of main advantage is ULID is sortable; by including the timestamp data in the identifier hence making it sortable when storing in database.
Example, 01JGFJJZ00XHF7E02JJ03AE4T7
is ULID that was generated at 01 Jan 2025 00:00:00.000 UTC
. We can break it down as below:
- First 10 characters (or 48-bit) is
01JGFJJZ00
, which is the timestamp portion - Next 16 characters (or 80-bit) is
XHF7E02JJ03AE4T7
, which is the randomness portion
While the randomness portion is randomly generated, I’m interested to know how’s the timestamp portion is produced.
So how’s the timestamp portion is produced?
Let’s dive on the technicality, and get ready for some mental workout.
To convert the datetime to ULID timestamp portion:
- Convert the datetime to epoch (or UNIX timestamp) milliseconds
- Convert epoch timestamp to binary number
- Convert the binary number to Crockford’s Base32; which is set of alphanumeric characters of 0 to 9 and A to Z, except I, L, O, U to avoid confusion
Using same example like above; we are using ULID 01JGFJJZ00XHF7E02JJ03AE4T7
in which are generated at 01 Jan 2025 00:00:00.000 UTC
.
Below is the representation of the datetime in epoch and binary number of the epoch:
Datetime | 01 Jan 2025 00:00:00.000 UTC |
Epoch | 1735689600000 |
Binary | 11001010000011111001010010111110000000000 |
Since the total length of the binary bit is 41, we add leading 0s so that the total length of the binary bit is 50 (for easier understanding later).
The updated binary number is 00000000011001010000011111001010010111110000000000
; where the highlighted red is leading 0s that we just added.
To convert to Crockford’s Base32, we need to divide the binary number to group of 5-bit (the reason I make it 50 characters previously). And from that group of 5-bit, we will convert to decimal and lastly mapped it to Crockford’s Base32. The example is depicted below:
Group of 5-bit binary | 00000 | 00001 | 10010 | 10000 | 01111 | 10010 | 10010 | 11111 | 00000 | 00000 |
Decimal value | 0 | 1 | 18 | 16 | 15 | 18 | 18 | 31 | 0 | 0 |
Crockford’s Base32 | 0 | 1 | J | G | F | J | J | Z | 0 | 0 |
And below is complete Crockford’s Base32 mapping table for your reference.
Decimal value | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
Crockford’s Base32 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F | G | H | J | K | M | N | P | Q | R | S | T | V | W | X | Y | Z |
Hence, the timestamp portion of ULID which is first 10 characters for timestamp 01 Jan 2025 00:00:00.000 UTC
is 01JGFJJZ00
I left some example in PHP as your brain exercise to figure it out:
<?php
$crockford_mapping = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
$timestamp = 1735689600000;
$result = '';
while ($timestamp > 0) {
$binary = $timestamp % 32;
$result = $crockford_mapping[$binary] . $result;
$timestamp = intval($timestamp / 32);
// or you can use bitwise operator
// another brain exercise for you to figure out why it's same like dividing by 32
// $timestamp >>= 5;
}
echo str_pad($result, 10, '0', STR_PAD_LEFT); // 01JGFJJZ00