Originally from: tweet, LinkedIn post.
I post a new PostgreSQL "howto" article every day. Join me in this journey – subscribe, provide feedback, share!
Adding a foreign key (FK) is straightforward:
alter table messages
add constraint fk_messages_users
foreign key (user_id)
references users(id);
However, this operation requires locks on both tables involved:
-
ShareRowExclusiveLock
,RowShareLock
, andAccessShareLock
on the referenced table, in this example it'susers
(plusAccessShareLock
on its primary key, PK). This blocks any data modifications tousers
(UPDATE
,DELETE
,INSERT
), as well as DDL. -
ShareRowExclusiveLock
andAccessShareLock
to the referencing table, in this examplemessages
(plus,AccessShareLock
to its PK). Again, this blocks writes to this table, and DDL.
And to ensure that the existing data doesn't violate the constraint, full table scans are needed – so the more data the tables have, the longer this implicit scan is going to take. During which, the locks are going to block all writes and DDL to the table.
To avoid downtime, we need to create the FK in three steps:
- Quickly define a constraint with the flag
NOT VALID
. - For the existing data, if needed, fix rows that would break the FK.
- In a separate transaction,
validate
the constraint for existing rows.
Example:
alter table messages
add constraint fk_messages_users
foreign key (user_id)
references users(id)
not valid;
This requires a very brief ShareRowExclusiveLock
and AccessShareLock
on both tables, so on loaded systems, it is
still recommended to execute this with low lock_timeout
and retries (read:
Zero-downtime database schema migrations),
to avoid lock queue blocking writes to the tables.
🖋️ Important: once the constraint with NOT VALID
is in place, new writes are checked (while old rows have not
been yet verified and some of them might violate the constraint):
nik=# \d messages
Table "public.messages"
Column | Type | Collation | Nullable | Default
---------+--------+-----------+----------+---------
id | bigint | | not null |
user_id | bigint | | |
Indexes:
"messages_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
"fk_messages_users" FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID
nik=# insert into messages(id, user_id) select 1, -1;
ERROR: insert or update on table `messages` violates foreign key constraint "fk_messages_users"
DETAIL: Key (user_id)=(-1) is not present in table `users`.
Now, with the FK created with NOT VALID
, we know that Postgres already checks all the new data against the new
constraint, but for the old data, some rows might still be violating it. Before the next step, it makes sense to ensure
there are no old rows violating our new FK. It can be done using this query:
select id
from messages
where
user_id not in (
select id from users
);
This query scans the whole messages
table, so it will take significant time. It is worth ensuring that users
is
accessed via PK here (depends on the data volumes and planner settings).
The rows identified by this query will block the next step, so they need to either be deleted or changed to avoid the FK violation.
To complete the process, we need to validate
the old rows in a separate transaction:
alter table messages
validate constraint fk_messages_users;
If the tables are large, this ALTER
is going to take significant time. However, it only acquires
ShareUpdateExclusiveLock
and AccessShareLock
on the referencing table (messages
in this example).
Therefore, it doesn't block UPDATE
/ DELETE
/ INSERT
, but it conflicts with DDL and VACUUM
runs. On the
referenced table (users
here), AccessShareLock
and RowShareLock
are acquired.
As usual, if autovacuum
processes this table in the transaction ID wraparound prevention mode, it won't yield – so
before running this, make sure there is no autovacuum
running in this mode or DDL in progress.