Typorithm · medium

Revision chains, upgrade, downgrade and stamp

pythonsqlalchemyalembicdatabasesindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

Revision chains, upgrade, downgrade and stamp

0%

Concept and contract

Run an actual two-revision Alembic environment and compare schema execution with merely marking a version.

An Alembic revision has an identity, a predecessor and reviewed upgrade and downgrade functions. The script directory describes possible transitions; the database version table records which revision is currently applied. A version label and the physical schema are related evidence, but neither should be blindly substituted for the other.

The complete program writes an owned env.py and two revision files in a temporary directory. It supplies an already owned connection through Config.attributes, so no production URL or external configuration is needed. The first revision creates roles; the second adds its nullable label column.

Mechanism and worked example

command.upgrade(config, head) executes the pending revisions and records owned_label.

command.upgrade(config, head) executes the pending revisions and records owned_label. The fixture then inserts a real row and downgrades to owned_base. The second revision’s downgrade removes only label, leaving the original id and score table definition and its row intact.

The final report compares the head observed after upgrade with the actual current revision after downgrade, inspected columns and stored values. The env.py shown in the complete source consumes the supplied connection. Setting a connection attribute would not work by magic if the environment ignored that attribute and opened some other database.

Complete worked example

python

from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy import create_engine, inspect, text
from alembic import command
from alembic.config import Config
from alembic.migration import MigrationContext

ENV_SOURCE = """\
from alembic import context
connection = context.config.attributes["connection"]
context.configure(connection=connection, target_metadata=None)
with context.begin_transaction():
    context.run_migrations()
"""
BASE_SOURCE = """\
from alembic import op
import sqlalchemy as sa
revision = "owned_base"
down_revision = None

def upgrade():
    op.create_table("roles", sa.Column("id", sa.Integer, primary_key=True),
                    sa.Column("score", sa.Integer, nullable=False))

def downgrade():
    op.drop_table("roles")
"""
LABEL_SOURCE = """\
from alembic import op
import sqlalchemy as sa
revision = "owned_label"
down_revision = "owned_base"

def upgrade():
    op.add_column("roles", sa.Column("label", sa.String(30), nullable=True))

def downgrade():
    with op.batch_alter_table("roles") as batch:
        batch.drop_column("label")
"""

def configure_owned(folder):
    scripts = Path(folder) / "migrations"
    (scripts / "versions").mkdir(parents=True)
    (scripts / "env.py").write_text(ENV_SOURCE, encoding="utf-8")
    (scripts / "versions" / "owned_base.py").write_text(BASE_SOURCE, encoding="utf-8")
    (scripts / "versions" / "owned_label.py").write_text(LABEL_SOURCE, encoding="utf-8")
    config = Config()
    config.set_main_option("script_location", str(scripts))
    return config

def inspect_example(score):
    with TemporaryDirectory() as folder:
        config = configure_owned(folder)
        engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
        try:
            with engine.begin() as connection:
                config.attributes["connection"] = connection
                command.upgrade(config, "head")
                head = MigrationContext.configure(connection).get_current_revision()
                connection.execute(text("INSERT INTO roles (id, score) VALUES (1, :score)"), {"score": score})
                command.downgrade(config, "owned_base")
                current = MigrationContext.configure(connection).get_current_revision()
                present = "roles" in inspect(connection).get_table_names()
                columns = [c["name"] for c in inspect(connection).get_columns("roles")] if present else []
                values = [list(row) for row in connection.execute(text("SELECT id, score FROM roles"))] if present else []
                return {"head": head, "current": current, "columns": columns, "rows": values}
        finally:
            engine.dispose()
Reasoning
command.upgrade(config, head) executes the pending revisions and records owned_label. The fixture then inserts a real row and downgrades to owned_base. The second revision’s downgrade removes only label, leaving the original id and score table definition and its row intact. The final report compares the head observed after upgrade with the actual current revision after downgrade, inspected columns and stored values. The env.py shown in the complete source consumes the supplied connection. Setting a connection attribute would not work by magic if the environment ignored that attribute and opened some other database.
Download the SQLAlchemy and Alembic computer lab ↗

