Async Alembic Notes
Notes on transitioning from a synchronous setup using fastapi, sqlalchemy, and alembic with a sqlite database to a setup that uses fastapi, sqlalchemy, and alembic, but completely async with a mysql db (using aiomysql)
I will only concentrate on the alembic setup and assume a mysql user with sufficient privileges.
- install aiomysql
pip install aiomysql
- alembic’s
env.py
Assuming a transition from the basicenv.py
that is generated by runningalembic init
without any template selected, the file has to be adapted to look something like this. All changes taken from the alembic docs - using asyncio with alembic1 and basically affects “onlyrun_migrations_online
will need to be updated”.
1import asyncio
2
3from sqlalchemy.ext.asyncio import async_engine_from_config
4
5from logging.config import fileConfig
6
7from sqlalchemy import engine_from_config
8from sqlalchemy import pool
9
10from alembic import context
11
12# import Base from database
13from src.data.database import Base
14
15# import all Classes from the model files
16from src.models import Class1, Class2
17
18# this is the Alembic Config object, which provides
19# access to the values within the .ini file in use.
20config = context.config
21
22# Interpret the config file for Python logging.
23# This line sets up loggers basically.
24if config.config_file_name is not None:
25 fileConfig(config.config_file_name)
26
27# add your model's MetaData object here
28# for 'autogenerate' support
29# from myapp import mymodel
30# target_metadata = mymodel.Base.metadata
31target_metadata = Base.metadata
32
33# other values from the config, defined by the needs of env.py,
34# can be acquired:
35# my_important_option = config.get_main_option("my_important_option")
36# ... etc.
37
38
39def run_migrations_offline() -> None:
40 """Run migrations in 'offline' mode.
41
42 This configures the context with just a URL
43 and not an Engine, though an Engine is acceptable
44 here as well. By skipping the Engine creation
45 we don't even need a DBAPI to be available.
46
47 Calls to context.execute() here emit the given string to the
48 script output.
49
50 """
51 url = config.get_main_option("sqlalchemy.url")
52 context.configure(
53 url=url,
54 target_metadata=target_metadata,
55 literal_binds=True,
56 dialect_opts={"paramstyle": "named"},
57 )
58
59 with context.begin_transaction():
60 context.run_migrations()
61
62
63
64def do_run_migrations(connection):
65 context.configure(connection=connection, target_metadata=target_metadata)
66
67 with context.begin_transaction():
68 context.run_migrations()
69
70
71async def run_async_migrations():
72 """In this scenario we need to create an Engine
73 and associate a connection with the context.
74
75 """
76
77 connectable = async_engine_from_config(
78 config.get_section(config.config_ini_section),
79 prefix="sqlalchemy.",
80 poolclass=pool.NullPool,
81 )
82
83 async with connectable.connect() as connection:
84 await connection.run_sync(do_run_migrations)
85
86 await connectable.dispose()
87
88
89def run_migrations_online():
90 """Run migrations in 'online' mode."""
91
92 asyncio.run(run_async_migrations())
93
94if context.is_offline_mode():
95 run_migrations_offline()
96else:
97 run_migrations_online()
- alembic’s
alembic.ini
This value also holds a variable pointing to your database and the driver it should use. Therefore you have to search for a variable calledsqlalchemy.url
around line 64 and use a connection string that reflects your chosen database type and driver:
sqlalchemy.url = mysql+aiomysql://<db user name>:<db user password>@<hostname>:<port>/<database>
- connection string and SQLalchemy engine
Assuming a set variableSQLALCHEMY_DATABASE_URL
in some file likedatabase.py
, that references a db and driver that is not the desired mysql/async-driver combination, it is necessary to update this variable to use a mysql db and using the aiomysql driver:
SQLALCHEMY_DATABASE_URL = "mysql+aiomysql://<db user name>:<db user password>@<hostname>:<port>/<database>"
Further, if you used a (sync) sqlite database, it is necessary to update the SQLalchemy engine creation to an async engine (and ommitting the recommended attribute for sqlite databases: connect_args={"check_same_thread": False}
):
1from sqlalchemy.ext.asyncio import create_async_engine
2
3engine = create_async_engine(SQLALCHEMY_DATABASE_URL)
- update
models.py
If the tables and columns inmodels.py
use some dialect specific instruction, it is necessary to update those to match, in this case, the MYSQL requirements. e.g. sqlite does not enforce length onSTRING
columns, MYSQL interpretsSTRING
asVARCHAR
and wants a maximum length on those columns:
columnname = Column(String(**200**), index=True)
This change needs to be added, I suppose, too to already generated alembic revisions2.
setup environments for alembic
You probably want to separate environments, speak “development” and “production” or “whatever-you-want”. I’ll leave thedatabase.py
part up to you to figure out, since it should be pretty straightforward. For alembic we have to go back toalembic.ini
andenv.py
. There seem to be be various opinions on how to solve this and various ways to solve the distinction between environments.
One way described in alembic’s cookbook as run multiple alembic environments from one ini file is to have various alembic environments in (one)alembic.ini
. This however still implies having database credentials in clear text in this file and having to take care at alembic runtime to specify the desired alembic environment with the--name
flag.
A question asked at stack overflow: is it possible to store the alembic connect string outside of alembic ini provides two possible solutions that don’t include database credentials in clear text and pulls the variables from environment variables. The first proposal seems to leverage an internal alembic API, which is not recommended to do. The second proposal describes interpolation of strings inalembic.ini
with variables pulled inenv.py
from environment. This looks good so far, but does not account for changing environment between “development” and “production”.
So I clumped together a solution that:- makes changing between “development” and “production” easy
- does not store database credentials in neither
env.py
noralembic.ini
- let’s me run
alembic
commands without specifying additional flags like--name
or--config
I base my solution on having the url for the development database in
alembic.ini
’ssqlalchemy.url
and only overwrite this value, when running alembic in production mode. All variables are stored in a single place, in my case fileconfig.env
.
alembic.ini
1# everything untouched except sqlalchemy.url
2(...)
3# specify the location of your development database, in my case a sqlite db using the async aiosqlite driver
4sqlalchemy.url = sqlite+aiosqlite:///src/sql_app.db
5
6(...)
env.py
based on the already updated version above, some changes for the connection in offline
and online
are necessary. This is again the complete file, updated and marked with changed/new lines.
1import asyncio
2
3from sqlalchemy.ext.asyncio import async_engine_from_config
4
5from logging.config import fileConfig
6
7from sqlalchemy import engine_from_config
8from sqlalchemy import pool
9
10from alembic import context
11
12# import Base from database
13from src.data.database import Base
14
15# import all Classes from the model files
16from src.models import Class1, Class2
17
18# import the config.env file which holds variables for the environment and the database url parts
19from src.config import get_config
20
21# import the actual values from config.env
22envconfig = get_config()
23
24# this is the Alembic Config object, which provides
25# access to the values within the .ini file in use.
26config = context.config
27
28# Interpret the config file for Python logging.
29# This line sets up loggers basically.
30if config.config_file_name is not None:
31 fileConfig(config.config_file_name)
32
33# add your model's MetaData object here
34# for 'autogenerate' support
35# from myapp import mymodel
36# target_metadata = mymodel.Base.metadata
37target_metadata = Base.metadata
38
39# other values from the config, defined by the needs of env.py,
40# can be acquired:
41# my_important_option = config.get_main_option("my_important_option")
42# ... etc.
43
44
45def run_migrations_offline() -> None:
46 """Run migrations in 'offline' mode.
47
48 This configures the context with just a URL
49 and not an Engine, though an Engine is acceptable
50 here as well. By skipping the Engine creation
51 we don't even need a DBAPI to be available.
52
53 Calls to context.execute() here emit the given string to the
54 script output.
55
56 """
57 if envconfig.ENVIRONMENT == "development":
58 url = config.get_main_option("sqlalchemy.url")
59 elif envconfig.ENVIRONMENT == "production":
60 url = f"mysql+aiomysql://{envconfig.DB_USER}:{envconfig.DB_PASSWORD}@{envconfig.DB_HOST}:{envconfig.DB_PORT}/{envconfig.DB_DATABASE}"
61
62 context.configure(
63 url=url,
64 target_metadata=target_metadata,
65 literal_binds=True,
66 dialect_opts={"paramstyle": "named"},
67 )
68
69 with context.begin_transaction():
70 context.run_migrations()
71
72
73def do_run_migrations(connection):
74 context.configure(connection=connection, target_metadata=target_metadata)
75
76 with context.begin_transaction():
77 context.run_migrations()
78
79
80async def run_async_migrations():
81 """In this scenario we need to create an Engine
82 and associate a connection with the context.
83
84 """
85 if envconfig.ENVIRONMENT == "development":
86 connectable = async_engine_from_config(
87 config.get_section(config.config_ini_section),
88 prefix="sqlalchemy.",
89 poolclass=pool.NullPool,
90 )
91 elif envconfig.ENVIRONMENT == "production":
92 async_config = {
93 "sqlalchemy.url": f"mysql+aiomysql://{envconfig.DB_USER}:{envconfig.DB_PASSWORD}@{envconfig.DB_HOST}:{envconfig.DB_PORT}/{envconfig.DB_DATABASE}",
94 "sqlalchemy.poolclass": "NullPool",
95 }
96 connectable = async_engine_from_config(
97 async_config,
98 prefix="sqlalchemy.",
99 poolclass=pool.NullPool,
100 )
101
102 async with connectable.connect() as connection:
103 await connection.run_sync(do_run_migrations)
104
105 await connectable.dispose()
106
107
108def run_migrations_online():
109 """Run migrations in 'online' mode."""
110
111 asyncio.run(run_async_migrations())
112
113
114if context.is_offline_mode():
115 run_migrations_offline()
116else:
117 run_migrations_online()
Some explanations:
Lines 18-22: I use a function to import my config from the file config.env
. These lines here simply import the function that returns my environment variables, which I can, as can be seen further down, be used like envconfig.<VARIABLENAME>
.
Lines 57-60: These lines check, the ENVIRONMENT
variable set in config.env
. In “development” alembic falls back to the variable set in alembic.ini
at sqlalchemy.url
3. If I set ENVIRONMENT
in config.env
to “production”, I use an f-string to create the full connection string (all secrets/variables pulled from config.env
)
Lines 85-100: The section for “development” is pretty straightforward to set up, since it, again, falls back to alembic.ini
’s sqlalchemy.url
. The section for “production” was a bit trickier. I solved it by defining a dictionary, mimicking the structure in alembic.ini
, that holds the same connection string as in the offline
section. This dictionary is passed to async_engine_from_config
, which extracts the safe connection string form it and creates an sqlalchemy async engine, which in turn is used by alembic.
- That’s all folks
I hope I captured all my modifications.
Alembic’s cookbook is delicious by the way! ↩︎
to be honest, I simply scrapped all existing revisions and create a new one, that included those changes. ↩︎
Since I use a sqlite database for development, there is no risk of exposing database credentials. For other databases it would be necessary to infer the credentials like on line 60 and completly ignore/not set
sqlalchemy.url
. ↩︎