Skip to content

Rethinking Database Keys: Auto-Incrementing BigInt and Public IDs Approach

Published:  at 06:16 PM
Updated:    at 03:20 PM
Table of Contents

TLDR;

Choosing the right database key strategy is crucial for both performance and security. After years of using auto-incrementing integers and UUIDs, I’ve found a better approach inspired by PlanetScale’s production practices.

Auto-Incrementing BigInt and Public IDs Approach:

  1. Use BigInt as your primary key for internal performance and joins.
  2. Add a public_id column with NanoIDs for anything client-facing.
  3. Be consistent: don’t expose primary keys externally, always expose public_id.
  4. Store NanoIDs as fixed-length CHAR for speed.

Traditional Options

Auto-Increment IDs

The issues with integers become obvious once you think about them:

UUIDs: Better, But Still Clunky

Naturally, many of us moved on to UUIDs. They fix some problems: they’re globally unique and don’t require the database to hand out numbers in sequence.

But UUIDs come with tradeoffs:

They solve predictability, but they’re still awkward to work with.

PlanetScale Approach: BigInt + Public IDs

One best practice I really liked from PlanetScale is how they balance internal performance with external safety.

Here’s how they do it:

Why this matters:

It’s the best of both worlds: internal efficiency and external security.

Why NanoIDs Are a Great Fit for Public IDs

Here’s why NanoID is particularly well-suited for public-facing identifiers:

Sample Implementation in Drizzle

Note: I originally wrote this example using Prisma, but have since migrated to Drizzle ORM for its better TypeScript support and performance. The pattern remains the same regardless of your ORM choice.

If you’re using Drizzle, this pattern is straightforward to set up. Here’s what it looks like in your schema:

import {
  bigserial,
  char,
  timestamp,
  varchar,
  pgTable,
} from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  // Internal primary key
  id: bigserial("id", { mode: "number" }).primaryKey(),

  // Public-facing ID
  publicId: char("public_id", { length: 21 }).notNull().unique(),

  email: varchar("email", { length: 255 }).notNull().unique(),
  name: varchar("name", { length: 255 }),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

Notes:

import { nanoid } from "nanoid";
import { db } from "@/server/db";
import { users } from "@/server/db/schema";

// Creating a new user
await db.insert(users).values({
  publicId: nanoid(),
  email: "example@test.com",
  name: "Alice",
});

// Querying by public ID (what you expose to clients)
const user = await db
  .select()
  .from(users)
  .where(eq(users.publicId, "user_abc123xyz"))
  .limit(1);

// Internal joins still use the efficient BigInt primary key
const userWithPosts = await db
  .select()
  .from(users)
  .leftJoin(posts, eq(posts.userId, users.id)) // Fast integer join
  .where(eq(users.publicId, "user_abc123xyz"));

Important: In this example, we’re generating the NanoID at the application level for clarity. In production, you might want to:

This gives you the full PlanetScale pattern: BigInt primary key + public NanoID.

Wrapping Up

Database key design is one of those foundational decisions that’s easy to overlook early on, but can have lasting impact on your application’s security, performance, and scalability.

The hybrid approach of using auto-incrementing BigInt for primary keys and NanoIDs for public-facing identifiers gives you the best of both worlds:

After reading about PlanetScale’s production model, this pattern really clicked for me. It’s a small design choice, but one that could save a lot of pain down the road.

Resources