Skip to content

BigInt vs UUIDv7 — ID Strategy for API Backends

The Core Question

When you have two ID types on the same row (internal BigInt + public UUIDv7), the question is:

Which one goes where?

One-line answer: BigInt stays inside the database. UUIDv7 is the only ID that ever crosses the API boundary.


Why You Need Both

Neither type alone is sufficient for a production backend.

Property BigInt UUIDv7
Storage 8 bytes 16 bytes
Index / join performance Excellent (sequential) Good (time-ordered, better than v4)
Safe to expose publicly No — sequential = enumerable Yes — opaque, reveals nothing
Globally unique No — conflicts across DBs Yes
Generated client-side / offline No Yes
Reveals row count / growth rate Yes No

BigInt is a DB performance tool — small, fast, perfect for joins and foreign keys.

UUIDv7 is an external identity — globally unique, time-ordered, safe to hand to any client or third-party system.


The One Rule

Does this ID ever leave the server?
        │
        ├── Yes → UUIDv7 (publicId, aliased as "id" in responses)
        │
        └── No  → BigInt (foreign keys, internal joins, logs)

The boundary is the API surface — not the type of caller. Whether the caller is a retail customer, an ERP portal user, or an admin dashboard, they all sit outside the server. They all get UUIDv7.


This Project's Context: Unified ERP + Retail Backend

This backend is both the ERP system and the retail e-commerce server. There is no separate external ERP to integrate with — it is the ERP.

                    ┌─────────────────────────────────┐
                    │         This Backend             │
                    │                                  │
Retail customers ───┤  API boundary                   │
                    │  UUIDv7 in, UUIDv7 out           │
Merchant ERP users ─┤  (same rule for all callers)    ├─── PostgreSQL
                    │                                  │    BigInt for all
Admin ERP users ────┤  Internal: BigInt for all       │    internal work
                    │  joins, queries, logs            │
                    │                                  │
Third-party ────────┤  Stripe, shipping, accounting   │
integrations        │  → UUIDv7 only                  │
                    └─────────────────────────────────┘

Since it's one unified system with one database, there is no cross-DB ID conflict problem. The BigInt vs UUIDv7 split is purely about the internal vs external boundary.


Entity Classification

Entity BigInt PK UUIDv7 publicId Reason
User ✓ ✓ External identity for all caller types
Product ✓ ✓ Referenced by frontend, ERP, payment gateways
Order ✓ ✓ Referenced by shipping, payment, ERP workflows
Address ✓ ✓ Referenced by orders across API calls
Category ✓ ✓ Synced via ERP/merchant portal
Review ✓ ✓ Externally visible, potentially referenced by CRM
OrderItem ✓ Not needed Always accessed through its parent Order
CartItem ✓ Not needed Ephemeral; scoped to a session; never cross-system
Inventory ✓ Not needed Accessed via Product; ERP references the product

The Request Flow

Every inbound request with a public ID goes through a one-step translation:

Client sends UUID
      │
      │  WHERE "publicId" = $uuid   ← one indexed lookup (publicId is @unique)
      ▼
Handler gets internal BigInt
      │
      │  All joins, subqueries, FK lookups use BigInt
      ▼
Response builder
      │
      │  SELECT publicId as id      ← BigInt never included in output
      ▼
Client receives UUID as "id"

The @unique constraint on every publicId column automatically creates a B-tree index, so the lookup is fast and does not require a sequential scan.


Response Key Naming

Never return a key named publicId in an API response. The word itself reveals that you have two ID systems — an internal one and a public one. Always alias it:

// Wrong — leaks internal architecture
.returning(['publicId', 'email', 'name'])
// → { publicId: "019612ab-...", email: "...", name: "..." }

// Correct — opaque to the caller
.returning(['publicId as id', 'email', 'name'])
// → { id: "019612ab-...", email: "...", name: "..." }

Similarly, never return the raw internal BigInt id column in a response for any entity that has a publicId.


Logging

Log destination Use
Internal DB / slow query logs BigInt — fast correlation back to the row
Application logs (request traces) Log both — BigInt for DB correlation, UUID for cross-request tracing
Logs shipped externally (SIEM, compliance, accounting) UUID only — external systems have no concept of your internal BigInt

Ideal application log line:

orderId=84729 orderPublicId=019612ab-... userId=312 action=cancel


Key Takeaways

Concept Rule
BigInt Internal only — joins, FKs, queries
UUIDv7 External only — API responses, webhooks, third-party integrations
API boundary The dividing line — applies equally to retail, ERP, and admin callers
Response key Always alias publicId as id — never expose the word publicId
Unified backend No cross-DB sync concern — one DB means BigInt conflicts are impossible
Ephemeral entities CartItem, OrderItem, Inventory — BigInt only, accessed via parent