Choosing the Right Time-Ordered ID: A Deep Dive into UUIDv7 and ULID

In modern distributed systems, choosing the right identifier strategy is a foundational architectural decision. For decades, RFC 4122 UUIDv4 (fully random) was the industry standard for decentralized ID generation. However, UUIDv4’s randomness introduced a severe database performance penalty: index fragmentation in B-Tree indexed databases.

To solve this, “lexicographically sortable” or “time-ordered” identifiers emerged. This article provides a comprehensive comparison between the two leading modern standards: ULID (Universally Unique Lexicographically Sortable Identifier) and UUIDv7 (Universally Unique Identifier version 7).

1. The Core Problem: Why Not UUIDv4?

Most relational databases (such as PostgreSQL, MySQL, and SQL Server) use B-Trees or B+Trees for primary key indexing. B-Trees perform optimally when new keys are inserted in sequential order.

When you insert records with fully random UUIDv4 keys:

  1. New keys are scattered uniformly across the index space.
  2. This causes frequent page splits as the database attempts to force keys into already-full index pages.
  3. Disk I/O spikes, cache hit ratios drop, and write performance degrades exponentially as the dataset grows.

To prevent this, we need identifiers that are time-ordered (monotonically increasing over time) yet still globally unique and cryptographically secure without a central coordinator.

2. Under the Hood: Structural Breakdown

Both UUIDv7 and ULID are 128-bit identifiers that combine a 48-bit millisecond-precision timestamp with random entropy. However, they allocate and format those bits differently.

ULID Structure

ULID was designed as an alternative to UUIDv4 with readability in mind. It consists of:

ulid-structure
 0                   1                   2                   3  
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1  
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  
|                      32-bit Timestamp                         |  
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  
|     16-bit Timestamp          |        16-bit Entropy         |  
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  
|                      32-bit Entropy                           |  
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  
|                      32-bit Entropy                           |  
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Click to expand and view more

UUIDv7 Structure (RFC 9562)

Published in May 2024, RFC 9562 officially standardized UUIDv7 to bring time-ordering to the standard UUID specification while maintaining backwards compatibility with the layout of previous RFC 4122 versions.

A UUIDv7 allocates:

UUIDv7-structure
 0                   1                   2                   3  
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1  
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  
|                      32-bit Timestamp                         |  
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  
|     16-bit Timestamp          |  Ver  |       12-bit Rand     |  
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  
|Var|                           62-bit Rand                     |  
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  
|                                                               |  
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Click to expand and view more

3. Key Commonalities (The “Likes”)

Despite their structural nuances, UUIDv7 and ULID share several core properties:

  1. 128-Bit Storage Footprint: Both compile down to exactly 16 bytes of binary data.
  2. K-Sortability: Because both start with a 48-bit Unix timestamp, IDs generated at different times will naturally sort chronologically. Within the same millisecond, sorting defaults to random order (or sequential order if monotonicity features are used).
  3. Millisecond Resolution: Both roll over to the next timestamp step every 1 millisecond.
  4. Collision Resistance: With 74 bits (UUIDv7) and 80 bits (ULID) of random entropy, the mathematical probability of a collision in a distributed system is practically zero.

$$P_{collision} \approx 0$$

4. Architectural Differences

Feature UUIDv7 (RFC 9562) ULID
Standardization Status Official IETF Standard (RFC 9562) De facto community specification
Text Representation Standard hex-and-hyphen (8-4-4-4-12) Crockford’s Base32 (no hyphens)
Length (Text) 36 characters 26 characters
Case Sensitivity Case-insensitive (typically lowercase) Case-insensitive (typically uppercase)
Entropy Bits 74 bits 80 bits
Monotonicity Optional (via sub-millisecond sequence) Native (specification details incrementation)
URL Safety Yes, but longer Yes, extremely compact and clean
Database Compatibility Native UUID types natively support it Requires storage as raw bytes or strings

Text Formatting Comparison

ULID’s Crockford’s Base32 encoding excludes ambiguous characters (I, L, O, U) to avoid human-reading errors, making it highly suitable for user-facing applications (such as invoice numbers or URL routes).