Focused decision: context is in the complete example

python

config.attributes["connection"] = connection
command.upgrade(config, "head")
command.downgrade(config, "owned_base")
Database decision
The environment consumes the owned connection and the revision files execute real DDL. upgrade and downgrade change schema; stamp alone changes the recorded version state.
Download the SQLAlchemy and Alembic computer lab ↗

Counterexamples and common mistakes

The controlled mistake calls stamp instead of upgrade.

The controlled mistake calls stamp instead of upgrade. That can mark the database as being at the head without executing the table-creation changes. The later INSERT fails because the roles table is missing, even though a version label exists. This is why a current revision is useful evidence but not a complete physical-schema check.

Downgrade is not synonymous with restoring a backup. Its Python code does whatever was reviewed; dropping a table or column can remove data. Branches can also have more than one head. A single linear fixture teaches predecessor ordering without claiming that every real repository has one unambiguous latest revision.

Controlled bug: observe the rejected database contract

python

from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy import create_engine, inspect, text
from alembic import command
from alembic.config import Config
from alembic.migration import MigrationContext

ENV_SOURCE = """\
from alembic import context
connection = context.config.attributes["connection"]
context.configure(connection=connection, target_metadata=None)
with context.begin_transaction():
    context.run_migrations()
"""
BASE_SOURCE = """\
from alembic import op
import sqlalchemy as sa
revision = "owned_base"
down_revision = None

def upgrade():
    op.create_table("roles", sa.Column("id", sa.Integer, primary_key=True),
                    sa.Column("score", sa.Integer, nullable=False))

def downgrade():
    op.drop_table("roles")
"""
LABEL_SOURCE = """\
from alembic import op
import sqlalchemy as sa
revision = "owned_label"
down_revision = "owned_base"

def upgrade():
    op.add_column("roles", sa.Column("label", sa.String(30), nullable=True))

def downgrade():
    with op.batch_alter_table("roles") as batch:
        batch.drop_column("label")
"""

def configure_owned(folder):
    scripts = Path(folder) / "migrations"
    (scripts / "versions").mkdir(parents=True)
    (scripts / "env.py").write_text(ENV_SOURCE, encoding="utf-8")
    (scripts / "versions" / "owned_base.py").write_text(BASE_SOURCE, encoding="utf-8")
    (scripts / "versions" / "owned_label.py").write_text(LABEL_SOURCE, encoding="utf-8")
    config = Config()
    config.set_main_option("script_location", str(scripts))
    return config

def inspect_example(score):
    with TemporaryDirectory() as folder:
        config = configure_owned(folder)
        engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
        try:
            with engine.begin() as connection:
                config.attributes["connection"] = connection
                command.stamp(config, "head")
                head = MigrationContext.configure(connection).get_current_revision()
                connection.execute(text("INSERT INTO roles (id, score) VALUES (1, :score)"), {"score": score})
                command.downgrade(config, "owned_base")
                current = MigrationContext.configure(connection).get_current_revision()
                present = "roles" in inspect(connection).get_table_names()
                columns = [c["name"] for c in inspect(connection).get_columns("roles")] if present else []
                values = [list(row) for row in connection.execute(text("SELECT id, score FROM roles"))] if present else []
                return {"head": head, "current": current, "columns": columns, "rows": values}
        finally:
            engine.dispose()
Reasoning
The controlled mistake calls stamp instead of upgrade. That can mark the database as being at the head without executing the table-creation changes. The later INSERT fails because the roles table is missing, even though a version label exists. This is why a current revision is useful evidence but not a complete physical-schema check. Downgrade is not synonymous with restoring a backup. Its Python code does whatever was reviewed; dropping a table or column can remove data. Branches can also have more than one head. A single linear fixture teaches predecessor ordering without claiming that every real repository has one unambiguous latest revision.
Download the SQLAlchemy and Alembic computer lab ↗

Independent practice

First type the explicit connection handoff, upgrade and targeted downgrade commands.

