Skip to content

Sync and migrations

Sync compares your model classes with the database and applies the table changes needed to keep them matched.

Unlike normal migrations, sync does not use numbered files or a migration history table.

Use model sync for normal table changes, such as creating a table, adding a column, changing a type, or adding an index. Use sync hooks when a change needs your own steps, such as renaming a column, moving data, or deleting an old column.

The usual workflow

From your project root, preview the sync with check:

model sync check

Or a dry run:

model sync apply --dry-run --print

If the output looks right, apply the same sync to the real database:

model sync apply --print

Then confirm that nothing is left to apply:

model sync check

check is a quick preview command. A dry run is usually more useful because it runs the apply flow against temporary table copies. The temporary dry-run environment contains a copy of the schema but no row data. MySQL dry runs also require permission to create and drop a database.

apply runs the changes. --print shows the SQL and hook code being run, which is useful when reviewing a change.

The final check should say there is nothing left to apply.

Configure sync

Create model.config.yaml in the project root:

include_dirs:
  - ./models

include_dirs tells Model where to look for model files. Each entry can be a file or a folder. Folders are searched recursively.

You can skip files or folders with exclude_dirs:

include_dirs:
  - ./models

exclude_dirs:
  - ./models/old

If your project uses a src folder, set cwd to the Python import root:

cwd: ./src
include_dirs:
  - ./my_app/models

The CLI imports your model files, so keep model modules importable without starting your whole app or doing unrelated work.

What sync does automatically

Model compares each discovered model with its database table.

It can create missing tables and apply supported column or index changes.

It does not guess destructive changes:

  • If a column exists in the database but not in the model, Model prints a warning and leaves it alone.
  • If a column or table was renamed, Model sees the old name and the new name as two different things. Use a pre_sync hook.
  • If old data must be copied or changed, write that step yourself in a hook.

This keeps common changes quick while making risky changes explicit.

Safety rules

Some changes are blocked unless you allow them in model.config.yaml.

include_dirs:
  - ./models

sync_rules:
  allow_length_decrease: true

Rules:

Rule Default Meaning
allow_primary_index_removal false Allow removing or changing a primary key.
allow_unique_index_removal true Allow removing or weakening a unique index.
allow_auto_increment_removal true Allow removing auto_increment from a column.
allow_length_decrease false Allow making a CHAR or VARCHAR column shorter.
allow_type_change true Allow changing a column type.
allow_type_category_change false Allow changing to a very different kind of type, such as text to number.

Allowing a rule does not guarantee the change will work. The database can still reject it, and Model may still stop if existing data would not fit.

Before applying a risky change, Model checks common data problems such as:

  • duplicate values before adding a single-column primary or unique index;
  • NULL values before making a column required;
  • text values that are too long for a shorter column;
  • numbers that do not fit in a smaller number type;
  • decimal values that would overflow or need rounding.

Sync hooks

A sync hook is a small function attached to a model. It runs during model sync apply.

Use pre_sync for work that must happen before Model updates the table. Common examples are renaming a table or column.

Use post_sync for work that must happen after Model updates the table. Common examples are filling a new column and then removing old columns.

Every hook needs a run_if function. It should return True only when the hook still needs to run.

The database methods used in hooks, such as has_column, rename_column, and drop_column, are documented in Database: Manage tables and columns.

@User.pre_sync(
    run_if=lambda: User.db.has_column(
        table=User.table,
        column="name",
    )
)
def rename_name_to_display_name():
    User.db.rename_column(
        table=User.table,
        old_column="name",
        new_column="display_name",
    )

Keep hooks safe to run once and safe to skip later. The run_if check is what prevents a sync hook from running again after it has already finished.

Hook order

For each model, apply does this:

  1. Run active pre_sync hooks in the order they are declared.
  2. Recheck the table and build the SQL diff.
  3. Run the SQL diff.
  4. Run active post_sync hooks in the order they are declared.
  5. Warn about database columns still missing from the model.

If a pre_sync hook fails, Model skips the SQL diff and post_sync hooks for that model.

If the SQL diff fails, Model skips post_sync hooks for that model.

If a post_sync hook fails, that model is reported as failed.

Model stops running hooks for that model after the first hook failure, then continues with the other discovered models.

model sync check does not run hook bodies, but it does evaluate run_if. Keep run_if read-only.

Example: rename a column

Start with this existing model:

class User(Model):
    table = "user"
    db = db

    id: int = Model.Column(type="INT", index="PRIMARY", auto_increment=True)
    name: str | None = Model.Column(type="VARCHAR", length=100)

Change the model to the new column name and add a pre_sync hook:

class User(Model):
    table = "user"
    db = db

    id: int = Model.Column(type="INT", index="PRIMARY", auto_increment=True)
    display_name: str | None = Model.Column(type="VARCHAR", length=100)


@User.pre_sync(
    run_if=lambda: User.db.has_column(
        table=User.table,
        column="name",
    )
)
def rename_user_name_column():
    User.db.rename_column(
        table=User.table,
        old_column="name",
        new_column="display_name",
    )

Then run:

model sync check
model sync apply --print
model sync check

The hook renames the real database column before Model checks the final table shape.

Example: copy data into a new column

Use a post_sync hook when the new column must exist before you can fill it.

Suppose the database has username, and the model now uses display_name. You do not want a simple rename because the new value may be cleaned up or changed later.

First, add the new nullable column:

class User(Model):
    table = "user"
    db = db

    id: int = Model.Column(type="INT", index="PRIMARY", auto_increment=True)
    username: str = Model.Column(
        type="VARCHAR",
        length=100,
        can_be_null=False,
    )
    # New column being added in this sync:
    display_name: str | None = Model.Column(type="VARCHAR", length=100)

Then add a post_sync hook to copy the old values:

@User.post_sync(
    run_if=lambda: (
        User.db.has_column(table=User.table, column="username")
        and User.db.has_column(table=User.table, column="display_name")
        and User.count("display_name IS NULL") > 0
    )
)
def fill_display_name_from_username():
    with User.db.query(
        f"""
        UPDATE {User.table}
        SET display_name = username
        WHERE display_name IS NULL
        """
    ):
        pass

If you want display_name to be required, make it non-nullable in a later change after every database has been filled. Remove old columns separately after you have verified the copied data.