Multi-tenant SaaS applications face a structural challenge with integer primary keys: two tenants inserting records concurrently may generate the same integer ID in different tenant schemas. UUIDs eliminate this entirely — each tenant generates globally unique IDs without coordination. UUID v7 adds time-ordering on top, which matters for audit logs, event feeds, and any query sorted by creation time. This page covers the practical patterns for UUID v7 in multi-tenant architectures on PostgreSQL, including row-level security, sharding, and the specific cases where UUID v4 is still the right choice.
Tenant ID vs record ID
Multi-tenant apps have two distinct types of identifiers:
| Identifier | Appears in | Recommended type |
|---|---|---|
| Tenant ID | URLs, API keys, billing | UUID v4 (no timestamp leak) |
| Record ID (rows, events) | Internal DB, API responses | UUID v7 (time-ordered) |
| Audit log entry ID | Append-only log | UUID v7 (ordered by creation) |
| Session token | Cookies, Authorization header | Not a UUID — use 256-bit random |
The split is intentional: UUIDs in externally visible positions should not reveal when an account was created. UUID v4’s 122 bits of randomness provide no timing information. UUID v7’s 48-bit timestamp discloses creation time to the millisecond — avoid it for tenant IDs and user IDs that appear in URLs.
Row-level security with UUID tenant IDs
PostgreSQL RLS policies enforce tenant isolation at query time:
-- Enable RLS on the orders table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see rows for their tenant
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
Set the tenant context at connection time from your application:
SET app.current_tenant_id = '018f4b2a-7c3d-4000-8e5f-1a2b3c4d5e6f';
This works identically whether tenant_id is UUID v4 or UUID v7. The PostgreSQL row-level security documentation covers policy types, bypass roles, and performance implications.
UUID v7 for audit logs
Audit trails require strict ordering — you need to answer “what happened and in what sequence?” UUID v7 primary keys on the audit log table replace the need for a separate event_sequence column:
CREATE TABLE audit_log (
id uuid PRIMARY KEY DEFAULT uuidv7(),
tenant_id uuid NOT NULL REFERENCES tenants(id),
action text NOT NULL,
payload jsonb
);
-- Query: last 50 actions for a tenant, ordered by creation time
SELECT * FROM audit_log
WHERE tenant_id = $1
ORDER BY id DESC
LIMIT 50;
ORDER BY id works because UUID v7 lexicographic order equals chronological order. No created_at index needed. PostgreSQL 18’s uuidv7() function generates the default inline.
Shard routing with UUID v7 timestamp prefix
Extract the 48-bit timestamp for time-based shard routing:
import uuid6
def shard_for_record(record_id: str) -> int:
ts_ms = uuid6.UUID(record_id).time # Unix milliseconds
# Route records older than 90 days to archive shard
if ts_ms < (now_ms - 90 * 86400 * 1000):
return ARCHIVE_SHARD
return CURRENT_SHARD
The uuid6 package on PyPI exposes the .time property for UUID v7 values. PostgreSQL table partitioning by range can automate this at the database level.
Cross-tenant API responses
When a SaaS API returns records, UUIDs are opaque to the caller — they carry no sequence information that could reveal tenant activity levels. An integer id=12345 reveals approximately how many records exist; a UUID v7 reveals only a creation timestamp (if the caller knows to look). For public APIs, UUID v4 is preferable for resource IDs to avoid disclosing creation time via the key itself.
Framework integration
Prisma (Node.js):
model Order {
id String @id @default(dbgenerated("uuidv7()")) @db.Uuid
tenantId String @db.Uuid
}
SQLAlchemy (Python):
import uuid6
from sqlalchemy.orm import mapped_column, Mapped
from sqlalchemy import String
class Order(Base):
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid6.uuid7())
)
The Prisma client on npm and SQLAlchemy on PyPI both integrate cleanly with UUID v7 primary keys.
Further reading
- UUID Security — what UUID v7 discloses in public identifiers
- UUID in Databases — storage types and index performance
- UUID Generation Strategies — where to generate IDs in a SaaS stack
External references
- PostgreSQL row-level security
- PostgreSQL table partitioning
- uuid6 on PyPI
- RFC 9562 — UUID standard
- OWASP Top 10 — insecure direct object references
Frequently asked questions
Do UUID primary keys guarantee tenant isolation in a shared database?
UUIDs guarantee ID uniqueness across tenants — two tenants will never generate the same UUID. But uniqueness is not isolation. Isolation requires row-level security (RLS) policies or tenant-scoped queries. PostgreSQL row-level security enforces tenant boundaries at the database layer regardless of whether IDs are UUIDs or integers.
Should I use UUID v4 or UUID v7 for tenant IDs?
UUID v4 is the better choice for tenant IDs that appear in URLs or API responses. UUID v7 embeds a creation timestamp — exposing a tenant ID in a URL would reveal when the tenant account was created, which is a privacy concern. Use UUID v7 for internal records (rows, events, audit logs) where the timestamp is useful, and UUID v4 for externally visible identifiers. The RFC 9562 standard defines both versions.
How do UUIDs help with database sharding in SaaS?
Integer primary keys require a global sequence or shard-aware ID generator to avoid collisions across shards. UUID v7 keys are globally unique without coordination — any shard can generate IDs independently. The 48-bit timestamp prefix also enables time-based shard routing: new records route to the current shard, old records route to archive shards by timestamp range. The PostgreSQL table partitioning documentation covers range partitioning by UUID timestamp.