First type the explicit connection handoff, upgrade and targeted downgrade commands. For independent practice downgrade this disposable fixture all the way to base instead of stopping at owned_base. Report that its table and rows no longer exist, while still recording the earlier upgraded head.

Do not replace the final row report with invented empty values while leaving the database unchanged. Let the actual revision commands run and inspect table existence before selecting from it. The task intentionally demonstrates data loss in an owned fixture, making it clear why an unreviewed production downgrade is not an automatic recovery procedure.

Step 1: Downgrade the disposable fixture all the way to base and report that its table and rows no longer exist. Step 2: Inspect table existence before reading final rows.

Practice starter: solve before reading the solution

python

from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy import create_engine, inspect, text
from alembic import command
from alembic.config import Config
from alembic.migration import MigrationContext

ENV_SOURCE = """\
from alembic import context
connection = context.config.attributes["connection"]
context.configure(connection=connection, target_metadata=None)
with context.begin_transaction():
    context.run_migrations()
"""
BASE_SOURCE = """\
from alembic import op
import sqlalchemy as sa
revision = "owned_base"
down_revision = None

def upgrade():
    op.create_table("roles", sa.Column("id", sa.Integer, primary_key=True),
                    sa.Column("score", sa.Integer, nullable=False))

def downgrade():
    op.drop_table("roles")
"""
LABEL_SOURCE = """\
from alembic import op
import sqlalchemy as sa
revision = "owned_label"
down_revision = "owned_base"

def upgrade():
    op.add_column("roles", sa.Column("label", sa.String(30), nullable=True))

def downgrade():
    with op.batch_alter_table("roles") as batch:
        batch.drop_column("label")
"""

def configure_owned(folder):
    scripts = Path(folder) / "migrations"
    (scripts / "versions").mkdir(parents=True)
    (scripts / "env.py").write_text(ENV_SOURCE, encoding="utf-8")
    (scripts / "versions" / "owned_base.py").write_text(BASE_SOURCE, encoding="utf-8")
    (scripts / "versions" / "owned_label.py").write_text(LABEL_SOURCE, encoding="utf-8")
    config = Config()
    config.set_main_option("script_location", str(scripts))
    return config

def inspect_example(score):
    with TemporaryDirectory() as folder:
        config = configure_owned(folder)
        engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
        try:
            with engine.begin() as connection:
                config.attributes["connection"] = connection
                command.upgrade(config, "head")
                head = MigrationContext.configure(connection).get_current_revision()
                connection.execute(text("INSERT INTO roles (id, score) VALUES (1, :score)"), {"score": score})
                command.downgrade(config, "owned_base")
                current = MigrationContext.configure(connection).get_current_revision()
                present = "roles" in inspect(connection).get_table_names()
                columns = [c["name"] for c in inspect(connection).get_columns("roles")] if present else []
                values = [list(row) for row in connection.execute(text("SELECT id, score FROM roles"))] if present else []
                return {"head": head, "current": current, "columns": columns, "rows": values}
        finally:
            engine.dispose()

# Task: Downgrade the disposable fixture all the way to base and report that its table and rows no longer exist.
Reasoning
Downgrade the disposable fixture all the way to base and report that its table and rows no longer exist.
Download the SQLAlchemy and Alembic computer lab ↗
  • Hint 1: Change the downgrade target, not the observed upgraded head.
  • Hint 2: The base target runs the first revision’s drop-table downgrade too.

Solution, tests and trade-offs

The solution reaches an empty revision state and finds no roles table.

The solution reaches an empty revision state and finds no roles table. Its final columns and rows are therefore empty, while the previously observed upgraded head remains owned_label. The cases vary the original score, ensuring that the worked targeted downgrade preserves values but the full downgrade actually removes them.

The lab runs real Alembic commands with actual revision files. It does not merely compare strings representing an imagined version sequence. Any unexpected command, schema or row failure rejects the example. Existing teaching content and application progress are unrelated to this fixture’s synthetic version table and remain outside its scope.

Practice solution and acceptance checks

python

