How to add Alembic migrations to an existing FastAPI + Ormar project

pawamoy

Timothée Mazzucotelli

Posted on October 22, 2022

How to add Alembic migrations to an existing FastAPI + Ormar project

I had trouble adding an ON DELETE CASCADE constraint to foreign keys after they were already created, so here's a small post explaning how I did it. This specific constraint, ON DELETE CASCADE, is not relevant here. This post will help you for any kind of change you want to apply on database already used in production.

First, I knew I was going to need "migrations", because my database was already used in production. I didn't previously add any migration. so I had to create the initial one, reflecting the current state of the database, and a second one to add the cascade delete constraints.

As documented in Ormar's pages themselves, the tool to write migrations is Alembic. Alembic has an "autogenerate" feature that can compare the "metadata" of the database (an SQLAlchemy object in memory) against the actual database to generate a migration file.

I'm using PDM to manage my dependencies, so I added Alembic to them with:

pdm add alembic
Enter fullscreen mode Exit fullscreen mode

I did not add it to development dependencies because I can't SSH into the production machine to run a one-off command. It has to be a production dependency so I can run migrations (upgrade the state of the database) every time the app is deployed and started.

Next, following the Alembic tutorial, I initialized it with:

pdm run alembic init alembic
Enter fullscreen mode Exit fullscreen mode

I then had to modify a bit the alembic.ini and alembic/env.py files it created, to set the URL of the database and to import the right metadata object:

# works locally only, see later how to deal with multiple environments
sqlalchemy.url = sqlite:///db.sqlite
Enter fullscreen mode Exit fullscreen mode
from my_app.db import metadata
target_metadata = metadata
Enter fullscreen mode Exit fullscreen mode

I was now able to generate the initial migration with the following command, making sure I removed the ondelete="CASCADE" arguments from my models' foreign keys:

pdm run alembic revision --autogenerate -m "Initial migration."
Enter fullscreen mode Exit fullscreen mode

The first issue I had was that Alembic was generating a migration that would drop tables instead of creating them. After some investigation, I realized that it was because the metadata object I imported was empty. With Ormar, the metadata object is populated only when loading the models definitions. So instead of importing it from my db module which only instantiated it, I imported it from my models module which uses it to declare my models classes:

from my_app.models import metadata
target_metadata = metadata
Enter fullscreen mode Exit fullscreen mode

I deleted the broken migration, and recreated a correct one:

pdm run alembic revision --autogenerate -m "Initial migration."
Enter fullscreen mode Exit fullscreen mode

To mark this migration as "applied", I ran the Alembic upgrade command:

pdm run alembic upgrade head
Enter fullscreen mode Exit fullscreen mode

Now it was time to add the second migration, the one that adds the "on delete cascade" constraint. I added the ondelete="CASCADE" arguments to my models' foreign keys, add created the second migration:

pdm run alembic revision --autogenerate -m 'Add `ondelete="CASCADE"` to foreign keys.'
Enter fullscreen mode Exit fullscreen mode

But then I got this error:

NotImplementedError: No support for ALTER of constraints in SQLite dialect.
Please refer to the batch mode feature which allows for SQLite migrations using a copy-and-move strategy.
Enter fullscreen mode Exit fullscreen mode

Oh no. Thankfully, a quick search on the internet got me to this SO post and answer: you have to use render_as_batch=True to be able to alter constraint when using an SQLite database. In alembic/env.py:

context.configure(
    connection=connection,
    target_metadata=target_metadata,
    render_as_batch=True
)

# in both run_migrations_offline() and run_migrations_online()!
Enter fullscreen mode Exit fullscreen mode

I ran the previous command again, and it worked. I applied the migration with pdm run alembic upgrade head again.

At this point, I have migrations that I will be able to apply on the production database. But how do I do that?

First, I need to support local and production environments. The way to do it is with an environment variable. The SQLite database URL will be stored in the SQLITE_DB environment variable. All I have to do now is to modify alembic/env.py to use this variable first, and fallback to the value configured in alembic.ini:

def get_url():
    return os.getenv("SQLITE_DB", config.get_main_option("sqlalchemy.url"))

# in run_migrations_offline:
    url = get_url()

# in run_migrations_online:
    configuration = config.get_section(config.config_ini_section)
    configuration["sqlalchemy.url"] = get_url()
    connectable = engine_from_config(
        configuration, prefix="sqlalchemy.", poolclass=pool.NullPool,
    )
Enter fullscreen mode Exit fullscreen mode

I was inspired by this FastAPI example.

Now I need to set this variable for production, for example in an .env file. For me it was in a Helm chart:

...
env:
- name: SQLITE_DB
  value: "sqlite:///different/path/to/db.sqlite"
Enter fullscreen mode Exit fullscreen mode

Finally, I have to call the alembic upgrade head command each time I start the application. This can be done in FastAPI's "startup" event:

import os
from pathlib import Path

import sqlalchemy
from alembic.config import Config
from alembic.commad import upgrade
from fastapi import FastAPI

from my_app.db import metadata, database

app = FastAPI()
app.state.database = database

@app.on_event("startup")
async def startup() -> None:
    """Startup application."""
    db_url = os.getenv(SQLITE_DB)
    db_path = db_url.replace("sqlite://", "")
    if not Path(db_path).exists():
        engine = sqlalchemy.create_engine(db_url)
        metadata.drop_all(engine)
        metadata.create_all(engine)

    upgrade(Config(), "head")

    database = app.state.database
    if not database.is_connected:
        await database.connect()
Enter fullscreen mode Exit fullscreen mode

In my next post,
you will see how to write tests for such setups,
but also how to configure all this in a more robust and elegant way.

💖 💪 🙅 🚩
pawamoy
Timothée Mazzucotelli

Posted on October 22, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related