migra is a great tool for tracking changes in a Postgres schema for ORM-less setups.

It generates schema diffs between two different Postgres instances. i.e., on an instance A with the desired schema, and instance B with current schema, migra will generate a SQL file that you can apply to instance B that will bring its schema up to parity with A.

Here's a diagram to help visualize this:

.         (current)                       (new)
.      ---------------               ---------------
.      | Instance $A |               | Instance $A |
.      | Schema V0   | -- Change --> | Schema V1   |
.      ---------------               ---------------
.                                          |
.                          |---------------|
.                          |
.                          v
.     ===============================================
.     |               $ migra diff                  |
.     |         Against Instance B (current)        |
.     ===============================================
.                          |
.                          |
.                          v
.                      (current)
.                   -----------------
.                   |  Instance $B  |
.                   |  Schema V0    |
.                   -----------------
.                          |
.                          |
.                          v
.     ===============================================
.     | Produces an upgrade SQL script to Schema V1 |
.     |             >> changes.sql                  |
.     ===============================================
.                          |
.                          |
.                          v
.     ===============================================
.     |           Review and apply to $B            |
.     |   $ psql postgresql://$B -1 -f changes.sql  |
.     ===============================================
.                          |
.                          |
.                          v
.                        (new)
.                   -----------------
.                   |  Instance $B  |
.                   |  Schema V1    |
.                   -----------------

This allows us to make schema tweaks on a local or dev instance, generate a diff against production, review and apply the generated diff to production, and keep the generated diff in source control.

Usage

migra is easy to use:

# $1 = Instance with current schema
# $2 = Instance with desired schema
# `changes.sql` => the diff between $1 and $2
$ migra postgresql://$1 postgresql://$2 > changes.sql

Let's assume that both instances ($1 and $2) have this schema:

# 0000.sql
CREATE SCHEMA IF NOT EXISTS app;
CREATE TABLE IF NOT EXISTS app.users (
	username TEXT NOT NULL,
	password TEXT NOT NULL
);

Since both instances have the same schema, running migra generates nothing:

$ migra \
    postgresql://postgreslocal:postgreslocal@localhost:5432/postgres \
    postgresql://postgresprod:postgresprod@localhost:5433/postgres

Now let's make a change to one of the instances:

ALTER TABLE app.users ADD COLUMN last_login TIMESTAMP NULL;

And run migra both ways:

$ migra --unsafe \
    postgresql://postgreslocal:postgreslocal@localhost:5432/postgres \
    postgresql://postgresprod:postgresprod@localhost:5433/postgres
alter table "app"."users" drop column "last_login";

# Switch the connection string around:
$ migra --unsafe \
    postgresql://postgresprod:postgresprod@localhost:5433/postgres \
    postgresql://postgreslocal:postgreslocal@localhost:5432/postgres
alter table "app"."users" add column "last_login" timestamp without time zone;

Now you can see why it's a schema diff tool. You can then pipe the output to a file, review and/or modify it, and apply the changes when you're satisfied:

# Output to file
$ migra --unsafe \
    postgresql://postgresprod:postgresprod@localhost:5433/postgres \
    postgresql://postgreslocal:postgreslocal@localhost:5432/postgres \
    > 0001.sql
alter table "app"."users" add column "last_login" timestamp without time zone;

# Review
$ cat 0001.sql
alter table "app"."users" add column "last_login" timestamp without time zone;

# Apply
$ psql postgresql://postgresprod:postgresprod@localhost:5433/postgres \
    -1 -f 0001.sql
ALTER TABLE

# Run migra again (prod->local)
$ migra --unsafe \
    postgresql://postgresprod:postgresprod@localhost:5433/postgres \
    postgresql://postgreslocal:postgreslocal@localhost:5432/postgres
#   => No output, because both instances are at parity

# Run migra again (local->prod)
$ migra --unsafe \
    postgresql://postgreslocal:postgreslocal@localhost:5432/postgres \
    postgresql://postgresprod:postgresprod@localhost:5433/postgres
#   => No output, because both instances are at parity

Why unsafe flag?

Without --unsafe, migra will not generate destructive statements. We want them because dropping columns/indexes/etc. is a valid schema tweak, so the flag allows us to (intentionally) retain destructive commands such as DROP ....

Check out https://databaseci.com/docs/migra/options for more information.

Usage warning: Be careful about renames!

Most rename operations are generated as a DROP foo; -> CREATE foo; statement, meaning that it re-creates the object instead of renaming it in-place. This will happen if when you rename a table, column, primary key, etc.

Check out migra/issues/29 and migra/issues/213 for more information.

On tracking schema changes

Tracking changes in source control can be achieved with just a single version-controlled SQL file, but in my opinion, it leaves much to be desired:

  • If there are incompatible changes between different database versions, local/dev environments may need to get nuked in order to apply the full schema again
  • Browsing through a series of sequential files to view point-in-time changes is more ergonomical than $ git blame

As a counterpoint, a single file helps maintain a single source-of-truth for the current state of the schema, which is a desireable trait. Only if there were Git for databases!

Appendix: Demostration set-up

Spin up two Postgres instances using Docker:

# docker-compose-local-pg.yml
version: '3.8'
services:
  postgres:
    image: postgres:14
    restart: always
    environment:
      - POSTGRES_USER=postgreslocal
      - POSTGRES_PASSWORD=postgreslocal
      - POSTGRES_DB=postgres
    ports:
      - 5432:5432
    volumes:
      - ./postgres-local-data:/var/lib/postgresql/data

# docker-compose-prod-pg.yml
version: '3.8'
services:
  postgres:
    image: postgres:14
    restart: always
    environment:
      - POSTGRES_USER=postgresprod
      - POSTGRES_PASSWORD=postgresprod
      - POSTGRES_DB=postgres
    ports:
      - 5433:5432
    volumes:
      - ./postgres-prod-data:/var/lib/postgresql/data

Use example starting schema:

# 0000.sql
CREATE SCHEMA IF NOT EXISTS app;
CREATE TABLE IF NOT EXISTS app.users (
	username TEXT NOT NULL,
	password TEXT NOT NULL
);

Apply example schema to both instances:

$ psql postgresql://postgreslocal:postgreslocal@localhost:5433/postgres -1 -f 0000.sql
CREATE SCHEMA
CREATE TABLE
$ psql postgresql://postgresprod:postgresprod@localhost:5433/postgres -1 -f 0000.sql
CREATE SCHEMA
CREATE TABLE