Skip to content
Tutorials

Migration lifecycle

Your users already have data. Changing a table should preserve that data and give every deployment the same reviewed SQL to run.

Rename without replacing the data

Suppose User.name becomes Member.full_name. Tell Rent these are new names for the existing table and column:

model Member {
  id        BigInt @id
  full_name String @rent.previousName("name")

  @@rent.previousName("users")
}

Without @rent.previousName, removing one name and adding another is ambiguous and may be classified as destructive. Keep the annotation for the migration that performs the rename. Remove it in a later migration after every environment has applied the change.

Preflight detects an unambiguous drop/add pair and prints the exact annotation to add:

Possible rename: public.users.display_name → name
   Preserve its data with @rent.previousName("display_name") on `name`.

Review required: Destructive changes need explicit approval.

This is guidance rather than an automatic guess: Rent does not risk data by silently deciding that two unrelated columns are the same object.

Review the proposed rename, then apply it locally:

rent migrate preflight
rent migrate dev --name rename_users
rent generate
rent migrate drift

The rename keeps the existing rows. A clean drift result means the database matches the schema. A reversal that drops data still requires explicit destructive-change approval. Preserving a rename does not authorize unrelated removals.

For a deployment with a separate SQL review step, use rent migrate diff, rent migrate plan, rent migrate validate, and rent migrate apply. The migration guide explains that workflow and rent migrate reset --yes for rebuilding a disposable local database.

Bring an existing application under migrations

Start with a baseline when tables already exist. The first migration must reconstruct those tables on an empty database. Record it as applied on each existing database with rent migrate resolve --applied. Then follow the same review and apply workflow as a new project.

Plan later changes from migration history, including migrations not yet applied to development. Rent replays that history into a disposable shadow database before calculating the next plan, so a second planned change does not duplicate the first. The migration guide shows how to plan two changes and configure a dedicated shadow database when necessary.

Adding typed JSON projections, a full-text index, or a partial unique is a schema change too. Update RSL, review the migration SQL, generate the client, apply the migration, and check drift. Changing the search configuration rebuilds the generated vector and its index. Adding a live-row unique needs existing live rows to satisfy it. A filter does not repair duplicates. Use an authored data migration when rows need cleanup or backfill.

Backfill existing rows safely

Adding a required column to a populated table normally takes three releases:

  1. Expand: add the column as nullable so old and new application versions both work.
  2. Backfill: populate every existing row in a separately authored data migration.
  3. Contract: make the column required only after the backfill has been verified everywhere.

Create and seal the middle migration explicitly:

rent migrate create --name backfill_member_slugs

# Edit the new SQL file, then freeze its reviewed checksum.

rent migrate seal
rent migrate validate
rent migrate apply

Use the migration API directly

The CLI handles the usual application workflow. Tools that need to manage catalogs programmatically can inspect the current database, compare it with a desired catalog, and apply the resulting plan:

let current = CatalogInspector::new(&pool).inspect().await?;
let plan = MigrationPlan::diff_for(
    Dialect::Sqlite,
    &current,
    &desired,
    DiffOptions::default(),
);

let report = apply_plan(
    &pool,
    Dialect::Sqlite,
    &plan,
    ApplyOptions::default(),
)
.await?;

This example uses SQLite. After application, inspect again and compare with the same desired catalog. No remaining changes means the two match. The same catalog API can describe schema-qualified PostgreSQL tables. The CLI adds checksummed migration directories and deployment history around these operations.

Complete example

Download this project, or copy the files below into an empty directory.

Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.

rent generate
cargo run

The project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.

Cargo.toml
[package]
name = "rent-lesson-15-migrations"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-15-migrations"
path = "main.rs"

