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:
Or a dry run:
If the output looks right, apply the same sync to the real database:
Then confirm that nothing is left to apply:
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 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:
If your project uses a src folder, set cwd to the Python import root:
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_synchook. - 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.
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;
NULLvalues 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:
- Run active
pre_synchooks in the order they are declared. - Recheck the table and build the SQL diff.
- Run the SQL diff.
- Run active
post_synchooks in the order they are declared. - 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:
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.