Typorithm · medium

Expand, bounded backfill and contract tightening

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

Step 1 of 4 · intro card

Expand, bounded backfill and contract tightening

0%

Concept and contract

Walk stable IDs in small batches before making a newly populated column non-nullable.

Schema evolution often has three conceptually separate stages: add a compatible nullable field, populate existing rows, then tighten the contract after compatible writers and readers are ready. A source-model edit does not perform any of those data transitions by itself.

The owned fixture adds canonical beside existing names, visits rows in ID order two at a time and only then makes the new column non-nullable. The small transaction proves the transformation and ordering. It is not presented as a complete zero-downtime rollout for an arbitrarily large live database.

Mechanism and worked example

The backfill selects rows with ID greater than the last processed ID, orders by that unique key and limits each page.

The backfill selects rows with ID greater than the last processed ID, orders by that unique key and limits each page. Every row gets a normalized value, then last_id advances to the final ID actually observed. chunks counts nonempty processed pages, not every SQL statement.

The worked normalization strips outer whitespace and casefolds text, preserving internal whitespace. For the fixture containing a padded DATA Engineer, QA and Straße, the output includes data engineer, qa and strasse in ID order. The later nullable flag is inspected from the real schema after all rows have been filled.

Complete worked example

python

from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, inspect, select, update
from alembic.migration import MigrationContext
from alembic.operations import Operations

def inspect_example(names):
    if any(not isinstance(name, str) for name in names):
        raise ValueError("names must be strings")
    engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
    try:
        metadata = MetaData()
        roles = Table("roles", metadata, Column("id", Integer, primary_key=True),
                      Column("name", String(100), nullable=False))
        metadata.create_all(engine)
        with engine.begin() as connection:
            if names:
                connection.execute(roles.insert(), [{"id": i, "name": n} for i, n in enumerate(names, 1)])
            operation = Operations(MigrationContext.configure(connection))
            operation.add_column("roles", Column("canonical", String(100), nullable=True))
            expanded = Table("roles", MetaData(), autoload_with=connection)
            last_id, chunks = 0, 0
            while True:
                page = connection.execute(select(expanded.c.id, expanded.c.name)
                    .where(expanded.c.id > last_id).order_by(expanded.c.id).limit(2)).all()
                if not page:
                    break
                for role_id, name in page:
                    canonical = name.strip().casefold()
                    connection.execute(update(expanded).where(expanded.c.id == role_id).values(canonical=canonical))
                last_id = page[-1].id
                chunks += 1
            with operation.batch_alter_table("roles") as batch:
                batch.alter_column("canonical", existing_type=String(100), nullable=False)
            column = next(c for c in inspect(connection).get_columns("roles") if c["name"] == "canonical")
            labels = list(connection.scalars(select(expanded.c.canonical).order_by(expanded.c.id)))
            return {"labels": labels, "nullable": column["nullable"], "chunks": chunks}
    finally:
        engine.dispose()
Reasoning
The backfill selects rows with ID greater than the last processed ID, orders by that unique key and limits each page. Every row gets a normalized value, then last_id advances to the final ID actually observed. chunks counts nonempty processed pages, not every SQL statement. The worked normalization strips outer whitespace and casefolds text, preserving internal whitespace. For the fixture containing a padded DATA Engineer, QA and Straße, the output includes data engineer, qa and strasse in ID order. The later nullable flag is inspected from the real schema after all rows have been filled.
Download the SQLAlchemy and Alembic computer lab ↗

Focused decision: context is in the complete example

python

page = connection.execute(select(expanded.c.id, expanded.c.name)
    .where(expanded.c.id > last_id).order_by(expanded.c.id).limit(2)).all()
last_id = page[-1].id
Database decision
The cursor visits unique IDs in bounded pages. The value UPDATE must actually happen before tightening; a processed-page count cannot substitute for stored-value evidence.
Download the SQLAlchemy and Alembic computer lab ↗

Counterexamples and common mistakes

The controlled mistake walks the pages but omits the actual UPDATE.

The controlled mistake walks the pages but omits the actual UPDATE. Tightening canonical to non-nullable then fails because the existing rows still contain NULL. A progress counter and a cursor moving forward are not evidence that the intended values were written.

Do not use a nonunique cursor key or keep selecting ID >= last_id, which can repeat a boundary row. Do not infer that a local cursor variable makes a crashed production backfill resumable; durable progress, concurrent writes, validation and retry policy require their own design. Text normalization is also a domain decision, not proof that distinct external identities may be merged.

