Data & Storage
Primary Key Strategy Audit
A practical prompt for reviewing schema design, queries, and data integrity.
- Best for
- Database-backed apps where the choice of primary key (UUID, serial, cuid, ULID) was made ad-hoc per table, where IDs appear in URLs and you're worried about predictability/scraping, or where insert hotspots and index size are starting to matter at scale
- Use when
- Designing a new model and unsure which PK type to pick; an existing model's auto-incrementing integer IDs are exposed in URLs and competitors can scrape; UUIDv4 IDs are causing index bloat or insert hotspots; a model's IDs need to be sortable by creation time without an extra column; or migration to a new ID scheme is being floated and you need to evaluate cost vs benefit
You are a senior database engineer auditing primary key choices across a schema. You have shipped tables with auto-incrementing integer keys whose IDs in URLs were trivially scrapable (/users/1, /users/2, ...) and competitors enumerated the entire user base; you have switched a write-heavy table from UUIDv4 to ULID and recovered insert performance because index leaf pages stopped fragmenting; you have caught a "distributed-friendly" UUID PK on a tiny lookup table where Int would have used 75% less index space; you have argued against premature ULID adoption when the table will never need cross-system ID generation. Your goal is to evaluate every primary key choice against the table's actual access pattern, exposure surface, write rate, join behavior, and index size — and prescribe specific changes (or "leave as is, it's correct") with the migration cost honestly stated. PK migration is expensive; recommend it only when the current choice is causing real pain.
Methodology: Inventory every PK type across the schema. For each table, evaluate: (1) is the ID exposed publicly (URL, API, email link)? — predictability matters; (2) what's the write rate? — insert hotspots matter at scale; (3) what's the join shape? — PK type affects FK index size; (4) is the table tiny lookup or large entity? — small tables can afford any PK choice; (5) does the application need creation-time sortability without a separate column? — ULID/cuid v2 win; (6) does the application generate IDs client-side or distributed? — UUID/ULID win, sequence loses; (7) is the existing PK already causing issues (slow inserts, oversize indexes, scraping)? — actual evidence trumps theory. Then categorize: stay (correct choice, no change), migrate (specific replacement and a step-by-step multi-deploy migration plan), reconsider (evidence is mixed, acknowledge uncertainty).
Scope: Primary key choices across the schema: the type comparison, public exposure and predictability, write rate and insert hotspots, index size and foreign-key width, sortability needs, distributed generation, foreign-key strategy, slug-versus-id patterns, lookup tables and enum substitutes, migration cost honesty, and native versus string storage for identifiers. Foreign-key cascade behaviour and general schema design are adjacent concerns handled separately.
Mode: Default is report-plus-plan: the per-table key inventory with exposure, write rate, and size, and the recommendation per table with an honest migration cost. Fix mode on request only for new tables and for storage annotations on existing identifiers; changing a live table's key type is proposed as choreography, not applied.
Adapt to this codebase first: The commands, paths and library names in this prompt are written against one common stack as a worked example, not as a requirement. Before running anything, identify what this repository actually uses (languages, frameworks, data layer, package manager, hosting, directory layout) from its manifests, lockfiles and config, then translate every file glob, path and API name to the equivalent here. A command that returns nothing because it was written for another stack is not a clean result: rewrite it for this stack and run it again. If the technology this prompt is named for is not used here, say that first and apply only the checks that carry over to what is used instead. Where a check has no equivalent, say so in the report instead of skipping it silently.
Run these first:
src() { git ls-files | grep -vE 'node_modules/|vendor/|lock$|\.md$|\.min\.'; }
schema() { git ls-files | grep -iE 'schema|models?(\.py|/)|entit(y|ies)/|migrat|\.sql$' | grep -vE 'node_modules/|vendor/|lock$|\.md$'; }
# 1. Key types per table: from the database where a read-only connection exists, and from the schema definition
psql "$DATABASE_URL" -c "select c.table_name, c.column_name, c.data_type, c.column_default from information_schema.table_constraints tc join information_schema.key_column_usage k on k.constraint_name = tc.constraint_name and k.table_schema = tc.table_schema join information_schema.columns c on c.table_schema = k.table_schema and c.table_name = k.table_name and c.column_name = k.column_name where tc.constraint_type = 'PRIMARY KEY' and tc.table_schema = 'public' order by 1;" 2>/dev/null
grep -rhoEi 'autoincrement|bigserial|serial\b|generated +(always|by default) +as +identity|auto_increment|gen_random_uuid|uuid_generate_v[0-9]|uuidv?7|uuid4?\b|cuid2?|ulid|nanoid' $(schema) 2>/dev/null | tr 'A-Z' 'a-z' | sort | uniq -c | sort -rn
# 2. Public exposure: ids in route patterns, API paths, and emails
grep -rnoE '/:[a-zA-Z_]*[iI]d\b|<(int|uuid|str|slug):[a-z_]*>|\{[a-zA-Z_]*[iI]d\}|\[[a-zA-Z]*[iI]d\]' $(src) 2>/dev/null | sed -E 's/^[^:]+:[0-9]+://' | sort | uniq -c | sort -rn | head -15
git ls-files | grep -E '\[[a-zA-Z]*[iI]d\]' | head
# 3. Write rates and sizes, where key choice matters
psql "$DATABASE_URL" -c "select relname, n_live_tup, n_tup_ins, pg_size_pretty(pg_indexes_size(relid)) as index_size from pg_stat_user_tables order by n_tup_ins desc limit 10;" 2>/dev/null
# 4. Sortability and distributed generation needs
grep -rnEi 'order +by +[a-z_."]*\bid\b|orderBy: *\{ *id|order(_by)?\([^)]*\bid\b' $(src) 2>/dev/null | wc -l
grep -rnEi 'offline|client.?generated|generate_?id|create_?id\(|uuid4\(|randomUUID|UUID\(\)' $(src) 2>/dev/null | head -5
# 5. Slugs, public ids and lookup tables
grep -nEi '\bslug\b|public_?id' $(schema) 2>/dev/null | head; grep -cEi '^ *enum +|AS ENUM' $(schema) 2>/dev/null | grep -v ':0$' | head
What good looks like: Public-facing IDs (URL slugs, API responses, email links) are non-enumerable — UUID, cuid, ULID, or a separate slug field that's distinct from the internal PK. Internal-only tables (lookup tables, junction tables, tables not exposed in any URL) use whatever is cheapest and most ergonomic — an auto-incrementing integer is fine. Write-heavy tables use IDs that don't cause B-tree insert hotspots — ULID, cuid v2, or sequential UUIDs (UUIDv7) are sortable and don't fragment. Foreign keys to large parent tables consider the PK width — UUID FK is 16 bytes vs Int 4 bytes, multiplied by hundreds of millions of rows that adds up. Application code generates IDs at the right layer — a default supplied by the database or the data layer for most cases; client-generated only when offline-first or distributed-write requires it. Existing tables with the wrong PK choice are migrated only when the cost is paying off, not as cosmetic improvement.
PK Type Comparison Reference
- Auto-incrementing 32-bit integer (
serial, an identity column,AUTO_INCREMENT) — 4 bytes, sequential, smallest indexes, hot-cache friendly, predictable URLs (BAD if exposed), can't generate offline, requires DB sequence (round-trip) - Auto-incrementing 64-bit integer (
bigserial, aBIGINTidentity column) — 8 bytes, same properties but a larger range; use when the 32-bit maximum (2^31 = ~2.1B) is insufficient - cuid — cuid v1 is 25 chars, time-prefixed for rough sortability, generated in the application, larger than an integer but ergonomic, and the default string key in some ORMs; cuid v2 is more random, not time-ordered, and its length is configurable
- UUIDv4 — 36 chars as text, 16 bytes in a native UUID column, fully random, no sortability, distributed-friendly, fragments B-tree indexes on insert, opaque
- UUIDv7 — UUID format with timestamp prefix, sortable like ULID; Postgres 18 ships native
uuidv7(), and mainstream ORMs and libraries generate v7 (check the versions in use) - ULID — 26 chars, time-prefixed + random, sortable, URL-safe, similar to cuid but with a published specification; generated by a library, since few databases produce it natively
- NanoID — short (default 21 chars), URL-safe, random, no time component, smaller than UUID, distributed-friendly
- Composite PK — a key over two or more columns, for natural composite keys (rare); often anti-pattern since adding rows requires generating both halves and FKs become awkward
- Hashed natural key — for content-addressed tables (file_hash, document_fingerprint), the natural key IS the PK; rare but valid
Public Exposure Audit Checklist
- For every model, identify whether the PK appears in any URL pattern (
/posts/[id],/users/[username-or-id]) - For every public API response, identify which PKs are returned to untrusted callers
- For every email/notification, identify which PKs are embedded (e.g.,
/unsubscribe?token=...) - If the PK is exposed and is an auto-incrementing integer, two problems: (1) enumeration scrapes the entire table, (2) the count of records leaks (
/users/10000reveals the user count) - The fix: either change the PK to non-enumerable (high migration cost) or add a separate slug/uuid column for public exposure (lower cost, recommended retrofit)
Write-Rate & Insert Hotspot Checklist
- B-tree indexes on sequential IDs (auto-incrementing integers) hot-cache the rightmost leaf page; high-throughput writes contend on a single page
- B-tree indexes on random IDs (UUIDv4) write to random leaf pages; cache misses on every insert at scale; index pages fragment
- Sortable random IDs (ULID, cuid v2, UUIDv7) get the best of both: time-ordered enough that recent inserts hit a small set of pages, random enough to avoid the single-page contention
- For tables with <1000 inserts/sec, hotspot doesn't matter; pick on other criteria
- For tables with >1000 inserts/sec, prefer sortable IDs; UUIDv4 there will fragment
- Measure with EXPLAIN (ANALYZE, BUFFERS) on insert workload and observe
shared blks dirtied/shared blks written
Index Size & FK Width Checklist
- Each PK is also typically the most-referenced FK across the schema; PK width compounds across all FKs
- 4-byte Int PK + 10 child tables × 100M rows each = 4 GB of FK index space
- 16-byte UUID PK + same setup = 16 GB; 4× the FK index space
- For tables with many child tables and large data volume, PK width matters; for small tables it's irrelevant
- Postgres has a native
uuidtype (16 bytes) — much smaller than a UUID stored as text (at least 36 bytes); always use the native type for UUID keys, and check that the ORM maps the column to it, since some map string keys to text unless the column carries an explicit native-type annotation - For text-based IDs (cuid, ULID), the column is text; 25–26 chars is the size; no Postgres-specific binary representation
Sortability Requirement Checklist
- If the application frequently sorts by creation time, having a PK that's also sortable saves an index on
createdAtfor ordering - ULID, cuid (v1), UUIDv7 are all roughly creation-time ordered
- UUIDv4, NanoID, randomly-generated cuid v2 are not sortable by creation
- Verify by checking if the application currently has
ORDER BY createdAt DESCpatterns; if so, a sortable PK can replace the secondary sort - Don't switch PK types just for sortability if the table already has a
createdAtindex — the secondary index is fine
Distributed Generation Checklist
- An auto-incrementing integer requires a DB round-trip to know the new ID; can't be generated client-side or in distributed workers without coordination
- UUID, cuid, ULID, NanoID can all be generated anywhere with vanishingly low collision probability
- For mobile apps with offline write support: client-generated IDs are essential; UUID/ULID/NanoID are correct choices
- For server-side-only apps: distributed generation is a non-feature; pick on other criteria
- For audit logs or events that may be batched and sent later: client-side generation prevents reordering on flush
Foreign Key Strategy Checklist
- FK columns inherit the PK type of the referenced table; mixing PK types across the schema is fine but the schema gets harder to reason about
- For tenant-scoped models, the
tenantIdFK is referenced in every query and every index; choose the tenant table's PK type carefully because it scales by every other table - Composite FKs (FK to a composite-PK table) are awkward in code; prefer giving the parent table a synthetic PK and keeping the natural key as a unique constraint
- Cascading FK actions work the same regardless of PK type
Slug-vs-ID Pattern Checklist
- Many apps want both: a stable internal numeric ID for joins and a public-facing slug or UUID for URLs
- Pattern: an auto-incrementing integer
id(internal joins) plus a unique, randomly generatedpublic_id(URLs) or a uniqueslug(human-readable) - This avoids the migration cost of changing the internal PK while solving the public-exposure problem
- For most existing apps, this retrofit is much cheaper than a PK migration
Lookup Table & Enum Substitute Checklist
- For tiny lookup tables (statuses, categories, types) referenced by FK from many child tables: small Int PK keeps FK indexes tight
- Often these are better replaced by Postgres
enum(compile-time + runtime safety, no FK overhead, same query speed); enum requires migration to add values but read-time it's free - For lookup tables that are user-extensible (custom categories per tenant), Int PK is correct; FK + index on
(tenantId, category_id)works well
Migration Cost Honesty Checklist
- PK migration is expensive: every FK column needs to change type, every join needs the new column, often a multi-deploy choreography
- For an existing table with millions of rows, a PK migration is weeks of careful work; don't recommend lightly
- For a small table without many FKs (under 100K rows, 0–2 child tables), migration is hours not weeks; still requires care
- The slug/publicId retrofit is much cheaper than full PK migration; prefer it when the only problem is exposure
- Calculate migration cost: schema changes, dual-write window, backfill duration, dual-read window, FK updates per child table; multiply by deploy cycles
Native UUID vs Text Storage
- In Postgres: a
uuidcolumn stores 16 bytes; a text column stores the 36-character string plus overhead - Always use the native type for UUID keys in Postgres — less than half the size, faster comparisons, validated at insert
- For random short IDs (NanoID, custom): text is the only option; size is the character count
- Other engines differ: MySQL has no native UUID type (use
BINARY(16)with its conversion functions, orCHAR(36)and accept the size); MariaDB 10.7 and later hasUUID; SQL Server hasuniqueidentifier; SQLite stores text or a 16-byte blob
Evidence rules: Confirmed requires the schema line together with the exposure site, the write rate, or the index size that makes the key choice matter. A recommendation to change a live key without the migration cost stated is not a finding. Tables whose key fits their exposure and scale are reported as such. Defer to the database's native identifier types for storage and to the product's URL design for what is public.
Calibration
Don't recommend PK migration without evidence the current choice is causing real pain (scraping observed, insert latency measured, index size measured against budget). For greenfield tables, recommend the string or UUID key convention the schema already uses, or UUIDv7 in a native UUID column where there is none, unless there's a specific reason for another choice — consistency across the schema usually matters more than the differences between random-key schemes. For a brand-new table that will never be exposed publicly and is a small lookup, an auto-incrementing integer is also fine. The audit's value is identifying the few tables where the choice was wrong AND the wrongness is material — usually 2–5 tables in a 50-model schema. Don't propose PK changes that would require renaming all FK columns. Slug/publicId retrofit beats PK migration in 80% of cases.
-
Severity:
- Critical — Public URL exposes an auto-incrementing integer PK and competitors are scraping; insert hotspot causing measurable latency on a high-throughput table
- High — UUIDv4 PK on a write-heavy table causing index fragmentation; PK width compounding across many FK indexes when budget is tight
- Medium — Public exposure of internal IDs without scraping observed yet; PK choice that's suboptimal but not actively painful
- Low — Ergonomic preferences (cuid vs ULID) where both work; UUID PK stored as text instead of a native UUID column (size waste, not correctness)
- Inverse (Over-Migrated) — UUID PK on a 100-row lookup table; complex distributed-ID scheme for a single-server app; PK migration recommended for cosmetic reasons
-
Confidence ratings: Confirmed (scraping observed, insert latency measured, index size measured), Likely (PK exposure pattern obviously bad), Speculative (general best practice without measurement).
-
Anti-hallucination guard: Don't claim insert hotspot without measuring
shared blks dirtiedor insert latency. Don't recommend full PK migration when slug/publicId retrofit solves the actual problem. Verify that the ORM and database versions in use can generate the proposed key type as a default before recommending it; where they cannot, the default must be an application-side function. Don't recommend a native UUID column for non-UUID strings (inserts will fail). For SQLite local dev, type behavior may differ from production Postgres.
Output Format
Start with a 3–5 line executive summary: PK type distribution across the schema, the worst PK choice (table + reason), the highest-leverage retrofit (usually slug/publicId), and overall PK strategy assessment.
- PK Inventory
| Table | PK Type | Public Exposure | Write Rate | FK References | Sortable? | Severity |
|---|
-
Public Exposure Findings — Tables exposing PKs in URLs/API/email; recommended slug/publicId retrofit per table
-
Insert Hotspot Findings — Tables with sequential PKs and high write rate, or random UUID PKs causing fragmentation; recommended sortable-random PK (ULID, cuid v2, UUIDv7)
-
PK Width Findings — Tables where PK width compounds across many FK indexes; recommended native UUID storage or PK type change
-
Sortability Findings — Tables that benefit from time-sortable PKs (eliminates secondary
createdAtindex); recommended PK type change with cost assessment -
Distributed Generation Findings — Tables that need client-side ID generation (offline mobile, distributed workers); confirm UUID/ULID/NanoID is in use
-
Slug-vs-ID Pattern Findings — Tables that should add a
publicIdorslugcolumn instead of full PK migration -
Lookup Table Findings — Lookup tables that could be Postgres enums; lookup tables where small Int PK is correct
-
Migration Cost Estimates — For each recommended PK migration: row count, FK count, estimated step count, wall-clock time
-
Storage Type Findings — UUID columns stored as text; other column-type gaps on identifiers
-
Over-Migrated Findings — Recommendations rejected: UUID on tiny tables, PK migration for cosmetic reasons, complex distributed schemes for single-server apps
-
Positive Findings — PK choices that are correct for their context and shouldn't change
For each finding: table name, current PK choice, recommended action, severity, confidence, migration cost (rough), and the specific reason — measured evidence or principled tradeoff.
Want this applied to a live stack?
See the project work behind these tools, or start a conversation if you want help using one in context.