This is a story of a near-miss where I didn’t know what I was doing and accidentally took down a website. Luckily, it was the staging site, so at least I wasn’t playing with live ammo. Hopefully, you can learn from my mistake.
I was doing what seemed like a simple task in Postgres: creating a generated column. I wanted to concatenate a person’s first, middle, and last names into one string and normalize it by removing extra whitespace, stripping accents (so “José” becomes “Jose”), and making everything lowercase.
It seemed simple enough… until I ran the migration. For a table with half a million records, the website froze.
Here’s the Postgres function I wrote to do the normalizing:
CREATE OR REPLACE FUNCTION normalize(VARIADIC inputs text[]) RETURNS text AS $$
DECLARE normalized text;
BEGIN
normalized := array_to_string(inputs, ' ');
normalized := trim(normalized);
normalized := unaccent(normalized);
normalized := lower(normalized);
normalized := regexp_replace(normalized, '-', ' ', 'g');
normalized := regexp_replace(normalized, '\s+', ' ', 'g');
normalized := regexp_replace(normalized, '[^a-z0-9 ]+', '', 'g');
RETURN normalized;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
My first instinct was to use a generated column. In Rails, I wrote a migration like this:
class AddNormalizedFullNameGeneratedColumn < ActiveRecord::Migration[7.0]
def up
execute <<~SQL
ALTER TABLE people ADD COLUMN normalized_full_name text
GENERATED ALWAYS AS ( normalize(first_name, middle_name, last_name) )
STORED;
SQL
end
end
That seemed elegant… until I ran it.
So what happened?
Postgres stores each row as a “tuple”, an immutable array of values. When you add a generated column, Postgres has to rewrite the entire table. It reads every old row, computes the generated column value, and writes a brand-new row. For a table with half a million rows, that’s a huge operation.
This also means the table gets locked against writes during the operation. And since my people
table is used on nearly every page of the site, the staging website froze solid.
That was a lesson. In a zero-downtime environment, even 60 seconds of downtime is unacceptable.
Instead, I took a safer approach:
Adding a nullable column is fast because Postgres doesn’t physically change any existing rows immediately.
class AddNormalizedFullNameColumn < ActiveRecord::Migration[7.0]
def change
add_column :people, :normalized_full_name, :string
end
end
Next, I created a trigger to keep the column updated automatically:
class AddNormalizedFullNameTrigger < ActiveRecord::Migration[7.0]
def up
execute <<~SQL
CREATE OR REPLACE FUNCTION update_normalized_full_name() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'INSERT'
OR NEW.first_name IS DISTINCT FROM OLD.first_name
OR NEW.middle_name IS DISTINCT FROM OLD.middle_name
OR NEW.last_name IS DISTINCT FROM OLD.last_name
THEN
NEW.normalized_full_name := normalize(
NEW.first_name,
NEW.middle_name,
NEW.last_name
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
SQL
execute <<~SQL
CREATE TRIGGER trigger_update_normalized_full_name
BEFORE INSERT OR UPDATE ON people
FOR EACH ROW EXECUTE FUNCTION update_normalized_full_name();
SQL
end
def down
execute <<~SQL
DROP TRIGGER IF EXISTS
trigger_update_normalized_full_name
ON people
SQL
execute "DROP FUNCTION IF EXISTS update_normalized_full_name();"
end
end
Of course, all existing rows still had a null normalized_full_name
. I backfilled the data in small batches so as not to lock the table for too long:
Person
.where(normalized_full_name: nil)
.find_in_batches(batch_size: 1_000) do |batch|
ids = batch.map(&:id)
ActiveRecord::Base.connection.execute <<~SQL
UPDATE people
SET normalized_full_name = normalize(
first_name,
middle_name,
last_name
)
WHERE id IN (#{ids.join(', ')})
SQL
end
end
You might be wondering why adding a nullable column doesn’t also freeze the table. Here’s the magic:
NULL
.So Postgres is smart enough to leave old data untouched until you explicitly change it.
So now you know! Software sometimes feels like performing open-heart surgery on a patient who’s running a marathon. Remember: always test your migrations on staging first, understand how Postgres stores data under the hood, and be cautious of seemingly “simple” changes like generated columns.