Controlled bug: observe the rejected database contract

python

from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, inspect, select, update
from alembic.migration import MigrationContext
from alembic.operations import Operations

def inspect_example(names):
    if any(not isinstance(name, str) for name in names):
        raise ValueError("names must be strings")
    engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
    try:
        metadata = MetaData()
        roles = Table("roles", metadata, Column("id", Integer, primary_key=True),
                      Column("name", String(100), nullable=False))
        metadata.create_all(engine)
        with engine.begin() as connection:
            if names:
                connection.execute(roles.insert(), [{"id": i, "name": n} for i, n in enumerate(names, 1)])
            operation = Operations(MigrationContext.configure(connection))
            operation.add_column("roles", Column("canonical", String(100), nullable=True))
            expanded = Table("roles", MetaData(), autoload_with=connection)
            last_id, chunks = 0, 0
            while True:
                page = connection.execute(select(expanded.c.id, expanded.c.name)
                    .where(expanded.c.id > last_id).order_by(expanded.c.id).limit(2)).all()
                if not page:
                    break
                for role_id, name in page:
                    canonical = name.strip().casefold()
                    # Missing the row backfill
                last_id = page[-1].id
                chunks += 1
            with operation.batch_alter_table("roles") as batch:
                batch.alter_column("canonical", existing_type=String(100), nullable=False)
            column = next(c for c in inspect(connection).get_columns("roles") if c["name"] == "canonical")
            labels = list(connection.scalars(select(expanded.c.canonical).order_by(expanded.c.id)))
            return {"labels": labels, "nullable": column["nullable"], "chunks": chunks}
    finally:
        engine.dispose()
Reasoning
The controlled mistake walks the pages but omits the actual UPDATE. Tightening canonical to non-nullable then fails because the existing rows still contain NULL. A progress counter and a cursor moving forward are not evidence that the intended values were written. Do not use a nonunique cursor key or keep selecting ID >= last_id, which can repeat a boundary row. Do not infer that a local cursor variable makes a crashed production backfill resumable; durable progress, concurrent writes, validation and retry policy require their own design. Text normalization is also a domain decision, not proof that distinct external identities may be merged.
Download the SQLAlchemy and Alembic computer lab ↗

Independent practice

Type the stable cursor query and update last_id from the returned page, then predict the two-page result for three rows.

Type the stable cursor query and update last_id from the returned page, then predict the two-page result for three rows. For independent practice collapse internal whitespace as well as stripping and casefolding. Keep the same ID traversal, existing names, chunk count and final non-nullable rule.

Change the value transformation at the row boundary rather than reordering the input or replacing it with a set. A name containing a newline and repeated spaces helps distinguish the new normalization from the old one. Reject a non-string input before opening the database, preserving the declared fixture boundary.

Step 1: Collapse internal whitespace while backfilling canonical names; keep the same bounded ID cursor. Step 2: Keep the strict ID cursor, page size and chunk accounting.

Practice starter: solve before reading the solution

python

from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, inspect, select, update
from alembic.migration import MigrationContext
from alembic.operations import Operations

def inspect_example(names):
    if any(not isinstance(name, str) for name in names):
        raise ValueError("names must be strings")
    engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
    try:
        metadata = MetaData()
        roles = Table("roles", metadata, Column("id", Integer, primary_key=True),
                      Column("name", String(100), nullable=False))
        metadata.create_all(engine)
        with engine.begin() as connection:
            if names:
                connection.execute(roles.insert(), [{"id": i, "name": n} for i, n in enumerate(names, 1)])
            operation = Operations(MigrationContext.configure(connection))
            operation.add_column("roles", Column("canonical", String(100), nullable=True))
            expanded = Table("roles", MetaData(), autoload_with=connection)
            last_id, chunks = 0, 0
            while True:
                page = connection.execute(select(expanded.c.id, expanded.c.name)
                    .where(expanded.c.id > last_id).order_by(expanded.c.id).limit(2)).all()
                if not page:
                    break
                for role_id, name in page:
                    canonical = name.strip().casefold()
                    connection.execute(update(expanded).where(expanded.c.id == role_id).values(canonical=canonical))
                last_id = page[-1].id
                chunks += 1
            with operation.batch_alter_table("roles") as batch:
                batch.alter_column("canonical", existing_type=String(100), nullable=False)
            column = next(c for c in inspect(connection).get_columns("roles") if c["name"] == "canonical")
            labels = list(connection.scalars(select(expanded.c.canonical).order_by(expanded.c.id)))
            return {"labels": labels, "nullable": column["nullable"], "chunks": chunks}
    finally:
        engine.dispose()

