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