Fixing ALTER TABLE errors with Flask-Migrate and SQLite

Posted by
on under

If you've done any work with SQLite databases you surely know that this database is very limited in terms of making changes to the database schema. When working with a migration framework such as Flask-Migrate, it is common to end up with migration scripts that fail to upgrade or downgrade just because they need to remove or modify a column in a table, something that SQLite does not support.

In this article I'm going to discuss this limitation of the SQLite database, and show you a workaround that is specific to Flask-Migrate and Alembic.

SQLite's ALTER TABLE Implementation

Changes that you make to the fields in your model or to the constraints associated with them will end up as an ALTER TABLE statement sent to the database. If you are using MySQL, Postgres or most other database servers besides SQLite, this isn't a problem. With SQLite, however, the ALTER TABLE command only supports adding or renaming columns. Any other change to columns or constraints is going to be rejected with an error.

You can test this yourself very easily. Take any Flask-SQLAlchemy application (you can use one of mine) and after making sure your database is up to date, remove or comment out a column in one of the models. Then generate a migration:

(venv) $ flask db migrate -m "remove a column"

If you open the generated migration script everything will look correct. Below you can see the migration that was generated after I removed a column named about_me from the User model:

"""remove a column

Revision ID: ec813e760b53
Revises: 834b1a697901
Create Date: 2020-07-19 18:18:44.066766

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'ec813e760b53'
down_revision = '834b1a697901'
branch_labels = None
depends_on = None


def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_column('user', 'about_me')
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.add_column('user', sa.Column('about_me', sa.VARCHAR(length=140), nullable=True))
    # ### end Alembic commands ###

The problem occurs when you try to upgrade the database with this migration:

(venv) $ flask db upgrade
[2020-07-19 18:21:14,268] INFO in __init__: Microblog startup
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 834b1a697901 -> ec813e760b53, remove a column
Traceback (most recent call last):
  File "/Users/mgrinberg/Documents/dev/python/microblog/venv/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 1283, in _execute_context
    self.dialect.do_execute(
  File "/Users/mgrinberg/Documents/dev/python/microblog/venv/lib/python3.8/site-packages/sqlalchemy/engine/default.py", line 590, in do_execute
    cursor.execute(statement, parameters)
sqlite3.OperationalError: near "DROP": syntax error

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
[ ... removed a long list of uninteresting stack frames ... ]
sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) near "DROP": syntax error
[SQL: ALTER TABLE user DROP COLUMN about_me]
(Background on this error at: http://sqlalche.me/e/e3q8)

If you tried this on your database, delete the broken migration script before continuing. You will learn how to generate migrations that work better in the next section.

Using Batch Mode

Unfortunately there is no simple way to perform these operations that SQLite has not implemented. The only way to do this is to generate a brand new table with the new schema and copy all the data. Alembic includes support for migrating a table in this way with a feature called "batch mode".

You can enable batch mode in Flask-Migrate right when you initialize the extension. If you use the direct method of initialization:

migrate = Migrate(app, db, render_as_batch=True)

If you use the two-step initialization:

migrate = Migrate()

def create_app():
    # ...
    migrate.init_app(app, db, render_as_batch=True)
    # ...

Now that you have batch mode enabled, try to generate the migration again:

(venv) $ flask db migrate -m "remove a column"

Nothing appears to be different, but if you look at the new migration script, you will see differences:

"""remove a column

Revision ID: adfac8a1b2ee
Revises: 834b1a697901
Create Date: 2020-07-19 18:32:27.782197

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'adfac8a1b2ee'
down_revision = '834b1a697901'
branch_labels = None
depends_on = None


def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    with op.batch_alter_table('user', schema=None) as batch_op:
        batch_op.drop_column('about_me')

    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    with op.batch_alter_table('user', schema=None) as batch_op:
        batch_op.add_column(sa.Column('about_me', sa.VARCHAR(length=140), nullable=True))

    # ### end Alembic commands ###

Complications

There are situations in which batch mode alone does not solve upgrade errors.

A nasty type of issue occurs when the ALTER TABLE error occurs in the middle of a migration, after some operations were already applied. This could leave your database in an inconsistent state, where some changes from the migration script have been applied, but because of the error the version information is still pointing to the previous migration.

To unblock a database after a partial migration was applied I follow these steps:

  • Determine which of the operations were applied.
  • Delete everything from the upgrade() function.
  • Edit the downgrade() function so that it only contains the reverse of the operations that were applied to your database.
  • Run flask db upgrade. This is going to succeed because now you are running an empty upgrade. The database version will be upgraded.
  • Run flask db downgrade. This will undo those partial changes that were applied earlier, and reset the database version back to the last good state.
  • Delete the migration script and try again with batch mode enabled.