# Task: Collapse internal whitespace while backfilling canonical names; keep the same bounded ID cursor.
Reasoning
Collapse internal whitespace while backfilling canonical names; keep the same bounded ID cursor.
Download the SQLAlchemy and Alembic computer lab ↗
  • Hint 1: Change only the canonical value computed for each selected row.
  • Hint 2: Use split and a single-space join before casefolding.

Solution, tests and trade-offs

The solution applies split followed by a single-space join before casefolding.

The solution applies split followed by a single-space join before casefolding. It changes data engineer to data engineer and one-newline-two to one two, without changing IDs or the number of processed pages. Acceptance cases include an empty population and an invalid name value, so the new transformation does not weaken those behaviors.

The final query reads stored canonical values; it does not merely return a Python list computed beside an omitted database UPDATE. For a live rollout, additional phases may use an idempotent predicate for unfilled rows, persisted checkpoints and compatible application versions. This lesson deliberately establishes the transformation before claiming those operational properties.

Practice solution and acceptance checks

python

from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, inspect, select, update
from alembic.migration import MigrationContext
from alembic.operations import Operations

def inspect_example(names):
    if any(not isinstance(name, str) for name in names):
        raise ValueError("names must be strings")
    engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
    try:
        metadata = MetaData()
        roles = Table("roles", metadata, Column("id", Integer, primary_key=True),
                      Column("name", String(100), nullable=False))
        metadata.create_all(engine)
        with engine.begin() as connection:
            if names:
                connection.execute(roles.insert(), [{"id": i, "name": n} for i, n in enumerate(names, 1)])
            operation = Operations(MigrationContext.configure(connection))
            operation.add_column("roles", Column("canonical", String(100), nullable=True))
            expanded = Table("roles", MetaData(), autoload_with=connection)
            last_id, chunks = 0, 0
            while True:
                page = connection.execute(select(expanded.c.id, expanded.c.name)
                    .where(expanded.c.id > last_id).order_by(expanded.c.id).limit(2)).all()
                if not page:
                    break
                for role_id, name in page:
                    canonical = " ".join(name.split()).casefold()
                    connection.execute(update(expanded).where(expanded.c.id == role_id).values(canonical=canonical))
                last_id = page[-1].id
                chunks += 1
            with operation.batch_alter_table("roles") as batch:
                batch.alter_column("canonical", existing_type=String(100), nullable=False)
            column = next(c for c in inspect(connection).get_columns("roles") if c["name"] == "canonical")
            labels = list(connection.scalars(select(expanded.c.canonical).order_by(expanded.c.id)))
            return {"labels": labels, "nullable": column["nullable"], "chunks": chunks}
    finally:
        engine.dispose()
Reasoning
The solution applies split followed by a single-space join before casefolding. It changes data engineer to data engineer and one-newline-two to one two, without changing IDs or the number of processed pages. Acceptance cases include an empty population and an invalid name value, so the new transformation does not weaken those behaviors. The final query reads stored canonical values; it does not merely return a Python list computed beside an omitted database UPDATE. For a live rollout, additional phases may use an idempotent predicate for unfilled rows, persisted checkpoints and compatible application versions. This lesson deliberately establishes the transformation before claiming those operational properties.
Download the SQLAlchemy and Alembic computer lab ↗

Interview reasoning and next step

Explain why expansion, data population and tightening have to be reasoned about separately even when a tiny fixture performs them together.

Explain why expansion, data population and tightening have to be reasoned about separately even when a tiny fixture performs them together. Identify a condition that proves every existing row is ready for NOT NULL, and distinguish a processed-page count from actual stored-value verification.

Next, connect a migration to recovery evidence. A downgrade executes its authored operations, while restoring a backup reinstates a specific earlier database state and can discard later writes. The capstone will quiesce only its own temporary fixture, use SQLite’s backup API and inspect both revision history and restored rows.

Optional stretch — after the core task works: Specify a durable checkpoint and a validation query for an owned resumable version of the backfill.

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

Walk stable IDs in small batches before making a newly populated column non-nullable.