How Supabase Handles Autoincrement Primary Keys

Most people coming from MySQL or PostgreSQL background hit a wall when they first set up a Supabase project and try to create tables with serial or auto-increment columns. It works, but not in the way you might expect. Supabase sits on top of PostgreSQL, so underneath it is standard Postgres behavior. The autoincrement types are SERIAL, BIGSERIAL, and the newer GENERATED ALWAYS AS IDENTITY. These three behave slightly differently, and if you pick the wrong one your migrations will break later. I learned this the hard way when I was building a multi-tenant SaaS dashboard. I had created over two dozen tables using SERIAL as primary keys. Everything worked fine locally. Then we hit production and tried to do a bulk data import from an old system. SERIAL columns don't respect COPY statement sequences properly unless you explicitly set them afterward. BIGSERIAL had the same issue. I ended up rewriting all those tables to use GENERATED ALWAYS AS IDENTITY with explicit sequence resets. Took me about four hours of downtime to fix.

Unlocking Efficient Data Management A Deep Dive Into Supabase Autoincrement Primary Keys

The core thing to understand is that Supabase does not invent its own primary key system. It uses PostgreSQL's built-in sequence mechanism. When you define a column as INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, Postgres creates an internal sequence object and assigns it to that column. Every INSERT pulls the next value from that sequence automatically. This matters more than most tutorials admit. If you ever need to know what the last inserted ID was for audit logging, reconciliation, or API responses, you have direct access to the sequence name. It follows the pattern tablename_columnname_seq. You can query it with SELECT last_value FROM tablename_columnname_seq. This is useful when you need to sync data between systems or verify that no gaps exist in your IDs after a bulk operation. There are three ways to define autoincrement columns in Supabase. The first is SERIAL. This is deprecated in modern PostgreSQL but still supported. It creates an implicit sequence behind the scenes. You cannot easily rename it or control its behavior. The second is BIGSERIAL. Same problem but the values go up to 8 bytes instead of 4. Good for large datasets. The third and recommended approach is GENERATED ALWAYS AS IDENTITY. This is the standard SQL approach and gives you full control over the sequence properties including cycle, start value, and increment.

Here is how you actually write it in practice: CREATE TABLE users (
  id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  email VARCHAR(255) NOT NULL UNIQUE,
  created_at TIMESTAMPTZ DEFAULT NOW()
 );
The generated identity clause is what separates the modern approach from the legacy SERIAL keyword. It forces Postgres to manage the sequence explicitly and gives you better error handling when someone tries to insert a conflicting value manually.

Get the Full Details

Supabase Deep Dive: Building a Robust Open Source Firebase Alternative ...
Supabase Deep Dive: Building a Robust Open Source Firebase Alternative ...

One thing most developers miss is how row-level security interacts with autoincrement columns. When you enable RLS on a table with a GENERATED IDENTITY primary key, the sequence still increments regardless of whether the insert succeeds or fails. This means you can get gaps in your IDs. It sounds like a bug but it is actually standard PostgreSQL behavior designed for performance. Sequences are not transactional. They give out values immediately even if the transaction rolls back. I ran into this when building a payment processing system. We had a gap of about 150 IDs in a month after testing failed transactions. Initially I thought something was wrong with the database. It turned out every failed insert still consumed a sequence value. The workaround was simple: switch to GENERATED ALWAYS AS IDENTITY with NO INCREMENT BY 1 and set the sequence to restart only after explicit manual intervention. That reduced the gaps significantly during testing phases. Another counter-intuitive thing is that you can safely insert custom values into an identity column if you know what you are doing. Use INSERT INTO tablename (id, other_columns) VALUES (999, 'data'). Postgres will reject it by default. But if you add FORCE NOTHING or handle it differently, you can insert. This is rarely needed but can be useful for data migration scenarios where you need to preserve original IDs from another system.

The real efficiency gain comes from understanding when NOT to use autoincrement keys. If you are building a distributed system with sharding or replication, sequential integers become a bottleneck. Each node needs to coordinate sequence generation or you get conflicts. In those cases UUIDs or composite keys make more sense. Supabase supports UUID natively with the uuid-ossp extension or the built-in gen_random_uuid() function in newer versions. For most applications though, autoincrement primary keys remain the right choice. They are fast, simple to debug, and easy to work with in ORMs. The performance difference between integer keys and UUIDs is measurable. Integer comparisons are faster, indexes are smaller, and joins require less memory. If your table has millions of rows, this adds up. When you use the Supabase dashboard to create tables visually, the UI defaults to SERIAL type. This is convenient but not optimal. I always switch to GENERATED ALWAYS AS IDENTITY manually. It takes ten extra seconds per table but saves you from headaches later when you need to modify the sequence or debug an ID conflict.

The migration path from SERIAL to IDENTITY is straightforward. You do not need to drop and recreate tables. Run an ALTER TABLE command to modify the column definition. The sequence gets replaced transparently. Old data stays intact. Here is the exact SQL: ALTER TABLE users ALTER COLUMN id DROP DEFAULT,
ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY;
This took about thirty seconds per table in my experience. For twenty tables it was roughly fifteen minutes of live work plus validation time.

Connecting and inserting data into a Supabase Table - YouTube
Connecting and inserting data into a Supabase Table - YouTube

One more practical detail: if you use Prisma or other ORMs with Supabase, they may generate SERIAL by default. Check your migration files before applying them. Prisma sometimes emits Serial instead of Int @default(autoincrement()). The output SQL differs slightly. I spent an afternoon debugging a permission issue that came down to the ORM generating the wrong column type. Overall, autoincrement primary keys in Supabase are reliable if you understand the underlying PostgreSQL behavior. Avoid SERIAL in new projects. Use GENERATED ALWAYS AS IDENTITY. Expect gaps in your sequences after failed inserts. And keep UUIDs in mind for distributed or sharded architectures where sequential integers will cause contention problems.