---
id: 56
title: "IDs are an interface: prefix the type, randomise the body"
kind: note
status: current
date: 2026-08-12
authors:
  - "Theo Zourzouvillys"
tags: [api, design, data, security]
references:
  - id: stripeids
    title: "Designing APIs for humans: Object IDs (Stripe, 2022)"
    url: https://dev.to/stripe/designing-apis-for-humans-object-ids-3o5a
    abstract: "Stripe's rationale for type-prefixed identifiers (cus_…, ch_…, pi_…): the prefix makes an ID self-describing in logs, dashboards, and support tickets, lets tooling validate that the right kind of ID landed in the right field, and costs nothing at generation time."
  - id: uuidv7
    title: "RFC 9562 — Universally Unique IDentifiers (UUIDs), including UUIDv7"
    url: https://www.rfc-editor.org/rfc/rfc9562.html
    abstract: "The 2024 revision of the UUID standard. UUIDv7 combines a millisecond Unix timestamp prefix with random bits, giving identifiers that sort roughly by creation time — index-friendly where fully random UUIDv4 causes write amplification — while remaining unguessable in the random portion."
  - id: tankproblem
    title: "The German tank problem"
    url: https://en.wikipedia.org/wiki/German_tank_problem
    abstract: "The classic statistical result: given a handful of sequentially-assigned serial numbers, an observer can estimate the total population with surprising accuracy — as the Allies did with German tank production. The reason sequential public identifiers leak your volumes to anyone who sees two of them."
summary: "An ID is read by more than your database: humans in logs, machines at boundaries, adversaries probing. Serve all three — a type prefix so IDs self-describe and misuse fails at parse time, a random body so nothing leaks or enumerates, time-ordered only when the index needs it."
supersedes: null
superseded_by: null
aliases: []
crossrefs:
  ZFN-15: "Tenant-partitioned data is where ID discipline pays off first: the partition key travels next to the ID everywhere, and a typed ID makes it impossible to quietly join the wrong kinds together."
  ZFN-14: "The schema is where ID types belong — declare each field as a distinct ID type, not 'string', and generated clients enforce this note for free."
  ZFN-49: "The same instinct applied to verification: an ID whose validity is checkable by computation (prefix, shape, checksum) rejects garbage at the edge without a database round-trip."
---

## TL;DR

**An identifier is not a database concern; it's a public interface with three audiences.** Humans
read IDs in logs, support tickets, and incident channels. Machines parse them at every service
boundary. Adversaries study them for structure, volume, and guessability. Design for all three:

- **Prefix the type**: `cus_a1B2…`, `inv_9xQ4…` — the [Stripe convention](ref:stripeids). An ID
  alone on a dashboard tells you what it is; an ID pasted into the wrong field fails at parse
  time instead of at 3 a.m.
- **Randomise the body**: at least 128 bits of entropy, encoded in a compact case-safe alphabet.
  Never expose a database auto-increment — sequential IDs leak your volumes
  ([the German tank problem](ref:tankproblem)) and hand enumeration attacks a road map.
- **Order by time only when the storage needs it** ([UUIDv7](ref:uuidv7)-style), and accept that
  you're publishing creation time when you do.
- **Type them in code and schema**, not just in the string: an `InvoiceId` that can't be passed
  where a `CustomerId` belongs turns a whole bug class into compile errors
  ([ZFN-14](/zfn/14-schema-first-apis-generate-clients/)).
- **Never parse meaning out of the body.** The prefix is the only semantic content; everything
  after it is opaque.

## Context

Identifier design gets decided implicitly, on day one, by whatever the ORM or the database
defaults to — and it's nearly impossible to change later, because IDs end up in URLs, webhooks,
customer databases, printed invoices, and other people's code. It deserves the five deliberate
minutes it rarely gets.

The failure modes of the defaults:

- **Auto-increment integers** are the worst public identifier: they enumerate (walk
  `/invoices/1041`, `/invoices/1042`, and hope the authorisation check is as tired as the
  developer — the IDOR pattern), they leak volume and growth rate to anyone who sees two of them
  ([German tank problem](ref:tankproblem)), and they collide the moment you shard or merge
  datasets ([ZFN-15](/zfn/15-partition-customer-data-by-tenant/)).
- **Bare UUIDv4** fixes guessability and coordination but serves the humans and machines badly:
  `9f2c8d1e-…` on a dashboard could be a user, a session, or a payment — you find out by grepping
  four tables. And fully random keys scatter B-tree inserts, which at write-heavy scale is real
  index pain.
- **"Smart" IDs that encode meaning** — region, shard, customer segment packed into the body —
  rot as facts change (data moves region; the ID says otherwise, forever) and invite callers to
  parse and depend on the structure.