5. Optimal Use Cases

When to choose UUIDv7:

When to choose ULID:

6. Implementation in C

The following C program provides a fully functional, self-contained implementation of both UUIDv7 and ULID generation, complete with string serialization. It uses standard POSIX headers to acquire sub-second system time.

C
#include <stdio.h>  
#include <stdint.h>  
#include <stdlib.h>  
#include <time.h>  
#include <sys/time.h>  
#include <string.h>

// Struct representing a 128-bit identifier  
typedef struct {  
    uint8_t bytes[16];  
} id128_t;

// Crockford's Base32 Alphabet (used by ULID)  
static const char BASE32_ALPHABET[] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";

// Helper: Get Unix epoch timestamp in milliseconds  
uint64_t get_current_time_ms(void) {  
    struct timeval tv;  
    gettimeofday(&tv, NULL);  
    return ((uint64_t)tv.tv_sec * 1000ULL) + ((uint64_t)tv.tv_usec / 1000ULL);  
}

// Helper: Generate pseudo-random bytes  
// NOTE: For production environments, use a cryptographically secure source like /dev/urandom  
void fill_random_bytes(uint8_t *buffer, size_t length) {  
    for (size_t i = 0; i < length; i++) {  
        buffer[i] = rand() & 0xFF;  
    }  
}

// Generates an RFC 9562 compliant UUIDv7  
id128_t generate_uuid_v7(void) {  
    id128_t uuid;  
    uint64_t ts = get_current_time_ms();

    // 1. 48-bit timestamp (bytes 0 to 5)  
    uuid.bytes[0] = (uint8_t)((ts >> 40) & 0xFF);  
    uuid.bytes[1] = (uint8_t)((ts >> 32) & 0xFF);  
    uuid.bytes[2] = (uint8_t)((ts >> 24) & 0xFF);  
    uuid.bytes[3] = (uint8_t)((ts >> 16) & 0xFF);  
    uuid.bytes[4] = (uint8_t)((ts >> 8) & 0xFF);  
    uuid.bytes[5] = (uint8_t)(ts & 0xFF);

    // 2. Fill remaining 10 bytes with random entropy  
    fill_random_bytes(&uuid.bytes[6], 10);

    // 3. Set UUIDv7 version bits (0111) in byte 6 (bits 4-7)  
    uuid.bytes[6] = (uuid.bytes[6] & 0x0F) | 0x70;

    // 4. Set UUID variant bits (10xx) in byte 8 (bits 6-7)  
    uuid.bytes[8] = (uuid.bytes[8] & 0x3F) | 0x80;

    return uuid;  
}

// Generates a ULID compliant binary payload  
id128_t generate_ulid(void) {  
    id128_t ulid;  
    uint64_t ts = get_current_time_ms();

    // 1. 48-bit timestamp (bytes 0 to 5)  
    ulid.bytes[0] = (uint8_t)((ts >> 40) & 0xFF);  
    ulid.bytes[1] = (uint8_t)((ts >> 32) & 0xFF);  
    ulid.bytes[2] = (uint8_t)((ts >> 24) & 0xFF);  
    ulid.bytes[3] = (uint8_t)((ts >> 16) & 0xFF);  
    ulid.bytes[4] = (uint8_t)((ts >> 8) & 0xFF);  
    ulid.bytes[5] = (uint8_t)(ts & 0xFF);

    // 2. 80-bit entropy (bytes 6 to 15)  
    fill_random_bytes(&ulid.bytes[6], 10);

    return ulid;  
}

// Format a 128-bit ID as an RFC 4122/9562 UUID string  
void format_uuid_string(const id128_t *id, char *out_str) {  
    sprintf(out_str,   
            "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",  
            id->bytes[0], id->bytes[1], id->bytes[2], id->bytes[3],  
            id->bytes[4], id->bytes[5],  
            id->bytes[6], id->bytes[7],  
            id->bytes[8], id->bytes[9],  
            id->bytes[10], id->bytes[11], id->bytes[12], id->bytes[13], id->bytes[14], id->bytes[15]);  
}

