🫡 Retired 2025-04-01. Moved to Notion. We're leaving the repo around for now, but note that it might not reflect current practice.
We deploy without downtime when we can to avoid user frustration. It's pretty rare that we can't.
There's a great talk/slideshow on this, but no convenient reference that we're aware of – hence this document.
We call a revision/migration "safe" if it can be deployed without downtime.
The application before a deploy is the "old app". The application after a deploy is the "new app".
NOTE: IMPORTANT!
The instructions below apply to Heroku-style deploys where the app code is deployed first, and migrations run after. We used to do it the other way around – see Git history for those instructions.
But as of 2021-03 (well, long before then) we typically run migrations first and restart the app code later. So TODO, update the instructions below!
END OF NOTE
Basically, you always need to make sure any code you deploy works both before and after its migrations run.
If you have code that only works after a migration has run, you need to deploy the migration first, and then deploy the new code.
A migration is not safe if it locks the DB for long. The below is for PostgreSQL, which we use. Much of it is true of any DB engine, but locking in particular will vary between engines.
However, all DDL statements that take an exclusive lock come with a risk even if the operation under the lock is very fast, since:
- the exclusive lock must wait for all ongoing work to finish before it can be acquired
- once someone is attempting to acquire an exclusive lock, all new incoming work must wait for it to be released
Therefore, under heavy load even a normally fast operation may cascade into a traffic jam around the affected table, if the initial wait to begin the DDL takes longer than expected. For extra safety, you can consider setting the timeout to a low value; it's better to abort a migration than to grind the database to a halt: SET LOCAL lock_timeout = '1s';
.
In certain circumstances adding a column can cause a physical rewrite of the entire table and its indexes. An exclusive lock is taken on the table for this operation. Consider the following to determine whether your change is safe:
-
NULL
, no default- Safe.
-
Small table
- Safe if the rewrite finishes quickly enough. You can use
SELECT pg_size_pretty(pg_total_relation_size('tablename'))
to make your decision.
- Safe if the rewrite finishes quickly enough. You can use
-
Non-volatile default
- Safe since Postgres 11. The default value is stored in the table metadata instead of physically updating the rows.
CURRENT_TIMESTAMP
is a stable function, so it is also safe.- See Function Volatility Categories for more (it's not hugely helpful in this context though).
- Safe since Postgres 11. The default value is stored in the table metadata instead of physically updating the rows.
-
❗
NULL
, volatile default- Safe if the default is set separately, i.e. first
ALTER TABLE ADD COLUMN
, thenALTER TABLE ALTER COLUMN SET DEFAULT
.- Setting the default in the same statement as adding a column will trigger a table rewrite; yes, even on adding a
NULL
column. - Setting the default separately will not touch existing rows, leaving them as
NULL
.
- Setting the default in the same statement as adding a column will trigger a table rewrite; yes, even on adding a
- Safe if the default is set separately, i.e. first
-
❗ Anything else
- Unsafe. Try to find a way to do a staged change, for example:
- Create the column as
NULL
without a default. - Fill in the data in batches to avoid updating the entire table in one transaction. Remember to disable transactions (
disable_ddl_transaction!
) if this is done in a migration, or do it outside of a migration. - Set the desired nullability and default on the column.
- Create the column as
Below, example of a safe migration for one of our largest tables,
images
with around 6M rows.== 20210527200847 AddOwnerIdAndOwnerTypeToImages: migrating =================== -- add_column(:images, :owner_type, :string) -> 0.0128s -- add_column(:images, :owner_id, :integer) -> 0.0440s == 20210527200847 AddOwnerIdAndOwnerTypeToImages: migrated (0.0571s) ==========
- Unsafe. Try to find a way to do a staged change, for example:
-
Removing columns is not safe for tables that are in use, unless the old (currently running) app ignores the column.
The old app will attempt to use the cached column name and will break things.
(If the table is not in use, with no queries running against it, you can remove the column in a single step.)
Deploy removals in multiple steps:
-
Make sure the column is not actually in use by any code.
-
(Optional) Deploy 1: Make the column nullable if it isn't already (being aware that it's dangerous on big tables:
class MyMigration < ActiveRecord::Migration def change change_column_null :items, :description, true end end
This is so you can safely ignore the column without breaking the creation of new records.
-
Deploy 2: Make the app ignore the column.
-
Ignore it like this:
class Item < ActiveRecord::base self.ignored_columns = [ :description ] # … the rest of the class end
Put that method call at the very top of the class or you risk errors like "undefined method `type' for nil:NilClass".
If the model uses STI, make sure to do this in the base class, not one of the subclasses.
-
-
Deploy 3: Remove it from DB.
-
Include a migration to remove the column.
In a big table, you should drop each index touching the column with
DROP INDEX CONCURRENTLY
(Example:remove_index :companies, :bookie_api_key, name: "index_companies_on_bookie_api_key", unique: true, algorithm: :concurrently
) before dropping the column itself, to avoid locking issues. This requires the migration class to run the class methoddisable_ddl_transaction!
first.
-
-
Deploy 4: Remove the code that ignored the column.
-
-
Renaming columns is not safe for tables that are in use, unless the old (currently running) app ignores the column.
Add a duplicate column with the new name, then remove the old one safely.
(If the table is not in use, with no queries running against it, you can rename the column in a single step.)
Deploy it in these steps:
-
Deploy 1:
- TODO: Next time, try using a DB-level trigger instead of doing it app-level. Then update this document!
- Add the new column.
-
Deploy 2:
- Make the app write to both the old and the new column. Now all new records will have a value in the new column.
- Migrate old records to copy the old column to the new column. Now both old and new records will have a value in the new column.
-
Deploy 3:
- Ignore the old column (see above) and change all code to reference only the new column.
- If you're daring, remove the old column. We usually leave it around for a little while until we've run some queries to verify all data is copied.
- NOTE: Rails will let you ignore the old column and still read it with
read_attribute
/[]
in one and the same commit/deploy.
-
Deploy 4: Remove the code that ignored the column.
-
- Adding tables is always safe.
- Changing column types is probably not safe. One case is safe though - changing a :string to :text (internally VARCHAR(255) to TEXT). This is because they are essentially compatible, and :text can hold more data than :string. But the other way may truncate data.
- Changing column NULL is not safe. E.g.
change_column_null :foos, :bar_id, false
. It causes a read-and-write lock on the entire table which may cause downtime for large tables that are in active use. Read more.
This is safe since Postgres 11, even with nullable columns, as long as the value is non-volatile – same as when adding a column with a default.
-
Removing tables is never safe.
Just like with removing columns, deploy them in two steps:
-
Deploy an app that doesn't use those tables at all.
-
Deploy the migration that removes those tables.
-
- Renaming tables is never safe.
Tables can be renamed in two phases:
- Make a simple view like
CREATE VIEW new_table AS SELECT * FROM old_table
, point the model tonew_table
, and deploy. Simple views are automatically updateable, so the view will act as a perfect proxy to the underlying table. DELETE VIEW new_table; ALTER TABLE old_table RENAME TO new_table
– when executed in a transaction, as most migrations would be, it atomically swaps out the objects.
Usual considerations about taking exclusive locks in the middle of a busy day still apply.
- Creating indexes should be done concurrently.
- Removing indexes is always safe.
- TODO
Someone may post a form, follow a link etc from the old app where the resulting request is handled by the new app.
If the old and new apps don't expect the exact same parameters and paths, the user may see an exception or a 404.
We generally don't attempt to handle this as problems are rare at our scale.
If you do need to handle them, you can do so by making the new app handle old-style input for a while, using aliases, no-ops and similar.
If it's very common and important in a particular instance, perhaps the forms and controllers can be explicitly versioned like an API. We've never had to do this.