Another common issue occurs when your table has unnamed constraints, which the batch mode process can't delete or modify because there is no way to refer to them by name. The Alembic documentation has some information on how to deal with unnamed constraints when using batch mode.

Conclusion

I hope this was a useful tip that can help you improve your migration workflow. Have you had issues with SQLite migrations that are not covered in this article? Let me know below in the comments.

Become a Patron!

Hello, and thank you for visiting my blog! If you enjoyed this article, please consider supporting my work on this blog on Patreon!

24 comments
  • #1 Matt said

    Thanks Miguel! And that Flask-Migrate batch configuration was the option all the time? Hours of rewriting migrate scripts with SQLite db...

  • #2 David S said

    Thank you for this excellent piece of information! I had been struggling with this issue in the back of my mind off and on for awhile in a "for fun" project I've been working on while learning Flask. I was glad to find your post because it directly addressed my issue and provided a solution!

  • #3 Willow said

    Thank you so much for this! Such a simple fix :)

  • #4 Robert said

    This worked really well. Is there a reason to revert back to non-batch mode? Or is this the ideal default for any migration?

  • #5 cog said

    Fantastic! Thank you!

  • #6 Miguel Grinberg said

    @Robert: I really haven't found any issues with batch mode being enabled.

  • #7 Sara said

    Miguel, thanks for this - and for the whole microblog tutorial. I'm now working on my own project - and running into trouble when adding a new Foreign Key to an existing table. I switched to using batch mode as described here, but then get an error of "Constraint must have a name" when it tries to execute the command: batch_op.create_foreign_key(None, 'tourny', ['tourny_id'], ['id']). This foreign key I am adding will be the second FK in the table... that is not a problem is it?
    I tried reading the Alembic documentation link you included at the end for unnamed constraints... but admittedly didn't understand how to apply t. Do you have any add'l advice for what to read to solve this? Many thanks.

  • #8 Miguel Grinberg said

    @Sara: That None first argument is the foreign key name. Sometimes Alembic does not assign a name there. You should edit that and give the key a unique name. Also put the name in the corresponding delete call in the downgrade method.

  • #9 Gitau Harrison said

    Thank you.

    I notice that any further attempts to upgrade the database whenever I add a new field and create a relationship between two existing tables throws the error ValueError: Constraint must have a name

    For example, upgrading these two tables after creating a relationship between them causes the said error:

    class Admin(db.Model):
        # ...
        comments = db.relationship('Comment', backref='admin', lazy='dynamic')
    
    class Comment(db.Model):
        # ...
        admin_id = db.Column(db.Integer, db.ForeingKey='admin.id')
    

    migrate instance has render_as_batch=True

    When I check the generate migration script, I notice that there is depends_on = None. The upgrade() function, the create_foreign_key() call uses a constraint name which, I think, at the moment is set to None. Hence, the error.

    """comment table admin_id relationship
    
    Revision ID: b2a878e994e9
    Revises: d27105d2cdc3
    Create Date: 2021-04-21 04:14:14.607153
    
    """
    from alembic import op
    import sqlalchemy as sa
    
    
    # revision identifiers, used by Alembic.
    revision = 'b2a878e994e9'
    down_revision = 'd27105d2cdc3'
    branch_labels = None
    depends_on = None
    
    
    def upgrade():
        # ### commands auto generated by Alembic - please adjust! ###
        with op.batch_alter_table('comment', schema=None) as batch_op:
            batch_op.add_column(sa.Column('admin_id', sa.Integer(), nullable=True))
            batch_op.create_foreign_key(None, 'admin', ['admin_id'], ['id'])
    
        # ### end Alembic commands ###
    
    
    def downgrade():
        # ### commands auto generated by Alembic - please adjust! ###
        with op.batch_alter_table('comment', schema=None) as batch_op:
            batch_op.drop_constraint(None, type_='foreignkey')
            batch_op.drop_column('admin_id')
    
        # ### end Alembic commands ###
    
    

    I know you have offered a possible solution to this issue before as seen in this SO question.

    Question #1:
    Does it mean that in the meantime, since Alembic does not offer automatic naming for this, I have to manually pass a name to create_foreign_key()?

    Question #2:
    If I pass the name create_foreign_key('admin') to replace create_foreign_key(None), would it work just fine or it has to be something like create_foreign_key('fk_admin') as seen here in the alembic doc?

    Question #3:
    Replacing the None constraint name: is it looked at from the Admin side or the Comment side? My example above adds "admin" without much thought.

    Also, could you please explain what naming_convention is in this method. It offers it as a solution, but I do not understand what is going on there.

  • #10 Miguel Grinberg said

    @Gitau: I think this can be handled by defining naming conventions for your SQLAlchemy database. See this SO question for some details on how to do this. Once this is done your constraints are going to be given a name instead of None.

  • #11 Danny said

    Fantastic, thank you! Given how influential the Flask Mega Tutorial continues to be, you may consider adding render_as_batch=True to the tutorial. A couple sentences about SQLite's limitations and the batch mode's workaround would suffice. Thank you for all that you share!

  • #12 Jonas Carvalho said

    Hello Miguel.

    This tutorial helped me a lot.

    Thank you.

  • #13 Alex said

    Hey Miguel thanks for your impact but I am currently having an error raised on my microblog app. It says "sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: user
    ". Please how do I solve it?

  • #14 Miguel Grinberg said

    @Alex: you haven't created your database tables. Try running flask db upgrade to get the tables created (assuming you already have your database migrations created).

  • #15 Hongyuan Qiu said

    Hi Miguel, thanks for this excellent tutorial! It helped me a lot!

  • #16 Patrick Yoder said

    Thank you so much! This helped me a lot.

  • #17 Bogus said

    Hello Miguel, Thank you very much. But I have a problem. My production database is using PostgreSQL and I had to make some changes with ALTER TABLE. This is what the problematic migration file looks like

    def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.alter_column('trade', 'trading_volume',
    existing_type=sa.INTEGER(),
    type_=sa.Float(),
    existing_nullable=True)
    op.alter_column('trade', 'fee',
    existing_type=sa.INTEGER(),
    type_=sa.Float(),
    existing_nullable=True)
    # ### end Alembic commands ###

    def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.alter_column('trade', 'fee',
    existing_type=sa.Float(),
    type_=sa.INTEGER(),
    existing_nullable=True)
    op.alter_column('trade', 'trading_volume',
    existing_type=sa.Float(),
    type_=sa.INTEGER(),
    existing_nullable=True)
    # ### end Alembic commands ###

    Because of this file, I cannot run 'flask db upgrade' locally with sqlite... Could you provide some suggestions so that my migration files work for both Postgres in prod and sqlite locally?

    I can remove this file, enable batch-mode and run 'flask db migrate' again which generates a migration file like below, which also works locally. But I am not sure if I can push this migration file for prod

    def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    with op.batch_alter_table('trade', schema=None) as batch_op:
    batch_op.alter_column('trading_volume',
    existing_type=sa.INTEGER(),
    type_=sa.Float(),
    existing_nullable=True)
    batch_op.alter_column('fee',
    existing_type=sa.INTEGER(),
    type_=sa.Float(),
    existing_nullable=True)

    # ### end Alembic commands ###
    

    def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    with op.batch_alter_table('trade', schema=None) as batch_op:
    batch_op.alter_column('fee',
    existing_type=sa.Float(),
    type_=sa.INTEGER(),
    existing_nullable=True)
    batch_op.alter_column('trading_volume',
    existing_type=sa.Float(),
    type_=sa.INTEGER(),
    existing_nullable=True)

    # ### end Alembic commands ###
    
  • #18 Miguel Grinberg said

    @Bogus: if you can make your migration work in batch mode, then that is the best solution. Batch mode has no effect on Postgres.

  • #19 Bogus said

    But if I make it work in batch mode by removing the migration file that has op.alter_column, it may work locally with sqlite but won't it be a problem when I want to run migration against Postgres since it won't know that there has been some altering table?(because the migration file has been removed)

  • #20 Bogus said

    I am a little worried because, as you mentioned, if batch mode has no effect on Postgres, I may be able to make it work locally with SQlite by removing the migration file that has 'Alter table' but this new series of migration files may not work on Postgres in Prod.. because it does not know, since the file is gone, it needs to alter table.

  • #21 Miguel Grinberg said

    @Bogus: I didn't mean to say that batch mode migrations do nothing, just that the batch mode is ignored for databases that do not need it. The batch mode migration will also have an "alter column" instruction. For SQLite it will be executed as a copy to a new table. For Postgres it will still be done with an ALTER TABLE SQL command.

  • #22 kris said

    I'm having troubles with the batch mode. I set it to True but when i use the "flask -m migrate" it generates new script but it still doesn't do anything. You have any idea why it still looks disabled?

    Manually editing the upgrade() with batch_alter_table() works, but I thought it should do it automatically.

  • #23 Miguel Grinberg said

    @kris: It's impossible for me to diagnose the problem without getting details. My guess is that you are not enabling batch mode correctly, you should not need to edit the batch mode changes by hand.

  • #24 Manuel said

    Thanx Miguel.
    I am learning Flask Web Dev from your book. In the process, I am working on different computers via version control and constantly get errors when migrating the database. Finally, I deleted the whole folder 'migrations' and initialized it again. Works for the test database for now.

    Best,
    Manuel

Leave a Comment