Rendering custom types in Alembic autogenerate
Add a render_item hook to env.py that renders a TypeDecorator as its implementation type — sa.String(length=255) rather than shop.types.EncryptedString(255) — so a revision depends only on SQLAlchemy and keeps working when the application class is renamed or moved. This guide belongs to autogenerating and reviewing migration scripts.
Quick Answer
Autogenerate renders anything it does not recognise by its repr, which names your module. The generated file then imports application code — and old revisions break when that code moves.
Before — the revision depends on a class in your application:
# alembic/versions/9a3f_add_secret.py
import sqlalchemy as sa
from alembic import op
def upgrade() -> None:
op.add_column("customers", sa.Column("secret", shop.types.EncryptedString(length=255)))
# NameError: name 'shop' is not defined
# ... and after shop.types moves to shop.db.types, every old revision fails the same way.
After — a render_item hook that renders the implementation type:
# alembic/env.py (excerpt)
from alembic import context
from sqlalchemy.types import TypeDecorator
from shop.models import Base
def render_item(type_, obj, autogen_context) -> str | bool:
"""Render custom types as the database type they actually create."""
if type_ == "type" and isinstance(obj, TypeDecorator):
autogen_context.imports.add("import sqlalchemy as sa")
return f"sa.{obj.impl.__class__.__name__}({_impl_args(obj)})"
return False # everything else: Alembic's default rendering
def _impl_args(obj) -> str:
length = getattr(obj.impl, "length", None)
return f"length={length}" if length else ""
def do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=Base.metadata,
compare_type=True,
render_item=render_item,
transaction_per_migration=True,
)
with context.begin_transaction():
context.run_migrations()
The revision now reads sa.Column("secret", sa.String(length=255)), which is exactly what the column is in the database — and what it will still be in five years, whatever happened to the Python class.
Execution Context & Async Workflow Integration
A migration file is a historical record: alembic upgrade head on an empty database replays every revision in order, including ones written years ago. Anything a revision imports therefore has to keep existing, unchanged, for as long as that history is replayable. Application code does not meet that bar — classes get renamed, modules get reorganised, packages get split — which is why a revision that names shop.types.EncryptedString is a latent failure.
Alembic renders three kinds of type differently. Generic SQLAlchemy types become sa.String(length=255). Dialect types become postgresql.JSONB(), and Alembic adds from sqlalchemy.dialects import postgresql to the file's imports automatically. Everything else — a TypeDecorator, a UserDefinedType, a type from a third-party library — is rendered with Python's repr(), which produces the fully qualified class name and no import.
render_item is the hook that intervenes. It is called for each item Alembic is about to write, with a type_ argument saying what kind of item it is ("type", "server_default", "constraint" and so on), the object itself, and an autogen_context that carries the file's import set. Returning a string uses that text verbatim; returning False — not None — falls through to the default rendering, which is what you want for everything you are not handling.
Two other levers exist for narrower cases. user_module_prefix in context.configure() replaces the module path used for non-SQLAlchemy types, so shop.types.EncryptedString can be rendered as shop_types.EncryptedString against an import you add to script.py.mako. And autogen_context.imports is a set of import lines, so a hook that genuinely wants to keep the application class can at least guarantee the file imports it:
def render_item(type_, obj, autogen_context):
if type_ == "type" and obj.__class__.__module__.startswith("shop."):
autogen_context.imports.add("import shop.types")
return False # keep the default repr, but make sure it imports
return False
Under an async env.py none of this changes: render_item is called during autogenerate, which runs inside connection.run_sync() on a synchronous facade, exactly as described in setting up Alembic env.py for asyncpg. The hook is pure text generation and touches no connection at all.
Resolving Warnings, Errors & Common Mistakes
| Exact error or symptom | Root Cause | Production Fix |
|---|---|---|
NameError: name 'shop' is not defined running a migration | The revision named an application class with no import. | render_item to render the implementation type, or add the import. |
ModuleNotFoundError: No module named 'shop.types' on an old revision | The application module moved after the revision was written. | Same fix, and stop generating new revisions that import application code. |
AttributeError: module 'sqlalchemy' has no attribute 'JSONB' | A dialect type rendered with the sa. prefix by a hand-edited file. | postgresql.JSONB() with from sqlalchemy.dialects import postgresql. |
Every autogenerate run proposes modify_type for a custom type | The reflected type never equals the decorator class. | Implement compare_against_backend on the type, or handle it in compare_type. |
| The hook has no effect | It returned None instead of False, or was not passed to context.configure(). | Return False for unhandled items; pass render_item=render_item. |
sa.Column("data", sa.NullType()) in a generated revision | Alembic could not determine the type — usually a type it cannot render. | Handle the type in render_item; never leave NullType in a revision. |
| A third-party type renders with its full module path | Same as a TypeDecorator, with a dependency you do not control. | Render the implementation type; the library is not part of your schema history. |
The modify_type loop is worth understanding because it produces migrations that are worse than useless — they alter a column to the type it already has, and on PostgreSQL that can rewrite the table. The cause is that comparison and rendering are separate concerns: a render_item hook fixes what gets written, and does nothing about what Alembic detects. The type itself should answer the comparison question:
import sqlalchemy as sa
from sqlalchemy.types import TypeDecorator
class EncryptedString(TypeDecorator):
impl = sa.String
cache_ok = True
def compare_against_backend(self, dialect, conn_type) -> bool:
# Stored as VARCHAR of the same length: there is nothing to migrate.
return isinstance(conn_type, sa.String) and conn_type.length == self.impl.length
That method lives on the type, so it applies wherever the type is used and needs no env.py configuration. The alternative — a compare_type callable in env.py returning False for these pairs — is the right place only for project-wide rules that are not properties of any one type, as described in detecting column type and server default changes in autogenerate.
Advanced: Rendering Server Defaults, Constraints and Enums
render_item is not only for types. The type_ argument tells you what is being rendered, and three other values come up in practice.
"server_default" is rendered by repr too, which turns a text("now()") construct into sa.text('now()') — usually fine — and a Python-side callable into something unusable. A hook can normalise them:
from sqlalchemy import text
from sqlalchemy.sql.elements import TextClause
def render_item(type_, obj, autogen_context):
if type_ == "server_default" and isinstance(obj, TextClause):
autogen_context.imports.add("import sqlalchemy as sa")
return f"sa.text({obj.text!r})"
if type_ == "type":
return _render_type(obj, autogen_context)
return False
"type" for enums is the case with the sharpest edge. A postgresql.ENUM renders with create_type=True implied, so a revision that adds a second table using the same enum tries to create the type again and fails with type "order_status" already exists. Rendering it with create_type=False is the fix, and it belongs in the hook so nobody has to remember it:
from sqlalchemy.dialects import postgresql
def _render_type(obj, autogen_context):
if isinstance(obj, postgresql.ENUM):
autogen_context.imports.add("from sqlalchemy.dialects import postgresql")
values = ", ".join(repr(v) for v in obj.enums)
return f"postgresql.ENUM({values}, name={obj.name!r}, create_type=False)"
return False
The enum type then has to be created explicitly, once, in the revision that introduces it — which is the honest arrangement anyway, and the one adding a value to a Postgres enum in Alembic builds on.
A permanent import is sometimes simpler than a hook. script.py.mako is the template every revision is generated from, so adding an import there puts it in every future file:
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
${imports if imports else ""}
That covers dialect types without a hook, and it is worth doing regardless: a template that always imports sa and the dialect removes a whole class of hand-editing. What it cannot do is remove the dependency on application code — only rendering a different type does that.
One rule holds all of this together: a revision should be readable and runnable with nothing but Alembic and SQLAlchemy installed. If a generated file imports your application, the next reorganisation of that application breaks your migration history, and the failure arrives at the worst possible moment — while rebuilding a database from scratch.
Verifying Migrations Replay Without the Application
The property this guide is protecting — that migrations do not depend on application code — is easy to state and easy to lose one revision at a time. Two checks keep it.
A grep in CI. Generated revisions should never mention your top-level package. One line catches every regression, and it is fast enough to run on every commit:
if grep -rn --include='*.py' -E '^(import|from) shop\b' alembic/versions/; then
echo "migrations must not import application code (see render_item in env.py)"
exit 1
fi
A replay from empty. The stronger check builds a database from base to head in a job that has the migrations but exercises nothing else, which is what a disaster-recovery rebuild actually does:
import pytest
from alembic import command
from shop.migrate import alembic_config
@pytest.mark.asyncio
async def test_migrations_replay_from_empty(empty_database_url):
cfg = alembic_config()
cfg.set_main_option("sqlalchemy.url", empty_database_url.replace("%", "%%"))
command.upgrade(cfg, "head") # every revision, in order
Run it against a throwaway database, as in running tests against a Postgres testcontainer, and pair it with alembic check so the end state also matches the models. Together they answer the two questions that matter: can the history be replayed, and does replaying it produce the schema the code expects.
For async projects, remember the call has to reach command.upgrade without nesting event loops — the shared-connection pattern in running Alembic migrations programmatically from async code is what makes that test work inside pytest-asyncio.
There is one case where a revision legitimately needs application code: a data migration that reuses a domain function to transform values. Even there, prefer copying the logic into the revision. A data migration is a statement about the data as it was at that moment, and freezing the logic alongside it is more honest than calling a function whose behaviour will keep changing — a point writing data migrations safely in Alembic makes in more detail.
Frequently Asked Questions
Why does my migration import my application package?
Because Alembic renders unknown types with repr(), which produces the fully qualified class name. Add a render_item hook that renders the underlying database type instead.
What should render_item return for items I do not handle?
False, which means "use the default rendering". Returning None is treated as a value and produces a broken file.
Why does autogenerate keep proposing a type change for my TypeDecorator?
Because the reflected type is the implementation type and never equals the decorator class. Implement compare_against_backend on the type.
Do I need render_item for JSONB or ARRAY?
No. Alembic renders dialect types with a dialect prefix and adds the import itself. A hook is useful for enums, where create_type=False avoids "type already exists" on later revisions.
Related
- Autogenerating and Reviewing Migration Scripts — The parent guide: what autogenerate compares and how to review it.
- Detecting column type and server default changes in autogenerate — The comparison side of the same problem.
- Writing a TypeDecorator for encrypted columns — The custom types this guide renders.
- Adding a value to a Postgres enum in Alembic — Why enums need create_type=False in generated files.