UUIDs and Identifiers

Choose between database sequences and UUIDs, generate and validate java.util.UUID values, and design IDs that work well in URLs and indexes.

SyntaxUUID id = UUID.randomUUID(); UUID parsed = UUID.fromString("3f2b8c1e-9d4a-4f6b-8a2e-5c7d9e0f1a2b"); UUID stable = UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8));

Every resource in an API needs an identifier. The two common choices are a number from a database sequence and a UUID, a 128-bit value usually written as 36 characters such as 3f2b8c1e-9d4a-4f6b-8a2e-5c7d9e0f1a2b.

Sequences versus UUIDs

Sequential numbers are small, fast to index and easy to read out over the phone. But they are assigned by the database, so you only know the ID after the insert, and in a URL they reveal how many orders you have and invite people to try the next number. That guessing only matters if authorisation checks are missing, and they must never be missing, but it still makes broken checks easy to exploit. UUIDs can be generated anywhere, in the service, in a client or offline, before anything touches the database, which suits idempotency keys, events and data merged from several systems.

What java.util.UUID gives you

  • UUID.randomUUID() makes a version 4 UUID: 122 random bits from a cryptographically strong generator. Collisions are not a practical concern.
  • UUID.nameUUIDFromBytes(bytes) makes a version 3 UUID, an MD5 hash of the input. The same input always gives the same UUID, which is useful for deriving a stable ID from a natural key. It is not secret: anyone who knows the input can compute it.
  • UUID.fromString parses text and throws IllegalArgumentException when it cannot. It also accepts some non-standard forms with short groups, so compare toString() with the lower-cased input if you want only the canonical form.
  • version() and variant() report what kind of UUID you have.

UUIDs in a database

Random version 4 values land all over a B-tree index, so inserts touch random pages and the index grows fragmented on large tables. Version 7, defined in RFC 9562 (2024), puts a millisecond timestamp in the first 48 bits so new IDs sort roughly by creation time and inserts stay near the end of the index. java.util.UUID in Java 25 cannot generate version 7, but building one takes a few lines, shown below, and libraries offer generators too. Store UUIDs in a native uuid column in PostgreSQL or BINARY(16) in MySQL rather than a 36-character string.

Practical rules

  • Validate path parameters: a value that is not a UUID is a 400, a valid UUID that matches nothing is a 404.
  • A UUID is an identifier, not a credential. Use a SecureRandom token for password resets and API keys.
  • A short type prefix, as in ord_... or cus_..., makes IDs in logs and support tickets self-explanatory and catches an ID of the wrong type being passed to an endpoint.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Generate an order ID in the service before the insert so the same ID can go into an outbox event in the same transaction.
  • Reject malformed ID path parameters with 400 before they reach the repository layer.
  • Use time-ordered UUIDs as primary keys on a large table to keep index inserts local and fast.

More examples

Validating an ID from the URL

Turning a parse failure into a 400 keeps garbage out of database queries and gives clients a clear error instead of a 500. Upper case is accepted because UUID text is case-insensitive, while the short form is refused so each order has exactly one valid URL.

Example · java
static String handle(String rawId, Map<UUID, String> orders) {
    UUID id;
    try {
        id = UUID.fromString(rawId);
    } catch (IllegalArgumentException e) {
        return "400 invalid id";
    }
    if (!id.toString().equals(rawId.toLowerCase(Locale.ROOT))) {
        return "400 non-canonical id";
    }
    String order = orders.get(id);
    return order == null ? "404 not found" : "200 " + order;
}

void main() {
    UUID known = UUID.fromString("3f2b8c1e-9d4a-4f6b-8a2e-5c7d9e0f1a2b");
    Map<UUID, String> orders = Map.of(known, "order for ada");

    List<String> inputs = List.of(
            "3f2b8c1e-9d4a-4f6b-8a2e-5c7d9e0f1a2b",
            "3F2B8C1E-9D4A-4F6B-8A2E-5C7D9E0F1A2B",
            "0b6f1d2c-7a3e-4c55-9e1f-2d3c4b5a6f70",
            "1-1-1-1-1",
            "1001");
    for (String input : inputs) {
        IO.println(input + " -> " + handle(input, orders));
    }
}

