Why are we still doing versioned database migrations?

4 min read Original article ↗

Database migrations are something we’ve been doing the same way for ages. A migrations folder with a bunch of versioned SQL files.

The truth is that these files are mostly useless once they’ve done their job.

Yet they’re still kept, clogging up the project source directory, and they just keep growing.

Before getting into why we still use versioned migrations, let’s define what migrations actually are.

What are migrations?

Migrations can generally be categorized into two types:

  • Schema migrations: changes to the database structure, such as creating tables, adding columns, or updating, deleting, and renaming columns.
  • Data migrations: changes to existing data, such as transforming values or moving data from one column to another.

Why are we doing versioned migrations?

Database migrations can be difficult to get right. Versioned migrations provide a reliable workflow:

  • Deployment: Versioned migration tools track which migrations have already run and apply only the new ones.

  • History: You can see how your database evolved over time by looking at the migration files.

  • Full control: Since you manually write each migration explicitly, you have more flexibility for handling complex migrations.

They also provide the ability to roll back, but that’s a terrible feature. Rollbacks are counterintuitive and unsafe. Database changes should always be forward-only.

With that said, just because this way works doesn’t mean we should stop looking for a better way. So how can we do it differently?

State-based schema updates

It’s simple: you declare the schema you want. A tool automatically compares it with the database and generates the DDL needed to update it to the desired state.

With a declarative schema, you don’t need separate versioned files to see how your database changed over time; Git already does that for you.

There are a few tools that are already doing exactly this, such as Prisma and Atlas. They’re slowly becoming popular choices.

But some people are still hesitant to switch to this approach.

What are the common issues with state-based migrations?

The main complication with this approach is destructive schema changes.

In a state-based approach, a tool has no way to know if you deleted a column or renamed it. For example, let’s say that you have a pseudo schema declaration like:

Table Users
- column username

And then you change the schema to:

Table Users
- column user_name

The tool doesn’t know whether you want to delete username and create a new column user_name, or simply rename username to user_name.

How can we handle this challenge?

There are different approaches.

Prisma warns you in these cases and requires manual editing of the generated migration.

Atlas used to require manual interaction when it detected a possible delete or rename, asking, “Did you rename the column from username to user_name?” As of 2026, it seems they use a declarative renamed_from field instead.

How Model handles it

I considered all of these different approaches while I was working on Model, a Python ORM with automatic state-based schema updates.

And in the end, I decided that simply not doing any destructive schema changes automatically is the safest way.

Model’s schema syncing automatically handles:

  • table creation
  • column additions
  • column updates (when safe)
  • indexes

However, it does not delete columns or attempt to detect renames. Those are left to the user to handle explicitly through hooks.

You define hooks that run in order, before schema sync or after schema sync.

While seemingly simple, this is a robust approach for handling complicated schema changes. For example, say you want to rename a column:

You change the name of the column in your model schema, then add a pre-sync hook that checks if the old column exists and renames it to the new column.

@User.pre_sync(
    run_if=lambda: User.db.has_column(
        table=User.table,
        column="username",
    )
)
def rename_username_to_user_name():
    User.db.rename_column(
        table=User.table,
        old_column="username",
        new_column="user_name",
    )

In the same way, you could delete a column or rename a table.

Additionally, hooks can be used for handling data migrations, which is something that can’t be done automatically. You can define a post-sync hook for copying data from one column to another.

About Model

Model is a minimal Python ORM for MariaDB/MySQL and SQLite.

Here’s how you define a model and its schema using Model:

Python Model usage example

The full schema for a table is stored in code. Model not only serves as an ORM for using your data models in your app, but also as a declarative definition of your database schema for state-based schema updates.

It’s a single source of truth.

You can check out Model on GitHub.