Recently I needed to change the primary key on an existing database table in a Phoenix project. While it sounds straight forward, there were a few hoops to jump through. I put together a writeup on how I went about it.

What we have

Let’s take an example of a books table. We’ve been using an ISBN string value for the id primary key.

The table has the following structure:

+-------------+-----------------------------+-------------+
| Column      | Type                        | Modifiers   |
|-------------+-----------------------------+-------------|
| id          | character varying(255)      |  not null   |
| title       | character varying(255)      |  not null   |
| page_count  | integer                     |             |
| inserted_at | timestamp without time zone |  not null   |
| updated_at  | timestamp without time zone |  not null   |
+-------------+-----------------------------+-------------+
Indexes:
    "books_pkey" PRIMARY KEY, btree (id)

What we want

Rather than using a ISBN string as the primary key, we’re going use “serial” datatype.

We’ll also copy the existing id value to a new isbn column which we’ll create on our books table.


Aside: Serial Data types

A serial is an auto-incrementing, non null numeric value. This is what phoenix uses by default as a primary for database tables.

More details on the serial datatypes can be found in the PostgreSQL and MySQL docs:


After making our changes, our books table will have the following structure:

+-------------+-----------------------------+-----------------------------------------------------+
| Column      | Type                        | Modifiers                                           |
|-------------+-----------------------------+-----------------------------------------------------|
| id          | bigint                      |  not null default nextval('books_id_seq'::regclass) |
| title       | character varying(255)      |  not nul                                            |
| page_count  | integer                     |                                                     |
| isbn        | character varying(255)      |  not null                                           |
| inserted_at | timestamp without time zone |  not null                                           |
| updated_at  | timestamp without time zone |  not null                                           |
+-------------+-----------------------------+-----------------------------------------------------+
Indexes:
    "books_pkey" PRIMARY KEY, btree (id)

How we get there

We’ll create a separate up and down function for our migration, our migration is to complicated for a change to infer how it should be rolled back.

Here’s what we need in our up/0 function:

1. Create some new columns

alter table(:books) do
  add(:new_primary_id, :serial)
  add(:isbn, :string)
end

flush()

First we add a new column "new_primary_id" that will become our primary key. Using the serial datatype, it will automatically be populated with unique ascending numbers

Add an "isbn" column that we’ll copy our existing"id" values into. While we want a not null constraint on the column, we can’t add that until it’s fully populated or we’ll be in violation of that constraint.

Use the flush() function to ensure the new columns are added immediately so they can be referenced later in the migration. Thanks to this great article from HashRocket for helping me figure that one out.

2. Copy over the existing id values

import Ecto.Query, only: [from: 2]

from(b in "books", update: [set: [isbn: b.id]])
|> MyApp.Repo.update_all([])

Next we copy over the values from our old "id" to the "isbn" column we’ve just created.

Note: We’re using a Schemaless Migration so we’re referencing our database directly, not our Ecto Schemas. This ensures our migration isn’t affected by any future changes to our Ecto.Schemas.

3. Swap over to our new primary key

alter table(:books) do
  remove(:id)
  modify(:new_primary_id, :integer, primary_key: true)
  modify(:isbn, :string, null: false)
end

We remove the old primary key "id" column. This also drops the primary key constraint associated with this column.

We now set our "new_primary_id" as the primary key for books. A table cannot have two primary keys (we’re not talking about composite keys here), so we have to drop the old one before we can set the new one.

Now that we’ve populated the "isbn" column in step #3, we can add the not null constraint on the column.

4. Rename our new primary key

Our column "new_primary_id" is now set up and populated so we can rename it to "id", getting us back to the standard primary key convention in Ecto.

rename(table(:books), :new_primary_id, to: :id)

5. Add an index

Even though the ISBN value is no longer our primary key, it’s still likely the table will be queried by ISBN, so let’s add an index on that column.

create(index(:books, :isbn))

Do it all again, backwards

For our down/0 method, we basically need to run through all of the above steps, in reverse order. Doing so will ensure that running mix ecto.rollback will revert our table back to it’s original state.

All together now

As I mentioned at the start, swapping primary keys is something that appears to be a simple task at first, but because relational databases have such tight constraints (and rightly so), we have to do a bit of work to achieve our goal.

Running mix ecto.migrate will:

  • move the ISBN values from "id" to a new "isbn" column
  • change the “id” column type to an auto-incrementing numeric and populate all the values

Here’s what our final migration looks like.

defmodule MyApp.Repo.Migrations.AddNewBooksPrimaryKey do
  use Ecto.Migration
  alias MyApp.Repo

  import Ecto.Query, only: [from: 2]

  def up do

    # 1. Create new columns
    # add new_primary_id to books which will take over as primary key
    # add isbn to store existing id
    alter table(:books) do
      add(:new_primary_id, :serial)
      add(:isbn, :string)
    end

    # ensure the new columns are added
    flush()

    # 2. Copy over the existing isbn values
    from(b in "books", update: [set: [isbn: b.id]])
    |> Repo.update_all([])


    # 3. Swap over to our new primary key
    alter table(:books) do
      remove(:id)
      modify(:new_primary_id, :integer, primary_key: true)
      modify(:isbn, :string, null: false)
    end

    # 4. Rename our new primary key
    rename(table(:books), :new_primary_id, to: :id)

    # 5. Add an index to isbn
    create(index(:books, :isbn))
  end

  def down do
    # Add back our old primary key
    alter table(:books) do
      add(:old_primary_id, :string)
    end

    # ensure the new column is added
    flush()

    # populate the old_primary_id column
    from(c in "books", update: [set: [old_primary_id: c.isbn]])
    |> Repo.update_all([])

    # swap back the primary key
    alter table(:books) do
      remove(:id)
      remove(:isbn)
      modify(:old_primary_id, :string, primary_key: true)
    end

    # rename the primary key
    rename(table(:books), :old_primary_id, to: :id)
  end
end