from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy import create_engine, inspect, text
from alembic import command
from alembic.config import Config
from alembic.migration import MigrationContext

ENV_SOURCE = """\
from alembic import context
connection = context.config.attributes["connection"]
context.configure(connection=connection, target_metadata=None)
with context.begin_transaction():
    context.run_migrations()
"""
BASE_SOURCE = """\
from alembic import op
import sqlalchemy as sa
revision = "owned_base"
down_revision = None

def upgrade():
    op.create_table("roles", sa.Column("id", sa.Integer, primary_key=True),
                    sa.Column("score", sa.Integer, nullable=False))

def downgrade():
    op.drop_table("roles")
"""
LABEL_SOURCE = """\
from alembic import op
import sqlalchemy as sa
revision = "owned_label"
down_revision = "owned_base"

def upgrade():
    op.add_column("roles", sa.Column("label", sa.String(30), nullable=True))

def downgrade():
    with op.batch_alter_table("roles") as batch:
        batch.drop_column("label")
"""

def configure_owned(folder):
    scripts = Path(folder) / "migrations"
    (scripts / "versions").mkdir(parents=True)
    (scripts / "env.py").write_text(ENV_SOURCE, encoding="utf-8")
    (scripts / "versions" / "owned_base.py").write_text(BASE_SOURCE, encoding="utf-8")
    (scripts / "versions" / "owned_label.py").write_text(LABEL_SOURCE, encoding="utf-8")
    config = Config()
    config.set_main_option("script_location", str(scripts))
    return config

def inspect_example(score):
    with TemporaryDirectory() as folder:
        config = configure_owned(folder)
        engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
        try:
            with engine.begin() as connection:
                config.attributes["connection"] = connection
                command.upgrade(config, "head")
                head = MigrationContext.configure(connection).get_current_revision()
                connection.execute(text("INSERT INTO roles (id, score) VALUES (1, :score)"), {"score": score})
                command.downgrade(config, "base")
                current = MigrationContext.configure(connection).get_current_revision()
                present = "roles" in inspect(connection).get_table_names()
                columns = [c["name"] for c in inspect(connection).get_columns("roles")] if present else []
                values = [list(row) for row in connection.execute(text("SELECT id, score FROM roles"))] if present else []
                return {"head": head, "current": current, "columns": columns, "rows": values}
        finally:
            engine.dispose()
Reasoning
The solution reaches an empty revision state and finds no roles table. Its final columns and rows are therefore empty, while the previously observed upgraded head remains owned_label. The cases vary the original score, ensuring that the worked targeted downgrade preserves values but the full downgrade actually removes them. The lab runs real Alembic commands with actual revision files. It does not merely compare strings representing an imagined version sequence. Any unexpected command, schema or row failure rejects the example. Existing teaching content and application progress are unrelated to this fixture’s synthetic version table and remain outside its scope.
Download the SQLAlchemy and Alembic computer lab ↗

Interview reasoning and next step

Trace the chain None to owned_base to owned_label and back, naming which function runs at each transition.

Trace the chain None to owned_base to owned_label and back, naming which function runs at each transition. Explain why stamp does not execute the missing upgrades and why a downgrade can destroy data even if its command succeeds. Describe both revision-state and schema-state evidence you would inspect.

Alembic can check applied heads, but matching a head still does not validate every row or every business invariant. Next, produce schema-difference candidates from metadata, review their limitations, and deliberately apply a particular additive change before checking whether the remaining diff is empty.

Optional stretch — after the core task works: Draw a two-head revision graph and distinguish reviewing a merge revision from blindly choosing a branch.

Computer practice: https://applydjinn.com/typorithm/sqlalchemy-lab-20261107.zip. Install the pinned requirements in an isolated Python 3.13 environment and run verify.py. Edit the working exercise baseline, then run --practice sa-revisions. The lab uses disposable SQLite databases and actual SQLAlchemy/Alembic calls; it does not connect to application accounts, production databases or paid services. The app includes prepared results and explained questions without an editor or Python runtime.

Run an actual two-revision Alembic environment and compare schema execution with merely marking a version.