DEV Community

Cover image for Stop Using Raw UUIDs: Type-Safe, Prefixed IDs in Rust (Stripe-style)
Harihar Nautiyal
Harihar Nautiyal

Posted on

Stop Using Raw UUIDs: Type-Safe, Prefixed IDs in Rust (Stripe-style)

We have all been there. You are staring at your server logs at 2 AM, trying to debug a request, and you see this:

Processing request for ID: 550e8400-e29b-41d4-a716-446655440000
Enter fullscreen mode Exit fullscreen mode

Is that a User ID? An Order ID? An API Key? Who knows. It’s just a blob of hex characters.

Even worse, have you ever written a function like this?

fn process_payment(user_id: Uuid, order_id: Uuid) { ... }
Enter fullscreen mode Exit fullscreen mode

If you accidentally call process_payment(order_id, user_id), the compiler won’t stop you. They are both just Uuid. You won't find out until your database throws a "Record not found" error—or worse, you corrupt data.

I’ve always admired how Stripe handles this. Their IDs look like cus_018... or ch_018.... They are self-describing and readable.

I wanted that same experience in Rust, but with the compiler enforcing safety. So, I built puuid.

What is puuid?

puuid (Prefixed UUID) is a lightweight Rust crate that wraps the standard uuid library. It gives you two main things:

  1. Readability: IDs print as "user_018c..." instead of just numbers.
  2. Type Safety: You cannot mix up a UserId with an OrderId.

How it works

It’s surprisingly simple to set up. You define your prefixes once (usually in your types.rs):

use puuid::{Puuid, prefix};

// 1. Define your prefixes
prefix!(User, "user");
prefix!(Order, "ord");

// 2. Create strong types
pub type UserId = Puuid<User>;
pub type OrderId = Puuid<Order>;
Enter fullscreen mode Exit fullscreen mode

Now, look at what happens when we try to make that mistake from earlier:

fn delete_order(id: OrderId) {
    println!("Deleting order: {}", id);
}

fn main() {
    let user_id = UserId::new_v7();

    // ❌ COMPILE ERROR: expected OrderId, found UserId
    delete_order(user_id); 
}
Enter fullscreen mode Exit fullscreen mode

The Rust compiler now protects you from your own typos.

"But does it kill performance?"

This was my main concern when building it. The answer is no.

Puuid<T> is a #[repr(transparent)] wrapper around the standard uuid::Uuid.

  • Memory: It takes up the exact same space as a standard UUID (16 bytes).
  • Database: You can insert it directly as a UUID type (via .into_inner()) or store it as text if you want to keep the prefix visible.
  • Methods: It implements Deref, so you can still call .as_bytes(), .get_version(), or any other method from the standard uuid crate.

A Note on UUID v7

By default, puuid encourages UUID v7.

If you haven't switched from v4 (random) to v7 yet, you should. v7 UUIDs are time-sortable. This means that when you store them in a database, they naturally index according to time creation. It prevents index fragmentation and makes INSERT performance much faster for large tables.

let id = UserId::new_v7();
// Output: user_018c6427-4f30-7f89-a1b2-c3d4e5f67890
Enter fullscreen mode Exit fullscreen mode

Serde is Magic

If you are building a JSON API (with Axum, Actix, etc.), puuid handles the serialization for you.

#[derive(Serialize, Deserialize)]
struct Checkout {
    id: OrderId,
    customer: UserId,
}
Enter fullscreen mode Exit fullscreen mode

If a client sends this JSON:

{
  "id": "ord_018...",
  "customer": "user_018..."
}
Enter fullscreen mode Exit fullscreen mode

It works perfectly. But if they send a raw UUID or mix up the prefixes, the deserializer rejects it immediately with a validation error.

Try it out

I’d love to hear your thoughts on the API design. I’ve been using it in my own projects to clean up my logs and it has saved me from at least three "argument swap" bugs already.

Crates.io: crates.io/crates/puuid
Docs: h01.in/projects/puuid
GitHub: github.com/h01-team/puuid

Let me know what you think! 🦀

Top comments (0)