Why Stripe, Discord, and Shopify don't use UUIDs
On this page
Open the developer console for almost any modern API and you’ll find one of two things: a UUID, or a deliberate choice not to use one. The deliberate choices are interesting — three of the largest API providers on the web use systems they built themselves, and the design rationale behind each is worth understanding.
This post walks through Stripe’s prefixed IDs, Discord’s Snowflakes, and Shopify’s Global IDs. None of them is “better than UUIDs” in the abstract — but each one solves problems UUIDs don’t, and each one ignores problems UUIDs do solve.
Stripe: prefixed IDs (cus_..., ch_..., evt_...)
Stripe’s identifiers look like this:
cus_NffrFeUfNV2Hib
ch_3OhrzS2eZvKYlo2C0xAZGXKx
evt_3OhrzS2eZvKYlo2C0xAZGXKx
in_1OhrzS2eZvKYlo2C0xAZGXKx
sub_1OhrzS2eZvKYlo2C0xAZGXKx
Two parts: a type prefix that identifies the resource (cus_ for
customer, ch_ for charge, evt_ for event, in_ for invoice,
sub_ for subscription, pi_ for payment intent, etc.) followed by
a random base62-ish string.
This isn’t a UUID. The format looks like KSUID or a similar ULID-style identifier underneath, but the wrapper is what matters.
Why prefixes?
Stripe ships SDKs in 8+ languages with thousands of developer-facing endpoints. Their API ergonomic priority is the developer can read an ID and immediately know what it is. Concrete benefits:
-
Self-documenting logs. A line
Refund failed: ch_3OhrzS2…tells you the failed thing was a charge. With a UUID, you’d need to look it up. -
Fail-fast on misuse. If a Stripe SDK method expects a customer ID and you pass
pi_…(payment intent), the SDK can reject it client-side without a round-trip. UUIDs can’t. -
No lookups for type detection. Webhook payloads are JSON; parsing
event.data.object.idand routing by prefix is a couple of characters of code instead of a database join. -
Forgiving copy-paste. If a developer accidentally pastes a payment-intent ID into a “subscription ID” field, the system can say “that looks like a payment intent, did you mean…” instead of a 404.
What’s the cost?
- Lock-in. A prefixed ID format is Stripe’s IP. You can’t generate Stripe-format IDs in your own code; you can’t import them; the format only makes sense within Stripe.
- Bigger storage and URLs. A 24-character ID with a prefix is longer than a 16-byte binary UUID.
- Schema migration cost. Stripe has explicitly migrated their ID format twice — you can’t do that lightly.
For Stripe’s audience (developers building integrations), the self-documentation wins. Their support tickets are a mountain of “I sent the wrong ID type” — anything that prevents that mistake at the client SDK layer pays for itself.
If you want this pattern: KSUID or ULID with a type prefix in front gets you most of the way there. We have a comparison of UUID v7 vs ULID here.
Discord: Snowflake IDs
Discord’s IDs look very different:
1078123456789012345
1234567890123456789
Plain integers. Specifically: 64-bit integers following a format called Snowflake, originally invented at Twitter (now X) in 2010.
The 64 bits are split:
┌─────────────────────────────────────────────────────────────┐
│ 1-bit │ 41-bit ms timestamp │ 5-bit datacenter │ 5-bit │
│ sign │ (since 2015 epoch) │ ID │ worker ID │
│ (0) │ │ │ │
│ │ │ │ │
│ │ │ │ │
│ │ │ ▼ │
│ │ │ 12-bit │
│ │ │ sequence │
└─────────────────────────────────────────────────────────────┘
Discord’s official documentation publishes this layout. Given a Discord ID, you can decode the exact millisecond it was created, which Discord datacenter generated it, and which worker process within that datacenter.
Why Snowflakes?
Discord’s calculus is different from Stripe’s. They aren’t selling APIs to integrators — they’re running a real-time chat platform with hundreds of millions of users.
-
Compact storage. 8 bytes vs 16 (UUID v7). At Discord’s scale (billions of messages per day, Cassandra cluster storing trillions of rows) the 2× difference compounds.
-
Time-ordered out of the box. A Snowflake’s high bits are a timestamp. Sort numerically → sort chronologically. Database indexes (especially Cassandra’s wide-row partitioning) become simpler.
-
Embedded provenance. “Which datacenter handled this message?” is a 5-bit lookup, not a join.
-
Public timestamp visibility. Users see message IDs in URLs (
https://discord.com/channels/<server>/<channel>/<message>) and tools can derive the message’s creation time without an API call. A nice-to-have for moderation, search, and embedded timestamps.
What’s the cost?
Three real costs:
-
Coordination at generation time. Datacenter and worker IDs must be unique. Discord has to manage worker-ID assignment at bootstrap. UUIDs (especially v4 and v7) need no such coordination — that’s their whole pitch.
-
Year 2080 problem. A 41-bit millisecond counter from 2015 overflows in 2084. Not Discord’s problem today, but a real time bomb.
-
Privacy. Anyone with a Discord ID can derive when the message was sent and which datacenter. For Discord that’s a feature; for another product it could be a leak.
Compare to UUID v7, which:
- Encodes a 48-bit ms timestamp (good for ~8000 more years)
- Has 74 random bits (no coordination needed)
- Is 16 bytes (2× the storage)
- Reveals only the timestamp, not the datacenter
UUID v7 was published as RFC 9562 in May 2024. If Discord were designing today, they’d plausibly pick v7 — they’d lose the datacenter-ID feature but gain coordination-free generation. They won’t migrate; rewriting trillions of message IDs costs more than any benefit.
Shopify: GIDs (Global IDs)
Shopify’s REST API uses simple incrementing integers (123456789),
but their newer GraphQL API uses Global IDs:
gid://shopify/Product/108828309
gid://shopify/Order/450789469
gid://shopify/Customer/207119551
Three parts joined: the literal prefix gid://shopify/, the resource
type, and the underlying integer ID.
Why Global IDs?
Shopify (documented at length) chose this for the GraphQL Relay specification. Relay requires every object in a GraphQL graph to be addressable by a single opaque global ID. The format Shopify picked is functional:
-
Globally unique across resource types. A product ID 100 and a customer ID 100 produce different GIDs (
gid://shopify/Product/100vsgid://shopify/Customer/100). -
Self-documenting. Like Stripe, a GID tells you what it is at a glance.
-
Compatible with REST IDs. The numeric portion is the same as the REST API, so apps can convert one to the other without a lookup. Migrating an old REST integration to GraphQL is mostly a sed expression.
-
GraphQL Relay-ready. Relay’s caching, pagination, and refetch semantics all assume a single global ID type.
What’s the cost?
- Verbose. A GID is 30+ characters; a REST integer is 8.
- Two-format stack. REST and GraphQL have different ID shapes, and the SDKs have to translate.
- The numeric part still has to come from somewhere. Shopify doesn’t escape the underlying-ID problem; they just wrap it. The underlying IDs in their system are still incrementing integers, with all the usual auto-increment problems at scale.
The GID pattern is a layer on top of whatever underlying ID system Shopify uses. If the underlying integers ever need to change (say, to UUID v7 for a new database), the GID wrapper insulates external clients from the change. That’s worth a lot.
Three different problems
Each company solved a different problem:
| Company | Underlying problem | Solution | Cost they accepted |
|---|---|---|---|
| Stripe | Developer mistakes mixing up resource types | Prefixed IDs with type metadata | Format lock-in; can’t generate outside Stripe |
| Discord | Compact storage at petabyte scale | 64-bit Snowflake with embedded time + provenance | Worker-ID coordination; year-2080 ceiling |
| Shopify | GraphQL Relay needs single global ID type | URI-style wrapper around their existing REST integers | Verbose strings; REST/GraphQL translation cost |
UUID v7 is the boring correct answer for most new systems. Each of these three companies had a specific reason to deviate, and the deviation was deliberate, well-documented, and worth the cost. None of them ignored UUIDs out of NIH; they’re all aware of the trade-off.
When you should deviate
Default to UUID v7 (or v4 if you don’t need ordering). Deviate only when you can name the specific cost you’re paying:
- Custom prefix format when developer-mistake reduction beats format flexibility. Stripe-style. (KSUID with a prefix gets you 80% there.)
- 64-bit time-ordered integers (Snowflake) when 8 bytes vs 16 is a measurable cost at your scale. Discord-style. (Twitter’s Snowflake is the reference; Sonyflake is a commonly-used Go port.)
- Wrapped IDs for GraphQL Relay or any system that needs a single global identifier across types. Shopify-style.
For everyone else: UUID v7 is the right default. We have a longer discussion of the trade-offs in UUID as primary key and the v7 vs ULID comparison.
Try the tool
If you want to inspect a UUID and see what it embeds, paste it into the validator. For generating v7 IDs, see the v7 generator. And for a feel of what a hundred or a thousand v7 UUIDs look like in time order, the bulk generator generates up to 10,000 at a time.
Further reading
- Stripe API design philosophy (Stripe blog)
- Discord Snowflake reference (Discord developer docs)
- Shopify Global IDs (Shopify GraphQL docs)
- Twitter’s original Snowflake announcement (2010)
- How Discord stores billions of messages
- Our reference: UUID vs Snowflake
- Related across the network:
- epoch.tooljo.com — Snowflake IDs encode a millisecond timestamp; the converter decodes the time bits when you paste an extracted Snowflake.
- hash.tooljo.com/which-hash-function — UUID v3 and v5 are MD5- and SHA-1-derived; the article covers when that’s safe (deterministic IDs from a known namespace) vs not.
- jwt.tooljo.com — JWTs often carry a
Snowflake-style
jticlaim; the decoder lets you inspect them.