Migrations
View SourceKura migrations provide version-tracked DDL operations for managing your database schema.
Creating a Migration
Migration modules must:
- Be named
m<YYYYMMDDHHMMSS>_<descriptive_name> - Implement the
kura_migrationbehaviour - Export
up/0anddown/0returning a list of operations
-module(m20250101120000_create_users).
-behaviour(kura_migration).
-include_lib("kura/include/kura.hrl").
-export([up/0, down/0]).
up() ->
[{create_table, ~"users", [
#kura_column{name = id, type = id, primary_key = true},
#kura_column{name = name, type = string, nullable = false},
#kura_column{name = email, type = string, nullable = false},
#kura_column{name = inserted_at, type = utc_datetime, nullable = false},
#kura_column{name = updated_at, type = utc_datetime, nullable = false}
]}].
down() ->
[{drop_table, ~"users"}].Place migration files in src/migrations/ (or any subdirectory under src/). Kura automatically discovers them by scanning the application's compiled modules for names matching the m<YYYYMMDDHHMMSS>_<name> pattern — no configuration needed.
Since migrations are regular .erl files in src/, they are compiled normally by rebar3. If you use {src_dirs, [{"src", [{recursive, true}]}]} in your rebar.config, subdirectories like src/migrations/ are included automatically.
DDL Operations
Create Table
{create_table, ~"table_name", [
#kura_column{name = id, type = id, primary_key = true},
#kura_column{name = name, type = string, nullable = false},
#kura_column{name = score, type = integer, default = 0},
#kura_column{name = active, type = boolean, default = true}
]}.Column options:
primary_key—true | false(default:false)nullable—true | false(default:true)default— literal value (integer, float, binary, boolean) orundefinedfor none
Drop Table
{drop_table, ~"table_name"}.Alter Table
{alter_table, ~"users", [
{add_column, #kura_column{name = bio, type = text}},
{drop_column, old_field},
{rename_column, old_name, new_name},
{modify_column, score, float}
]}.Create Index
Indexes use a map-based options format with auto-generated names following Ecto conventions ({table}_{columns}_index):
%% Simple unique index
{create_index, ~"users", [email], #{unique => true}}.
%% Generates: CREATE UNIQUE INDEX "users_email_index" ON "users" ("email")
%% Non-unique index
{create_index, ~"posts", [user_id], #{}}.
%% Generates: CREATE INDEX "posts_user_id_index" ON "posts" ("user_id")
%% Composite index
{create_index, ~"users", [first_name, last_name], #{}}.
%% Generates: CREATE INDEX "users_first_name_last_name_index" ON ...
%% Partial index
{create_index, ~"users", [email], #{unique => true, where => ~"email IS NOT NULL"}}.
%% Generates: CREATE UNIQUE INDEX "users_email_index" ON "users" ("email") WHERE email IS NOT NULLThe index name is auto-generated via kura_migration:index_name/2. If you need a custom name, the legacy 5-tuple format is still supported:
{create_index, ~"my_custom_idx", ~"users", [email], [unique]}.Drop Index
{drop_index, ~"users_email_index"}.Raw SQL
{execute, ~"ALTER TABLE users ADD CONSTRAINT age_check CHECK (age >= 0)"}.Running Migrations
%% Run all pending migrations
{ok, AppliedVersions} = kura_migrator:migrate(my_repo).Each migration runs in its own transaction. If a migration fails, it is rolled back and subsequent migrations are not attempted.
Rolling Back
%% Roll back the last migration
{ok, RolledBack} = kura_migrator:rollback(my_repo).
%% Roll back the last N migrations
{ok, RolledBack} = kura_migrator:rollback(my_repo, 3).Checking Status
Status = kura_migrator:status(my_repo).
%% Returns: [{Version, Module, up | pending}, ...]Schema-Level Indexes
Instead of manually writing index operations in migrations, you can declare indexes on your schema module. This is the recommended approach — it keeps index definitions alongside your schema and allows rebar3_kura to auto-generate the migration operations for you.
-module(my_user).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([table/0, fields/0, indexes/0]).
table() -> ~"users".
fields() ->
[#kura_field{name = id, type = uuid, primary_key = true, nullable = false},
#kura_field{name = username, type = string, nullable = false},
#kura_field{name = email, type = string},
#kura_field{name = phone_number, type = string}].
indexes() ->
[{[username], #{unique => true}},
{[email], #{unique => true}},
{[phone_number], #{unique => true, where => ~"phone_number IS NOT NULL"}}].Unique indexes declared via indexes/0 are automatically registered as changeset constraints — no manual unique_constraint/2 calls needed. When a PostgreSQL unique violation fires (e.g. users_email_index), it maps to {email, <<"has already been taken">>} on the changeset.
Schema Migrations Table
Kura automatically creates a schema_migrations table to track which migrations have been applied. This table is created on first use of migrate/1, rollback/1, or status/1.