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.