// Format a 128-bit ID as a 26-character Crockford Base32 ULID string  
void format_ulid_string(const id128_t *id, char *out_str) {  
    // ULID string decoding operates on 5-bit chunks.  
    // 128 bits represented in 26 characters (leaving 2 unused bits in the first character).  
      
    uint8_t temp[26];  
      
    // Unpack 128 bits into 26 5-bit indices  
    temp[0]  = (id->bytes[0] & 224) >> 5;  
    temp[1]  = id->bytes[0] & 31;  
    temp[2]  = (id->bytes[1] & 248) >> 3;  
    temp[3]  = ((id->bytes[1] & 7) << 2) | ((id->bytes[2] & 192) >> 6);  
    temp[4]  = (id->bytes[2] & 62) >> 1;  
    temp[5]  = ((id->bytes[2] & 1) << 4) | ((id->bytes[3] & 240) >> 4);  
    temp[6]  = ((id->bytes[3] & 15) << 1) | ((id->bytes[4] & 128) >> 7);  
    temp[7]  = (id->bytes[4] & 124) >> 2;  
    temp[8]  = ((id->bytes[4] & 3) << 3) | ((id->bytes[5] & 224) >> 5);  
    temp[9]  = id->bytes[5] & 31;  
    temp[10] = (id->bytes[6] & 248) >> 3;  
    temp[11] = ((id->bytes[6] & 7) << 2) | ((id->bytes[7] & 192) >> 6);  
    temp[12] = (id->bytes[7] & 62) >> 1;  
    temp[13] = ((id->bytes[7] & 1) << 4) | ((id->bytes[8] & 240) >> 4);  
    temp[14] = ((id->bytes[8] & 15) << 1) | ((id->bytes[9] & 128) >> 7);  
    temp[15] = (id->bytes[9] & 124) >> 2;  
    temp[16] = ((id->bytes[9] & 3) << 3) | ((id->bytes[10] & 224) >> 5);  
    temp[17] = id->bytes[10] & 31;  
    temp[18] = (id->bytes[11] & 248) >> 3;  
    temp[19] = ((id->bytes[11] & 7) << 2) | ((id->bytes[12] & 192) >> 6);  
    temp[20] = (id->bytes[12] & 62) >> 1;  
    temp[21] = ((id->bytes[12] & 1) << 4) | ((id->bytes[13] & 240) >> 4);  
    temp[22] = ((id->bytes[13] & 15) << 1) | ((id->bytes[14] & 128) >> 7);  
    temp[23] = (id->bytes[14] & 124) >> 2;  
    temp[24] = ((id->bytes[14] & 3) << 3) | ((id->bytes[15] & 224) >> 5);  
    temp[25] = id->bytes[15] & 31;

    for (int i = 0; i < 26; i++) {  
        out_str[i] = BASE32_ALPHABET[temp[i]];  
    }  
    out_str[26] = '\0';  
}

int main(void) {  
    // Seed PRNG  
    srand((unsigned int)time(NULL));

    printf("--- Generating UUIDv7 and ULID Identifiers ---\n\n");

    for (int i = 0; i < 5; i++) {  
        id128_t uuid = generate_uuid_v7();  
        id128_t ulid = generate_ulid();

        char uuid_str[37];  
        char ulid_str[27];

        format_uuid_string(&uuid, uuid_str);  
        format_ulid_string(&ulid, ulid_str);

        printf("Generation Iteration [%d]:\n", i + 1);  
        printf("  UUIDv7 string : %s\n", uuid_str);  
        printf("  ULID string   : %s\n\n", ulid_str);  
    }

    return 0;  
}
Click to expand and view more

7. Conclusion

Ultimately, the choice between UUIDv7 and ULID is a question of platform target rather than architectural efficacy.

Copyright Notice

Author: Vorasilp K.

Link: https://vorasilp.xom/posts/choosing-the-right-time-ordered-id-a-deep-dive-into-uuidv7-and-ulid/

License: CC BY-NC-SA 4.0

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. Please attribute the source, use non-commercially, and maintain the same license.

Start searching

Enter keywords to search articles

↑↓
ESC
⌘K Shortcut