[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
tempfile = "3.27.0"
rent-schema-language = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6" }
rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"

[database]
dialect = "sqlite"
backends = ["sqlite"]
schema.rsl
datasource db {
  provider = "sqlite"
}

model Member {
  id        BigInt  @id
  full_name String  @rent.previousName("name")
  slug      String

  @@rent.previousName("users")
  @@map("members")
}
main.rs
use std::fs;

use anyhow::{Result, bail, ensure};
use rent::driver::DatabasePool;
use rent::migrate::{
    ApplyOptions, Catalog, CatalogInspector, Column, ColumnRename, DiffOptions, MigrationDirectory, MigrationPlan,
    Schema, SqlRenderer, Table, TableRename, apply_plan, validate_sql,
};
use rent::sql::Dialect;
use rent::sql::ast::RenderedQuery;

async fn expand_backfill_contract(
    pool: &DatabasePool,
    renamed: &Catalog,
    renamed_inspected: &Catalog,
) -> Result<Catalog> {
    // Expand: make room for the new value without breaking the currently deployed application.
    let mut expanded = renamed.clone();
    let Some(members) = expanded.schemas.first_mut().and_then(|schema| schema.tables.first_mut()) else {
        bail!("migration tutorial catalog is missing its members table");
    };
    members.columns.push(Column::new("slug", "text", true));
    let expand_plan = MigrationPlan::diff_for(Dialect::Sqlite, renamed_inspected, &expanded, DiffOptions::default());
    ensure!(!expand_plan.is_destructive());
    apply_plan(pool, Dialect::Sqlite, &expand_plan, ApplyOptions::default()).await?;

    // Backfill: author data SQL, validate the edited draft, seal its checksum, then execute it.
    let temporary = tempfile::tempdir()?;
    let migrations = MigrationDirectory::new(temporary.path());
    let draft = migrations.write(
        "2026090902",
        "backfill_member_slugs",
        "-- Replace this line with the reviewed data migration.\n",
    )?;
    fs::write(
        draft.path,
        "UPDATE members SET slug = lower(replace(full_name, ' ', '-')) WHERE slug IS NULL;\n",
    )?;
    for migration in migrations.unsealed_migrations()? {
        validate_sql(&migration.contents, ApplyOptions::default())?;
    }
    migrations.seal()?;
    for migration in migrations.migrations()? {
        pool.execute(&migration.contents).await?;
    }

    // Contract: only require the value after every old row has been populated.
    let expanded_inspected = CatalogInspector::new(pool).inspect().await?;
    let mut contracted = expanded.clone();
    let Some(slug) = contracted
        .schemas
        .first_mut()
        .and_then(|schema| schema.tables.first_mut())
        .and_then(|table| table.columns.iter_mut().find(|column| column.name == "slug"))
    else {
        bail!("migration tutorial catalog is missing its slug column");
    };
    slug.nullable = false;
    let contract_plan =
        MigrationPlan::diff_for(Dialect::Sqlite, &expanded_inspected, &contracted, DiffOptions::default());
    ensure!(contract_plan.is_destructive());
    apply_plan(
        pool,
        Dialect::Sqlite,
        &contract_plan,
        ApplyOptions {
            allow_destructive: true,
            ..ApplyOptions::default()
        },
    )
    .await?;
    let slugs = pool
        .fetch_strings(&RenderedQuery {
            sql: "SELECT slug FROM members ORDER BY id".into(),
            arguments: Vec::new(),
        })
        .await?;
    ensure!(slugs == ["maya"]);
    Ok(contracted)
}

async fn exercise() -> Result<()> {
    rent_schema_language::parse_schema("schema.rsl", include_str!("schema.rsl"))?;
    let pool = DatabasePool::connect(Dialect::Sqlite, "sqlite::memory:").await?;
    let current = CatalogInspector::new(&pool).inspect().await?;
    let mut users = Table::new("users");
    let mut id = Column::new("id", "integer", false);
    id.identity = true;
    users.columns = vec![id, Column::new("name", "text", false)];
    users.primary_key = vec!["id".to_owned()];
    let mut main = Schema::new("main");
    main.tables.push(users);
    let desired = Catalog {
        schemas: vec![main],
        ..Catalog::default()
    };

    let plan = MigrationPlan::diff_for(Dialect::Sqlite, &current, &desired, DiffOptions::default());
    ensure!(!plan.is_empty() && !plan.is_destructive());
    let report = apply_plan(&pool, Dialect::Sqlite, &plan, ApplyOptions::default()).await?;
    ensure!(report.statements > 0);

    let inspected = CatalogInspector::new(&pool).inspect().await?;
    let fixed_point = MigrationPlan::diff_for(Dialect::Sqlite, &inspected, &desired, DiffOptions::default());
    ensure!(fixed_point.is_empty(), "unexpected fixed-point operations: {fixed_point:#?}");

    pool.execute("INSERT INTO users (id, name) VALUES (1, 'Maya')").await?;
    let mut members = Table::new("members");
    let mut member_id = Column::new("id", "integer", false);
    member_id.identity = true;
    members.columns = vec![member_id, Column::new("full_name", "text", false)];
    members.primary_key = vec!["id".to_owned()];
    let mut renamed_schema = Schema::new("main");
    renamed_schema.tables.push(members);
    let renamed = Catalog {
        schemas: vec![renamed_schema],
        ..Catalog::default()
    };
    let rename_plan = MigrationPlan::diff_with_identity_renames(
        Dialect::Sqlite,
        &inspected,
        &renamed,
        DiffOptions::default(),
        &[TableRename {
            schema: "main".to_owned(),
            from: "users".to_owned(),
            to: "members".to_owned(),
        }],
        &[ColumnRename {
            schema: "main".to_owned(),
            table: "members".to_owned(),
            from: "name".to_owned(),
            to: "full_name".to_owned(),
        }],
    )?;
    ensure!(!rename_plan.is_destructive());
    apply_plan(&pool, Dialect::Sqlite, &rename_plan, ApplyOptions::default()).await?;
    let names = pool
        .fetch_strings(&RenderedQuery {
            sql: "SELECT full_name FROM members ORDER BY id".into(),
            arguments: Vec::new(),
        })
        .await?;
    ensure!(names == ["Maya"]);
    let renamed_inspected = CatalogInspector::new(&pool).inspect().await?;
    ensure!(MigrationPlan::diff_for(Dialect::Sqlite, &renamed_inspected, &renamed, DiffOptions::default()).is_empty());

    let contracted = expand_backfill_contract(&pool, &renamed, &renamed_inspected).await?;
    let contracted_inspected = CatalogInspector::new(&pool).inspect().await?;
    ensure!(
        MigrationPlan::diff_for(Dialect::Sqlite, &contracted_inspected, &contracted, DiffOptions::default(),)
            .is_empty()
    );

    let destructive = MigrationPlan::diff_for(
        Dialect::Sqlite,
        &contracted_inspected,
        &Catalog::default(),
        DiffOptions::default(),
    );
    ensure!(destructive.is_destructive());
    ensure!(
        apply_plan(&pool, Dialect::Sqlite, &destructive, ApplyOptions::default())
            .await
            .is_err()
    );

    let mut tenant_schema = Schema::new("tenant_data");
    let mut tenant_posts = Table::new("posts");
    tenant_posts.columns = vec![Column::new("id", "BIGINT", false)];
    tenant_posts.primary_key = vec!["id".to_owned()];
    tenant_schema.tables.push(tenant_posts);
    let multi_schema = Catalog {
        schemas: vec![tenant_schema],
        ..Catalog::default()
    };
    let postgres_plan =
        MigrationPlan::diff_for(Dialect::Postgres, &Catalog::default(), &multi_schema, DiffOptions::default());
    let postgres_sql = SqlRenderer::new(Dialect::Postgres).render_plan(&postgres_plan)?;
    ensure!(postgres_sql.iter().any(|statement| statement.contains("tenant_data")));
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    exercise().await
}

#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn example_runs() -> anyhow::Result<()> {
        super::exercise().await
    }
}