The observation that reframes it: **every ID is read far more often by humans and boundary code
than it is used as a storage key.** An engineer triaging an incident sees dozens of them an hour.
A support agent pastes them between systems. Every service that receives one must decide whether
it's plausible before hitting a datastore. The identifier is the one part of your data model that
travels *everywhere* — so it should carry its type on its face and nothing else at all.

> [!aside]
>
> The quiet superpower of typed prefixes is negative space: the wrong ID in the wrong place
> becomes *visibly* wrong. A `sess_` where a `cus_` belongs is caught by the first regex, the
> first code review, the first glance at a log line. Nobody ever debugs the incident that got
> rejected at parse time.

## Recommendation

**Adopt one identifier scheme, everywhere, on day one:**

- **Shape: `<prefix>_<body>`.** Prefix is a short lowercase token from a registered list — one
  per entity type, recorded next to the schema ([ZFN-14](/zfn/14-schema-first-apis-generate-clients/)).
  Registration matters: prefixes are forever, and two teams independently minting `pr_` is a
  merge you can't do.
- **Body: ≥128 random bits**, encoded base32/base58-style — no ambiguous characters, no case
  sensitivity surprises, double-click-selectable, URL-safe. Generated by the service that owns
  the entity, never by the caller (a caller-supplied ID is a different thing — an idempotency
  key ([ZFN-19](/zfn/19-annotate-readonly-idempotent-endpoints/)) — with different rules).
- **Time-ordered variants where write locality matters:** a [UUIDv7](ref:uuidv7)-style
  timestamp-prefixed body for high-volume, index-heavy tables. Be honest about the trade: the ID
  now publishes its creation instant. For most entities that's fine; for a few (say, anything
  that reveals when a customer did something sensitive) it isn't — so make ordered-vs-random a
  per-type decision, not a global one.
- **Internal keys can differ from public IDs** — a table can cluster on whatever it likes — but
  then the public ID is the *only* one that ever leaves the service. The moment an internal key
  appears in a URL, it's public API.
- **Enforce types at every boundary.** In the schema, each ID field is its own named type. In
  code, wrap them (newtype/branded types) so cross-assignment doesn't compile. At ingress,
  validate prefix and shape before touching storage — malformed IDs get a clean `400`, not a
  table scan ([ZFN-49](/zfn/49-verify-by-computation-not-lookup/) — the cheap check is a
  computation, not a lookup).
- **An unguessable ID is not an authorisation.** Knowing the ID must never be the access check —
  every read still verifies the caller's right to the resource
  ([ZFN-10](/zfn/10-verify-resource-owner/) is the same rule one level up). Unguessability is
  defence in depth against the day someone forgets that, not a substitute for it.

## Consequences

**Easier:**

- **Debugging and support get faster in a way that compounds.** Every log line, ticket, and
  dashboard becomes self-describing; "what is this ID?" stops being a question anyone asks.
- **A whole class of confusion bugs becomes structurally impossible** — wrong-ID-in-wrong-field
  fails at compile time, parse time, or review time instead of production.
- **Sharding, merging, and multi-region stop being ID crises.** Random bodies never collide;
  nothing about the scheme assumes one database ([ZFN-15](/zfn/15-partition-customer-data-by-tenant/)).
- **Nothing leaks by default.** No volumes, no growth curves, no enumerable URL space.

**Harder:**

- **IDs get longer**, and someone will object on aesthetic or storage grounds. (Storage: the
  internal key can stay compact; the public ID is the one that pays for legibility.)
- **Fully random bodies cost you index locality** on huge write-heavy tables — that's what the
  time-ordered variant is for, paid for with the timestamp leak.
- **The prefix registry is a real, if small, governance artefact** — unowned, it drifts into
  collisions and inconsistency ([ZFN-47](/zfn/47-govern-the-contract-between-teams/) in
  miniature).
- **Migration from an existing scheme is genuinely painful** — old IDs live in customers'
  systems, so you'll honour both forms for years. Which is the argument for deciding this before
  the first entity ships, not after.

## References

- [ZFN-15](/zfn/15-partition-customer-data-by-tenant/) — partition-first data modelling; the ID
  scheme has to survive sharding from day one.
- [ZFN-14](/zfn/14-schema-first-apis-generate-clients/) — declare ID types in the schema so
  generated clients enforce them everywhere.
- [ZFN-49](/zfn/49-verify-by-computation-not-lookup/) — validate shape by computation at the
  edge; don't pay a lookup to discover garbage.
- [ZFN-10](/zfn/10-verify-resource-owner/) — why possession of an identifier must never be the
  authorisation.
- [Stripe object IDs](ref:stripeids), [RFC 9562](ref:uuidv7), and
  [the German tank problem](ref:tankproblem) — the prefix convention, the ordered-random
  spectrum, and the arithmetic of what sequential IDs give away.

## Changelog

- **2026-08-12**: First published as a Field Note.
