Field Note 56current
IDs are an interface: prefix the type, randomise the body
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.
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 conventionDesigning APIs for humans: Object IDs (Stripe, 2022)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.dev.to ↗. 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 problemThe German tank problemThe 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.en.wikipedia.org ↗) and hand enumeration attacks a road map.
- Order by time only when the storage needs it (UUIDv7RFC 9562 — Universally Unique IDentifiers (UUIDs), including UUIDv7The 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.rfc-editor.org ↗-style), and accept that you’re publishing creation time when you do.
- Type them in code and schema, not just in the string: an
InvoiceIdthat can’t be passed where aCustomerIdbelongs turns a whole bug class into compile errors (ZFN-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: 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.Open ZFN-14 →). - 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 problemThe German tank problemThe 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.en.wikipedia.org ↗), and they collide the moment you shard or merge datasets (ZFN-15Field Note · currentZFN-15 — Partition customer data by tenant from day oneMake customer data tenant-partitioned from day one: tenant-scope every query, never join across tenants, route through a tenant→location directory. Run one physical database at first — but keep the model shardable. Retrofitting isolation onto a shared DB is brutal.Why it's cited here: 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.Open ZFN-15 →). - 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.
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-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: 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.Open ZFN-14 →). Registration matters: prefixes are forever, and two teams independently mintingpr_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-19Field Note · currentZFN-19 — Annotate read-only and idempotent endpoints; make every mutation idempotentAnnotate every endpoint as read-only (safe) or idempotent, in the schema, so infrastructure can retry, route to replicas, and cache safely. Make every state-changing endpoint idempotent (idempotency keys for create/charge/send); a non-idempotent retry double-applies.Open ZFN-19 →) — with different rules).
- Time-ordered variants where write locality matters: a UUIDv7RFC 9562 — Universally Unique IDentifiers (UUIDs), including UUIDv7The 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.rfc-editor.org ↗-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-49Field Note · currentZFN-49 — Verify by computation, not lookup; store revocations, not issuancesVerification should be a computation, not a query: HMACs, signatures, hashes, and pass-by-value claims let any node verify locally. When revocation is rarer than issuance, invert the state — keep the few revocations for the lifetime of what they revoke, not a row per grant.Why it's cited here: 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.Open ZFN-49 → — 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-10Field Note · currentZFN-10 — Pin the expected owner on cross-account resource calls (confused-deputy defense)Authority to call a resource isn't proof it's the one you meant. Any call crossing an account boundary must assert the expected owner: ExpectedBucketOwner on S3, aws:ResourceAccount conditions, validation of untrusted ARNs, plus inbound trust pinned with SourceArn/ExternalId.Open ZFN-10 → 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-15Field Note · currentZFN-15 — Partition customer data by tenant from day oneMake customer data tenant-partitioned from day one: tenant-scope every query, never join across tenants, route through a tenant→location directory. Run one physical database at first — but keep the model shardable. Retrofitting isolation onto a shared DB is brutal.Why it's cited here: 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.Open ZFN-15 →).
- 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-47Field Note · currentZFN-47 — Govern the contract between teams, not the code inside themTeams own services end to end; one team owns the gateway that dispatches to them. Govern exactly one thing centrally — the contract at the boundary (schema, identity, errors, idempotency) — and enforce it at runtime. Don't mandate libraries; ship them as an opt-in blueprint.Open ZFN-47 → 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-15Field Note · currentZFN-15 — Partition customer data by tenant from day oneMake customer data tenant-partitioned from day one: tenant-scope every query, never join across tenants, route through a tenant→location directory. Run one physical database at first — but keep the model shardable. Retrofitting isolation onto a shared DB is brutal.Why it's cited here: 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.Open ZFN-15 → — partition-first data modelling; the ID scheme has to survive sharding from day one.
- ZFN-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: 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.Open ZFN-14 → — declare ID types in the schema so generated clients enforce them everywhere.
- ZFN-49Field Note · currentZFN-49 — Verify by computation, not lookup; store revocations, not issuancesVerification should be a computation, not a query: HMACs, signatures, hashes, and pass-by-value claims let any node verify locally. When revocation is rarer than issuance, invert the state — keep the few revocations for the lifetime of what they revoke, not a row per grant.Why it's cited here: 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.Open ZFN-49 → — validate shape by computation at the edge; don’t pay a lookup to discover garbage.
- ZFN-10Field Note · currentZFN-10 — Pin the expected owner on cross-account resource calls (confused-deputy defense)Authority to call a resource isn't proof it's the one you meant. Any call crossing an account boundary must assert the expected owner: ExpectedBucketOwner on S3, aws:ResourceAccount conditions, validation of untrusted ARNs, plus inbound trust pinned with SourceArn/ExternalId.Open ZFN-10 → — why possession of an identifier must never be the authorisation.
- Stripe object IDsDesigning APIs for humans: Object IDs (Stripe, 2022)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.dev.to ↗, RFC 9562RFC 9562 — Universally Unique IDentifiers (UUIDs), including UUIDv7The 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.rfc-editor.org ↗, and the German tank problemThe German tank problemThe 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.en.wikipedia.org ↗ — 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.