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.

What the generated file depends on Left: autogenerate renders a TypeDecorator by its repr, so the revision names shop.types.EncryptedString and needs that import; if the class is later renamed or moved, every old revision stops importing and the migration history cannot be replayed. Right: a render_item hook renders the implementation type, so the revision depends only on SQLAlchemy. default rendering sa.Column("secret", shop.types.EncryptedString(255)) needs an application import breaks when the class moves render_item hook sa.Column("secret", sa.String(length=255)) depends only on SQLAlchemy replayable from any revision A migration describes a schema, so it should name database types, not application classes.

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.

How a type becomes text Four steps. Autogenerate produces an operation describing the column and its type object. For each item it calls render_item if one is configured, passing the item type, the object and the context. Returning a string uses it verbatim and lets the hook register any import it needs; returning False falls back to Alembic default rendering. The default prefixes SQLAlchemy types with sa, adds a dialect import for dialect types, and uses repr for anything else. autogenerate builds the op column + type object nothing written yet render_item("type", obj, ctx) your hook decides per item return a string used verbatim register imports on the context return False Alembic default rendering The hook is called for types, servers defaults and constraints — the item type argument says which.

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 symptomRoot CauseProduction Fix
NameError: name 'shop' is not defined running a migrationThe 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 revisionThe 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 typeThe reflected type never equals the decorator class.Implement compare_against_backend on the type, or handle it in compare_type.
The hook has no effectIt 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 revisionAlembic 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 pathSame as a TypeDecorator, with a dependency you do not control.Render the implementation type; the library is not part of your schema history.
Four rendering strategies Four tiles. Default repr names the application class and needs its import. user_module_prefix changes the prefix used for non-SQLAlchemy types without changing the class name. A render_item hook returning the implementation type removes the application dependency entirely. Adding the import through autogen_context.imports keeps the class name but guarantees the file imports it. default repr shop.types.Encrypted... application import user_module_prefix chooses the prefix still the app class render_item → impl type sa.String(length=255) no app dependency autogen_context.imports keeps the class adds the import line Choose once per project: mixing strategies makes old revisions inconsistent and hard to replay.

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.

Three kinds of type, three behaviours Three bands. Generic SQLAlchemy types render with the sa prefix and need nothing. Dialect types such as JSONB render with a dialect prefix, and Alembic adds the dialect import itself. TypeDecorators and other custom types render by repr, which names your module and requires either an import in the file or a hook that renders something else. generic types — String, Integer, DateTime rendered as sa.String(length=255); nothing to configure dialect types — JSONB, ARRAY, TSVECTOR rendered as postgresql.JSONB(); Alembic adds "from sqlalchemy.dialects import postgresql" custom types — TypeDecorator, UserDefinedType rendered by repr, naming your module: the file will not import without help

"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.

Comparison as well as rendering Left: the reflected column is a plain VARCHAR while the model says EncryptedString, and with compare_type enabled Alembic reports a type change on every autogenerate run, producing revisions that alter a column to the type it already has. Right: compare_against_backend answers the question once, on the type itself, so the comparison is correct wherever the type is used. no comparison rule database: VARCHAR(255) model: EncryptedString(255) "modify_type" every run no-op migrations pile up compare_against_backend the type answers for itself VARCHAR(255) == EncryptedString(255) autogenerate stays empty one rule, every usage Rendering and comparison are separate hooks: fixing one does not fix the other.

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.