Erlang_migrate - a golang-migrate-inspired database migration library for Erlang/OTP


Hi Erlang community,

I’d like to share a library I’ve been working on: erlang_migrate ( GitHub - imboy-pub/erlang_migrate · GitHub ), a database migration library for

Erlang/OTP modelled closely after golang-migrate/migrate v4 ( GitHub - golang-migrate/migrate: Database migrations. CLI and Golang library. · GitHub ).

Why another migration library?

The Erlang ecosystem lacks a standalone, production-grade migration tool with well-defined semantics. Most projects either hand-roll SQL scripts or rely

on framework-specific tooling. Meanwhile, golang-migrate has proven that a tiny, principled design goes a long way:

  • Plain SQL files — no ORM, no DSL, no code generation

  • Integer versions — deterministic ordering, no ambiguity

  • Dirty state machine — failures are permanently visible, never silently skipped

  • Database advisory locks — safe for multi-node clusters

erlang_migrate ports these semantics faithfully to Erlang/OTP.


Design highlights

Single-row tracking table

Aligned with golang-migrate’s TRUNCATE + INSERT semantics: the table holds at most one row at all times. Every set_version call is DELETE + INSERT. An

empty table means no migrations applied.

CREATE TABLE schema_migrations (

  version    BIGINT PRIMARY KEY,

  dirty      BOOLEAN NOT NULL DEFAULT false,

  applied_at TIMESTAMPTZ NOT NULL DEFAULT now()

);

This eliminates the force/2 stale-dirty-row bug that a multi-row design would have.

Two-phase commit with dirty flag

set_version(V, dirty=true) ← in-progress

execute SQL

set_version(V, dirty=false) ← complete

If the process crashes between phases, dirty=true stays in the table and blocks all future up/down/goto calls until force/2 is called after manual

recovery. Same semantics as golang-migrate.

Advisory locks per database

┌────────────┬─────────────────────────────────────────┐

│ Database │ Lock mechanism │

├────────────┼─────────────────────────────────────────┤

│ PostgreSQL │ pg_try_advisory_lock + 100ms retry loop │

├────────────┼─────────────────────────────────────────┤

│ MySQL │ GET_LOCK(‘name’, 0) + retry loop │

├────────────┼─────────────────────────────────────────┤

│ SQLite │ OTP global:set_lock/3 + retry loop │

└────────────┴─────────────────────────────────────────┘

Lock timeout defaults to 15 000 ms (matching golang-migrate) and is configurable.


Zero runtime dependencies

The library itself has no hard dependencies. Users add only the driver package they need:

%% PostgreSQL

{deps, [{erlang_migrate, “0.2.0”}, {epgsql, “4.8.0”}]}.

%% MySQL

{deps, [{erlang_migrate, “0.2.0”}, {mysql, “1.8.0”}]}.

%% SQLite

{deps, [{erlang_migrate, “0.2.0”}, {esqlite, “0.8.1”}]}.


Three config knobs, no source edits needed

Config = #{

  conn   => Conn,

  driver => erlang_migrate_mysql,         % which backend

  dir    => "priv/migrations",            % where SQL files live

  table  => <<"myapp_schema_migrations">> % custom tracking table

},

ok = erlang_migrate:up(Config).

The tracking table is created automatically on first run. Lock ID is derived from the table name, so different table names are lock-isolated out of the

box.


Migration file naming

priv/migrations/

00000001_create_users.up.sql

00000001_create_users.down.sql

00000002_add_email_index.up.sql

20240101120000_add_audit_log.up.sql   <- timestamp versions work too

Rules: positive integer version, {version}_{title}.up.sql / .down.sql, flat directory.


Full API surface

up(Config) %% apply all pending → golang-migrate Up()

up(Config, N) %% apply up to N → Steps(+N)

down(Config) %% roll back all → Down()

down(Config, N) %% roll back N → Steps(-N)

goto(Config, V) %% jump to version V → Migrate(v)

version(Config) %% {ok, Ver, Dirty} → Version()

force(Config, V) %% clear dirty state → Force(v)

drop(Config) %% drop tracking table → Drop() (partial)


Pluggable driver interface

Implement the erlang_migrate_driver behaviour to add support for any database:

-module(my_driver).

-behaviour(erlang_migrate_driver).

%% 8 callbacks: ensure_table/2, current_version/2, lock/3, unlock/2,

%% set_version/4, is_dirty/2, exec_sql/2, drop_table/2


Current status

  • PostgreSQL 18+, MySQL 8+, SQLite 3+ supported

  • 57 EUnit tests, 0 failures

  • Zero hard dependencies

  • Available on GitHub:

Feedback, issues, and PRs are very welcome. I’m particularly interested in hearing from anyone who has opinions on the single-row vs multi-row tracking

table tradeoff — happy to discuss the design in this thread.


7 Likes

大佬又在这造轮子 :grinning_face: 这次是合金的