VillageSQL is a drop-in replacement for MySQL with extensions.
All examples in this guide work on VillageSQL. Install Now →
Choosing an Async Driver
SQLAlchemy 2.0 ships dialects for two async MySQL drivers:asyncmy and aiomysql. Neither is documented as the official recommendation over the other. asyncmy is a compiled driver written in Cython and ships prebuilt wheels for current Python versions, which is why it is used in this guide:
sqlalchemy instead of sqlalchemy[asyncio] is the first real trap. SQLAlchemy’s async ORM layer bridges async and sync code with the greenlet library, and it is not a dependency of the base package. Skipping the [asyncio] extra produces this error the first time you touch the async engine:
pip install greenlet or, better, always installing SQLAlchemy with the [asyncio] extra so it is pulled in automatically.
The second trap is cryptography. MySQL 8 and VillageSQL default new accounts to the caching_sha2_password authentication plugin, which needs an RSA key exchange to send the password the first time a given account authenticates from a given client library. asyncmy does not bundle an RSA implementation, so a connection that has to do this full handshake fails with:
mysql CLI), the server caches the result and later connections use a faster path that skips the RSA step, which is why this error can appear to come and go depending on what connected to the account most recently. Installing cryptography up front removes the dependency on that cache entirely and is the reliable fix.
Creating the Database and User
Create a dedicated database and application user rather than connecting as root:127.0.0.1, not localhost. The connection string in this guide connects over TCP to 127.0.0.1, so the grant has to match that host; a Unix-socket connection is matched against localhost instead, regardless of the account’s IP-based grants.
Project Layout
requirements.txt:
The Async Engine and Session
app/database.py creates the async engine, a session factory, and the declarative base used by every model:
expire_on_commit=False matters for FastAPI. With the default expire_on_commit=True, every attribute on a committed object is marked stale, and the next read of that attribute issues a fresh SELECT to refresh it. Triggered from outside an await, that refresh fails with:
expire_on_commit=False keeps the already-loaded values on the object usable after commit, which avoids both.
get_session() is a generator function, which lets FastAPI’s Depends() close the session automatically after the request finishes, whether it succeeds or raises.
The Model
app/models.py defines a Post table using SQLAlchemy 2.0’s typed declarative style, Mapped and mapped_column, instead of the legacy Column-only style:
server_default=func.now() pushes the timestamp default to MySQL itself, so it is set correctly even for rows inserted outside the application.
Setting Up Alembic
Initialize Alembic with the async template, which generates anenv.py already wired for an async engine:
alembic.ini to the current working directory regardless of the path given to init, even though the alembic/ scripts folder itself is created at the given path. Running it from the wrong directory leaves a stray alembic.ini somewhere unexpected.
Point alembic.ini at the live database:
alembic/env.py to import the models so autogenerate can see them:
The Pydantic Schemas
app/schemas.py defines the request and response shapes separately from the ORM model:
from_attributes=True lets PostRead build its response directly from a Post ORM instance instead of a dictionary.
The FastAPI Routes
app/main.py wires the session into each route through Depends(), so every request gets its own session rather than sharing one global connection:
Depends() avoids that, since each request gets its own session and its own connection checked out from the pool.
Running and Verifying
Start the app:Frequently Asked Questions
Why does the async engine need a different connection string than a sync one?
The driver name in the connection string tells SQLAlchemy which DBAPI to load. A sync setup usesmysql+pymysql:// or mysql+mysqldb://; an async setup needs a driver built on asyncio, so the string uses mysql+asyncmy:// instead. Using a sync driver name with create_async_engine fails immediately, since that driver has no async interface for SQLAlchemy to call into.
Do I need expire_on_commit=False?
Not strictly, but skip it and every attribute access on a committed object triggers a fresh database round trip to reload it, and that round trip only works if it happens inside an active async context. Depending on exactly where the attribute is read, that surfaces as MissingGreenlet or, once the session has closed, DetachedInstanceError. The safer default for a web application is expire_on_commit=False.
Can I still use Alembic’s synchronous autogenerate workflow?
Yes. The async template’senv.py still runs revision --autogenerate and upgrade from the regular command line; the async part only affects how env.py opens its own connection to inspect and modify the schema. Nothing about the Alembic CLI commands themselves changes.
Troubleshooting
See also
- Using MySQL with ORMs: Django, Rails, and Prisma — the same charset and strict mode configuration applies to SQLAlchemy
- MySQL Connection Pooling — tuning pool size for an async engine under concurrent FastAPI requests