// Output:
// 3f2b8c1e-9d4a-4f6b-8a2e-5c7d9e0f1a2b -> 200 order for ada
// 3F2B8C1E-9D4A-4F6B-8A2E-5C7D9E0F1A2B -> 200 order for ada
// 0b6f1d2c-7a3e-4c55-9e1f-2d3c4b5a6f70 -> 404 not found
// 1-1-1-1-1 -> 400 non-canonical id
// 1001 -> 400 invalid id

Time-ordered version 7 UUIDs by hand

Because the timestamp sits in the leading hex digits, IDs created later sort later as text and as database values, which keeps inserts at the end of a B-tree index. The random bits still come from SecureRandom, and the creation time can be read back from the ID.

Example · java
static final SecureRandom RANDOM = new SecureRandom();

// RFC 9562 layout: 48-bit Unix time in ms, version 7, 12 random bits, variant, 62 random bits.
static UUID uuidV7(long epochMillis) {
    long msb = (epochMillis << 16) | 0x7000L | (RANDOM.nextInt() & 0x0FFF);
    long lsb = (RANDOM.nextLong() & 0x3FFF_FFFF_FFFF_FFFFL) | 0x8000_0000_0000_0000L;
    return new UUID(msb, lsb);
}

void main() {
    long start = Instant.parse("2026-10-13T09:00:00Z").toEpochMilli();
    List<UUID> ids = new ArrayList<>();
    for (int i = 0; i < 3; i++) {
        ids.add(uuidV7(start + i * 1000L));
    }

    UUID first = ids.getFirst();
    IO.println("version " + first.version() + ", variant " + first.variant());
    IO.println("time prefix: " + first.toString().substring(0, 13));
    IO.println("created at: " + Instant.ofEpochMilli(first.getMostSignificantBits() >>> 16));

    List<String> asText = ids.stream().map(UUID::toString).toList();
    IO.println("text order matches creation order: " + asText.equals(asText.stream().sorted().toList()));
}

// Output:
// version 7, variant 2
// time prefix: 01a1347d-7680
// created at: 2026-10-13T09:00:00Z
// text order matches creation order: true

Prefixed public IDs

The database still stores a plain 16-byte UUID; the prefix exists only at the API edge. A mixed-up ID now fails with a clear message instead of an unexplained 404.

Example · java
record PublicId(String prefix, UUID uuid) {
    String encode() {
        return prefix + "_" + uuid.toString().replace("-", "");
    }

    static PublicId decode(String expectedPrefix, String value) {
        String head = expectedPrefix + "_";
        if (!value.startsWith(head) || value.length() != head.length() + 32) {
            throw new IllegalArgumentException("not a " + expectedPrefix + " id: " + value);
        }
        String hex = value.substring(head.length());
        String dashed = hex.substring(0, 8) + "-" + hex.substring(8, 12) + "-" + hex.substring(12, 16)
                + "-" + hex.substring(16, 20) + "-" + hex.substring(20);
        return new PublicId(expectedPrefix, UUID.fromString(dashed));
    }
}

void main() {
    UUID uuid = UUID.fromString("3f2b8c1e-9d4a-4f6b-8a2e-5c7d9e0f1a2b");
    String orderId = new PublicId("ord", uuid).encode();
    IO.println(orderId);
    IO.println("round trip: " + PublicId.decode("ord", orderId).uuid().equals(uuid));

    try {
        PublicId.decode("cus", orderId);   // an order ID sent to the customers endpoint
    } catch (IllegalArgumentException e) {
        IO.println(e.getMessage());
    }
}

// Output:
// ord_3f2b8c1e9d4a4f6b8a2e5c7d9e0f1a2b
// round trip: true
// not a cus id: ord_3f2b8c1e9d4a4f6b8a2e5c7d9e0f1a2b

Discussion

  • Be the first to comment on this lesson.