Adding columns with default values to large tables in Postgres

I recently had to add a new column with a default value to an existing table in a Rails application.

The Rails application was backed by PostgreSQL and the table that we were adding this new column to had over 2 million rows of data. Now, adding a new column with a default value in Rails is a simple task that involves writing a migration that looks like

 

In most cases, the above will work. However, if you have really big table with large amounts of data, which in this specific case there were over 2 million rows of data, the above migration will take an eternity to run. This is because adding a default value for a column in a table will get Postgres to go over every row and update the default values for the said column. If you have a large table, this operation can take some time. What’s worse is that because the above migration is locked in a transaction, the table in question becomes locked during the migration and your application will grind to a halt since your database table won’t be functional.

So… how do we solve this? Let’s first list our problems in the situation where we’re adding a new default column to a really large table.

Problems

  • Column creation (or simply adding default value to an existing column) causes all rows in that table to be updated at the same time, which can be time consuming in really large tables
  • Updates are slow in Postgres since it has to guarantee consistency
  • During the update, the table that’s in operation is locked, causing the application to grind to a halt until the update finishes

To solve this problem, we need to work around the above facts so that we can add the default column and allow all of the rows in the large table to be updated while keeping the application running. How do we do this? Well… we can do the following.

Solution

  • Adding a column to a table without default values in Postgres is fast. Thus, we should add the new column with a default value of null first to get the creation of the new column out of the way.
  • Rather than updating every row in the table all at once, we can split up the number of records and update them in batches. This makes the work more manageable for the database.
  • If we make the updates of the rows in the table with default values more manageable, the table won’t be locked during the operations and the application can keep running during the migration.

So, let’s do this. To do this in a Rails migration, we need to recognize a few more things about how Rails handles migrations. Rails migrations are done in a transaction. Meaning anything in that def change are being ran in a transaction to allow for rollbacks. If we do any data updates there, it means that all of those updates will be wrapped in a transaction, locking the entire table. This means that we’ll need a way to disable this auto-transaction mechanism of Rails migrations and handle our transactions manually, picking and choosing where we want to wrap our database operations in a transactions and where we want to disable wrapping our database operations in transactions. Thankfully, Rails comes with a method called disable_ddl_transaction! that we can use in our migration files to disable the default transaction locking behavior in our migrations. Also, we want to replace the def change method with the def up and def down  so that we can manually define our rollback strategy just in case we want to rollback our migration. Below is my attempt at utilizing this feature of Rails and handling the transactions on my own.

For the sake of demonstration, let’s say that we’re adding a integer column named number_of_retries with a default value of 0 to a table called users.

You can see that I wrap all database operations (adding a column, altering columns with default values, and etc.) in transactions, while I do not wrap the part where I update the default user values in transactions. The find_in_batches has a default batch_size of 1,000, but you can always increase that to a larger number for faster migration speed if you want. I just found that 1,000 to be a good number for stable migrations. I’ve had situations where the migration timed out when I set the default batch_size to a higher number like 10,000. Also, as you can see, having a separate def up and def down methods in the migration allows us a way to safely rollback the migration if needed.

The above migration will work. Granted, it won’t be an instant migration. You’ll still have to wait for the migration to run. However, it’ll prevent locking of the table which grinds the application to a halt, which ends up in bad experience for your users. Unfortunately as of now, there are no good simple ways to add new columns with default values quickly to an existing large table. The above is a workaround that utilizes certain principles to add new columns with default values on really large tables.

I hope that this post is helpful for those looking to solve similar problems on their production Rails applications.