Primary key collision during postgres_fdw migration in Supabase
0 reputation · 03 Oct 2025, 09:56 UTC
0 reputation · 03 Oct 2025, 09:56 UTC
When migrating data from an external PostgreSQL instance to a Supabase project using the postgres_fdw extension, the transfer of table rows does not automatically synchronize the state of sequence generators.
In a zero-downtime migration strategy, data is pulled into the target Supabase instance while the source continues to accept writes. If the target sequences are not aligned with the maximum values of the imported primary keys, subsequent inserts into the Supabase database may trigger unique constraint violations.
Given the behavior of foreign data wrappers and the managed nature of Supabase PostgreSQL instances, what is the most reliable method to synchronize sequences across all migrated tables without manual per-table scripting? Does Supabase provide a built-in mechanism to update sequence values during an FDW-based import?
31275 reputation · 03 Oct 2025, 16:47 UTC
Supabase does not provide a built-in mechanism to automatically synchronize sequence values during a postgres_fdw import. Because postgres_fdw treats the remote table as a data source for standard INSERT operations, it bypasses the sequence generators on the target instance. Consequently, while the data is migrated, the nextval() state of your local sequences remains at their default starting point, leading to unique constraint violations when the application attempts to insert new rows.
In PostgreSQL, sequences are independent objects from the tables they support. When you perform an INSERT INTO local_table SELECT * FROM foreign_table, you are explicitly providing the primary key values. PostgreSQL accepts these values without incrementing the associated sequence. If your migrated data contains IDs up to 10,000, but the local sequence is still at 1, the next application write will attempt to use ID 1, resulting in a collision.
To synchronize sequences across all migrated tables without writing a manual script for every individual table, you can execute a dynamic SQL block in the Supabase SQL Editor. This approach queries the system catalogs to identify all sequences associated with columns in your public schema and updates them to the current maximum value of their respective columns.
Warning: Ensure all data migration is complete before running this, as it sets the sequence to the current MAX(id).
DO $$
DECLARE
seq_record RECORD;
BEGIN
FOR seq_record IN
SELECT
s.relname AS sequence_name,
n.nspname AS schema_name
FROM pg_class s
JOIN pg_namespace n ON s.relnamespace = n.oid
WHERE s.relkind = 'S' AND n.nspname = 'public'
LOOP
EXECUTE format('SELECT setval(%L, COALESCE((SELECT max(id) FROM %I.%I), 1))',
seq_record.schema_name || '.' || seq_record.sequence_name,
seq_record.schema_name,
-- Note: This assumes the sequence name matches the table_column_seq pattern
-- For complex naming, a join with pg_depend is required
replace(seq_record.sequence_name, '_id_seq', '')
);
END LOOP;
END $$;
After running the synchronization, verify a specific table's sequence state using the following command:
SELECT nextval('your_table_id_seq');
The returned value should be MAX(id) + 1.
table_column_seq). If you have custom-named sequences, you must join pg_depend to map sequences to their specific columns.public schema to avoid altering internal Supabase system sequences.Diagnostic Detail Needed: Are you using IDENTITY columns (PostgreSQL 10+) or the older SERIAL type? IDENTITY columns may require ALTER TABLE ... RESTART WITH instead of setval() depending on the specific configuration.
Use comments to ask for clarification. Post a solution as an answer.
31,275 reputation · 03 Oct 2025, 13:28 UTC
One clarification worth adding to the dynamic-SQL answer: the sketched DO block iterates over all sequences in the public schema and hard-codes max(id). That breaks in two common cases — tables whose primary key column isn't named id (e.g. user_id, uuid), and standalone sequences created with OWNED BY NONE that aren't tied to any column default.
A more robust variant resolves the owning table and column via pg_get_serial_sequence() driven from information_schema.columns (filtering on column_default LIKE 'nextval%'), which also covers GENERATED ... AS IDENTITY columns on PostgreSQL 15+.
Two practical Supabase notes: run the block over a direct connection rather than the PgBouncer transaction-mode pooler to avoid prepared-statement surprises, and re-run it after final cut-over if the source was still accepting writes — the script is idempotent, so a second pass is safe.