[{"data":1,"prerenderedAt":1228},["ShallowReactive",2],{"page-\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Ftesting-alembic-migrations-in-ci\u002F":3},{"id":4,"title":5,"body":6,"description":1191,"extension":1192,"meta":1193,"navigation":120,"path":1224,"seo":1225,"stem":1226,"__hash__":1227},"content\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Ftesting-alembic-migrations-in-ci\u002Findex.md","Testing Alembic Migrations in CI",{"type":7,"value":8,"toc":1180},"minimark",[9,18,23,58,62,312,477,481,500,503,507,560,564,572,699,706,844,848,851,858,863,868,873,967,974,978,981,988,1109,1116,1120,1126,1132,1138,1142,1171,1176],[10,11,12,13,17],"p",{},"A migration is the one piece of code that runs exactly once, against data nobody on the team can see, at the moment of a deploy. Most test suites never execute it: they build the schema from ",[14,15,16],"code",{},"metadata.create_all()"," for speed, so the migration chain is tested for the first time in production. Four small tests close that gap, and together they run in a few seconds against the same container the rest of the suite already uses.",[19,20,22],"h2",{"id":21},"prerequisites","Prerequisites",[24,25,26,37,45,55],"ul",{},[27,28,29,32,33,36],"li",{},[14,30,31],{},"alembic >= 1.13"," and ",[14,34,35],{},"SQLAlchemy >= 2.0",".",[27,38,39,40,36],{},"A real database engine matching production, started once per session — see ",[41,42,44],"a",{"href":43},"\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002Fstarting-postgres-with-testcontainers-python\u002F","starting Postgres with testcontainers-python",[27,46,47,50,51,54],{},[14,48,49],{},"pytest >= 8.0",", and an ",[14,52,53],{},"alembic.ini"," whose URL can be overridden from a fixture.",[27,56,57],{},"An empty database per migration test, separate from the one the transactional fixture uses, since these tests commit DDL.",[19,59,61],{"id":60},"solution","Solution",[63,64,69],"pre",{"className":65,"code":66,"language":67,"meta":68,"style":68},"language-python shiki shiki-themes github-light github-dark","import pytest\nfrom alembic import command\nfrom alembic.autogenerate import compare_metadata\nfrom alembic.config import Config\nfrom alembic.migration import MigrationContext\nfrom alembic.script import ScriptDirectory\nfrom sqlalchemy import create_engine\n\nfrom myapp.models import Base\n\n\n@pytest.fixture\ndef alembic_cfg(empty_database_dsn):\n    cfg = Config(\"alembic.ini\")\n    cfg.set_main_option(\"sqlalchemy.url\", empty_database_dsn)\n    return cfg\n\n\ndef test_single_head(alembic_cfg):\n    # Two concurrent branches each adding a revision produce two heads;\n    # `alembic upgrade head` then refuses to run at deploy time.\n    heads = ScriptDirectory.from_config(alembic_cfg).get_heads()\n    assert len(heads) == 1, f\"multiple heads: {heads} — add a merge revision\"\n\n\ndef test_models_match_migrations(alembic_cfg, empty_database_dsn):\n    command.upgrade(alembic_cfg, \"head\")\n    engine = create_engine(empty_database_dsn)\n    with engine.connect() as connection:\n        diff = compare_metadata(MigrationContext.configure(connection), Base.metadata)\n    # Any entry here is a model change with no migration, or the reverse.\n    assert diff == [], f\"models and migrations disagree:\\n{diff}\"\n\n\ndef test_every_revision_upgrades_and_downgrades(alembic_cfg):\n    script = ScriptDirectory.from_config(alembic_cfg)\n    revisions = list(reversed(list(script.walk_revisions())))   # base → head\n    for revision in revisions:\n        command.upgrade(alembic_cfg, revision.revision)\n    for revision in reversed(revisions):\n        command.downgrade(alembic_cfg, revision.down_revision or \"base\")\n","python","",[14,70,71,79,85,91,97,103,109,115,122,128,133,138,144,150,156,162,168,173,178,184,190,196,202,208,213,218,224,230,236,242,248,254,260,265,270,276,282,288,294,300,306],{"__ignoreMap":68},[72,73,76],"span",{"class":74,"line":75},"line",1,[72,77,78],{},"import pytest\n",[72,80,82],{"class":74,"line":81},2,[72,83,84],{},"from alembic import command\n",[72,86,88],{"class":74,"line":87},3,[72,89,90],{},"from alembic.autogenerate import compare_metadata\n",[72,92,94],{"class":74,"line":93},4,[72,95,96],{},"from alembic.config import Config\n",[72,98,100],{"class":74,"line":99},5,[72,101,102],{},"from alembic.migration import MigrationContext\n",[72,104,106],{"class":74,"line":105},6,[72,107,108],{},"from alembic.script import ScriptDirectory\n",[72,110,112],{"class":74,"line":111},7,[72,113,114],{},"from sqlalchemy import create_engine\n",[72,116,118],{"class":74,"line":117},8,[72,119,121],{"emptyLinePlaceholder":120},true,"\n",[72,123,125],{"class":74,"line":124},9,[72,126,127],{},"from myapp.models import Base\n",[72,129,131],{"class":74,"line":130},10,[72,132,121],{"emptyLinePlaceholder":120},[72,134,136],{"class":74,"line":135},11,[72,137,121],{"emptyLinePlaceholder":120},[72,139,141],{"class":74,"line":140},12,[72,142,143],{},"@pytest.fixture\n",[72,145,147],{"class":74,"line":146},13,[72,148,149],{},"def alembic_cfg(empty_database_dsn):\n",[72,151,153],{"class":74,"line":152},14,[72,154,155],{},"    cfg = Config(\"alembic.ini\")\n",[72,157,159],{"class":74,"line":158},15,[72,160,161],{},"    cfg.set_main_option(\"sqlalchemy.url\", empty_database_dsn)\n",[72,163,165],{"class":74,"line":164},16,[72,166,167],{},"    return cfg\n",[72,169,171],{"class":74,"line":170},17,[72,172,121],{"emptyLinePlaceholder":120},[72,174,176],{"class":74,"line":175},18,[72,177,121],{"emptyLinePlaceholder":120},[72,179,181],{"class":74,"line":180},19,[72,182,183],{},"def test_single_head(alembic_cfg):\n",[72,185,187],{"class":74,"line":186},20,[72,188,189],{},"    # Two concurrent branches each adding a revision produce two heads;\n",[72,191,193],{"class":74,"line":192},21,[72,194,195],{},"    # `alembic upgrade head` then refuses to run at deploy time.\n",[72,197,199],{"class":74,"line":198},22,[72,200,201],{},"    heads = ScriptDirectory.from_config(alembic_cfg).get_heads()\n",[72,203,205],{"class":74,"line":204},23,[72,206,207],{},"    assert len(heads) == 1, f\"multiple heads: {heads} — add a merge revision\"\n",[72,209,211],{"class":74,"line":210},24,[72,212,121],{"emptyLinePlaceholder":120},[72,214,216],{"class":74,"line":215},25,[72,217,121],{"emptyLinePlaceholder":120},[72,219,221],{"class":74,"line":220},26,[72,222,223],{},"def test_models_match_migrations(alembic_cfg, empty_database_dsn):\n",[72,225,227],{"class":74,"line":226},27,[72,228,229],{},"    command.upgrade(alembic_cfg, \"head\")\n",[72,231,233],{"class":74,"line":232},28,[72,234,235],{},"    engine = create_engine(empty_database_dsn)\n",[72,237,239],{"class":74,"line":238},29,[72,240,241],{},"    with engine.connect() as connection:\n",[72,243,245],{"class":74,"line":244},30,[72,246,247],{},"        diff = compare_metadata(MigrationContext.configure(connection), Base.metadata)\n",[72,249,251],{"class":74,"line":250},31,[72,252,253],{},"    # Any entry here is a model change with no migration, or the reverse.\n",[72,255,257],{"class":74,"line":256},32,[72,258,259],{},"    assert diff == [], f\"models and migrations disagree:\\n{diff}\"\n",[72,261,263],{"class":74,"line":262},33,[72,264,121],{"emptyLinePlaceholder":120},[72,266,268],{"class":74,"line":267},34,[72,269,121],{"emptyLinePlaceholder":120},[72,271,273],{"class":74,"line":272},35,[72,274,275],{},"def test_every_revision_upgrades_and_downgrades(alembic_cfg):\n",[72,277,279],{"class":74,"line":278},36,[72,280,281],{},"    script = ScriptDirectory.from_config(alembic_cfg)\n",[72,283,285],{"class":74,"line":284},37,[72,286,287],{},"    revisions = list(reversed(list(script.walk_revisions())))   # base → head\n",[72,289,291],{"class":74,"line":290},38,[72,292,293],{},"    for revision in revisions:\n",[72,295,297],{"class":74,"line":296},39,[72,298,299],{},"        command.upgrade(alembic_cfg, revision.revision)\n",[72,301,303],{"class":74,"line":302},40,[72,304,305],{},"    for revision in reversed(revisions):\n",[72,307,309],{"class":74,"line":308},41,[72,310,311],{},"        command.downgrade(alembic_cfg, revision.down_revision or \"base\")\n",[313,314,317,473],"figure",{"className":315},[316],"diagram",[318,319,326,327,326,331,326,335,326,343,326,353,326,362,326,365,326,370,326,376,326,380,326,385,326,389,326,394,326,397,326,399,326,403,326,407,326,410,326,413,326,416,326,419,326,423,326,425,326,429,326,433,326,436,326,439,326,442,326,446,326,451,326,453,326,457,326,461,326,464,326,467,326,470],"svg",{"viewBox":320,"role":321,"ariaLabelledBy":322,"xmlns":325},"0 0 820 268","img",[323,324],"alm-t","alm-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[328,329,330],"title",{"id":323},"Four migration checks and what each one catches",[332,333,334],"desc",{"id":324},"Four cards. The single-head check catches concurrent branches that cannot be applied linearly. The drift check compares migrated schema against model metadata. The stairway walks every revision up and back down. The data check applies a migration to rows written in the old schema and asserts the transformed result.",[336,337],"rect",{"x":338,"y":338,"width":339,"height":340,"rx":341,"fill":342},"0","820","268","14","#fffdf8",[344,345,352],"text",{"x":346,"y":347,"textAnchor":348,"fontSize":349,"fontWeight":350,"fill":351},"410","28","middle","16","700","#3d405b","Four tests, four different failures caught before deploy",[336,354],{"x":355,"y":356,"width":357,"height":358,"rx":359,"fill":342,"stroke":360,"strokeWidth":361},"24","52","188","190","12","#81b29a","2",[336,363],{"x":355,"y":356,"width":357,"height":364,"rx":359,"fill":351},"30",[344,366,369],{"x":367,"y":368,"textAnchor":348,"fontSize":359,"fontWeight":350,"fill":342},"118","72","single head",[344,371,375],{"x":372,"y":373,"fontSize":374,"fill":351},"38","106","11","two branches both",[344,377,379],{"x":372,"y":378,"fontSize":374,"fill":351},"126","added a revision",[344,381,384],{"x":372,"y":382,"fontSize":374,"fontWeight":350,"fill":383},"160","#8f3d22","deploy refuses:",[344,386,388],{"x":372,"y":387,"fontSize":374,"fill":351},"180","\"multiple heads\"",[344,390,393],{"x":372,"y":391,"fontSize":374,"fill":392},"216","#2a5f49","milliseconds",[336,395],{"x":396,"y":356,"width":357,"height":358,"rx":359,"fill":342,"stroke":360,"strokeWidth":361},"222",[336,398],{"x":396,"y":356,"width":357,"height":364,"rx":359,"fill":351},[344,400,402],{"x":401,"y":368,"textAnchor":348,"fontSize":359,"fontWeight":350,"fill":342},"316","drift",[344,404,406],{"x":405,"y":373,"fontSize":374,"fill":351},"236","a model changed with",[344,408,409],{"x":405,"y":378,"fontSize":374,"fill":351},"no migration, or back",[344,411,412],{"x":405,"y":382,"fontSize":374,"fontWeight":350,"fill":383},"production schema",[344,414,415],{"x":405,"y":387,"fontSize":374,"fill":351},"differs from tests",[344,417,418],{"x":405,"y":391,"fontSize":374,"fill":392},"one upgrade",[336,420],{"x":421,"y":356,"width":357,"height":358,"rx":359,"fill":342,"stroke":422,"strokeWidth":361},"420","#f2cc8f",[336,424],{"x":421,"y":356,"width":357,"height":364,"rx":359,"fill":351},[344,426,428],{"x":427,"y":368,"textAnchor":348,"fontSize":359,"fontWeight":350,"fill":342},"514","stairway",[344,430,432],{"x":431,"y":373,"fontSize":374,"fill":351},"434","each revision up,",[344,434,435],{"x":431,"y":378,"fontSize":374,"fill":351},"then all the way down",[344,437,438],{"x":431,"y":382,"fontSize":374,"fontWeight":350,"fill":383},"broken downgrade",[344,440,441],{"x":431,"y":387,"fontSize":374,"fill":351},"found in an incident",[344,443,445],{"x":431,"y":391,"fontSize":374,"fill":444},"#8a5a00","seconds",[336,447],{"x":448,"y":356,"width":449,"height":358,"rx":359,"fill":342,"stroke":450,"strokeWidth":361},"618","178","#e07a5f",[336,452],{"x":448,"y":356,"width":449,"height":364,"rx":359,"fill":351},[344,454,456],{"x":455,"y":368,"textAnchor":348,"fontSize":359,"fontWeight":350,"fill":342},"707","data",[344,458,460],{"x":459,"y":373,"fontSize":374,"fill":351},"632","rows in the old shape",[344,462,463],{"x":459,"y":378,"fontSize":374,"fill":351},"then upgrade",[344,465,466],{"x":459,"y":382,"fontSize":374,"fontWeight":350,"fill":383},"NOT NULL with no",[344,468,469],{"x":459,"y":387,"fontSize":374,"fill":351},"default, bad backfill",[344,471,472],{"x":459,"y":391,"fontSize":374,"fill":444},"per data migration",[474,475,476],"figcaption",{},"The first three are generic and can be added to any project in an afternoon. The fourth is written per migration, for the ones that touch existing rows.",[19,478,480],{"id":479},"why-this-works","Why this works",[10,482,483,484,487,488,491,492,495,496,499],{},"Alembic keeps its history as a directed graph of revisions, and ",[14,485,486],{},"ScriptDirectory"," exposes that graph without touching a database — which is why the head check costs nothing. ",[14,489,490],{},"compare_metadata"," is the same machinery ",[14,493,494],{},"alembic revision --autogenerate"," uses: it reflects the live schema and diffs it against model metadata, so an empty diff after ",[14,497,498],{},"upgrade head"," means the migrations produce exactly the schema the models describe.",[10,501,502],{},"The stairway test walks the graph one revision at a time rather than jumping to head. That matters because a revision can depend on state an earlier revision created, and jumping skips the intermediate points where a later revision's downgrade must leave the schema in a shape its predecessor recognises.",[19,504,506],{"id":505},"edge-cases-and-failure-modes","Edge cases and failure modes",[24,508,509,520,530,536,546],{},[27,510,511,515,516,519],{},[512,513,514],"strong",{},"Autogenerate false positives."," Server defaults, type variants and some constraint names are compared imperfectly on certain dialects. Filter known-harmless entries explicitly with ",[14,517,518],{},"include_object"," rather than loosening the assertion.",[27,521,522,525,526,529],{},[512,523,524],{},"Irreversible migrations."," A downgrade that raises ",[14,527,528],{},"NotImplementedError"," is honest; the stairway test should skip past it by stopping the downgrade walk at that revision, not by deleting the test.",[27,531,532,535],{},[512,533,534],{},"Tests sharing the transactional fixture's database."," These tests run DDL and commit it. Give them a separate empty database per test, or they will destroy the schema the rest of the suite is using.",[27,537,538,541,542,545],{},[512,539,540],{},"SQLite in CI, Postgres in production."," Many migrations work on one and fail on the other — ",[14,543,544],{},"ALTER COLUMN"," is the usual casualty. Run the real engine.",[27,547,548,551,552,555,556,559],{},[512,549,550],{},"Branch labels and dependencies."," Projects that use multiple branches deliberately need the head check relaxed to \"one head per branch label\", which ",[14,553,554],{},"get_heads"," combined with ",[14,557,558],{},"get_revision"," handles.",[19,561,563],{"id":562},"testing-a-migration-that-transforms-data","Testing a migration that transforms data",[10,565,566,567,571],{},"The failures that reach production are rarely DDL — they are migrations that work on an empty table and fail on real rows. Testing those needs rows in the ",[568,569,570],"em",{},"old"," shape, which means raw SQL, because the model classes already describe the new one.",[63,573,575],{"className":65,"code":574,"language":67,"meta":68,"style":68},"from alembic import command\nfrom sqlalchemy import create_engine, text\n\n\ndef test_backfill_splits_full_name(alembic_cfg, empty_database_dsn):\n    engine = create_engine(empty_database_dsn)\n\n    # 1. Bring the schema to the revision BEFORE the one under test.\n    command.upgrade(alembic_cfg, \"a1b2c3d4\")         # has customer.full_name\n\n    # 2. Insert rows in that old shape. Models cannot be used here.\n    with engine.begin() as conn:\n        conn.execute(text(\n            \"INSERT INTO customer (id, full_name) VALUES \"\n            \"(1, 'Ada Lovelace'), (2, 'Plato'), (3, NULL)\"\n        ))\n\n    # 3. Apply the migration under test.\n    command.upgrade(alembic_cfg, \"e5f6a7b8\")         # splits into first\u002Flast\n\n    # 4. Assert on the transformed data, including the awkward rows.\n    with engine.connect() as conn:\n        rows = conn.execute(text(\n            \"SELECT id, first_name, last_name FROM customer ORDER BY id\"\n        )).all()\n    assert rows == [(1, \"Ada\", \"Lovelace\"), (2, \"Plato\", None), (3, None, None)]\n",[14,576,577,581,586,590,594,599,603,607,612,617,621,626,631,636,641,646,651,655,660,665,669,674,679,684,689,694],{"__ignoreMap":68},[72,578,579],{"class":74,"line":75},[72,580,84],{},[72,582,583],{"class":74,"line":81},[72,584,585],{},"from sqlalchemy import create_engine, text\n",[72,587,588],{"class":74,"line":87},[72,589,121],{"emptyLinePlaceholder":120},[72,591,592],{"class":74,"line":93},[72,593,121],{"emptyLinePlaceholder":120},[72,595,596],{"class":74,"line":99},[72,597,598],{},"def test_backfill_splits_full_name(alembic_cfg, empty_database_dsn):\n",[72,600,601],{"class":74,"line":105},[72,602,235],{},[72,604,605],{"class":74,"line":111},[72,606,121],{"emptyLinePlaceholder":120},[72,608,609],{"class":74,"line":117},[72,610,611],{},"    # 1. Bring the schema to the revision BEFORE the one under test.\n",[72,613,614],{"class":74,"line":124},[72,615,616],{},"    command.upgrade(alembic_cfg, \"a1b2c3d4\")         # has customer.full_name\n",[72,618,619],{"class":74,"line":130},[72,620,121],{"emptyLinePlaceholder":120},[72,622,623],{"class":74,"line":135},[72,624,625],{},"    # 2. Insert rows in that old shape. Models cannot be used here.\n",[72,627,628],{"class":74,"line":140},[72,629,630],{},"    with engine.begin() as conn:\n",[72,632,633],{"class":74,"line":146},[72,634,635],{},"        conn.execute(text(\n",[72,637,638],{"class":74,"line":152},[72,639,640],{},"            \"INSERT INTO customer (id, full_name) VALUES \"\n",[72,642,643],{"class":74,"line":158},[72,644,645],{},"            \"(1, 'Ada Lovelace'), (2, 'Plato'), (3, NULL)\"\n",[72,647,648],{"class":74,"line":164},[72,649,650],{},"        ))\n",[72,652,653],{"class":74,"line":170},[72,654,121],{"emptyLinePlaceholder":120},[72,656,657],{"class":74,"line":175},[72,658,659],{},"    # 3. Apply the migration under test.\n",[72,661,662],{"class":74,"line":180},[72,663,664],{},"    command.upgrade(alembic_cfg, \"e5f6a7b8\")         # splits into first\u002Flast\n",[72,666,667],{"class":74,"line":186},[72,668,121],{"emptyLinePlaceholder":120},[72,670,671],{"class":74,"line":192},[72,672,673],{},"    # 4. Assert on the transformed data, including the awkward rows.\n",[72,675,676],{"class":74,"line":198},[72,677,678],{},"    with engine.connect() as conn:\n",[72,680,681],{"class":74,"line":204},[72,682,683],{},"        rows = conn.execute(text(\n",[72,685,686],{"class":74,"line":210},[72,687,688],{},"            \"SELECT id, first_name, last_name FROM customer ORDER BY id\"\n",[72,690,691],{"class":74,"line":215},[72,692,693],{},"        )).all()\n",[72,695,696],{"class":74,"line":220},[72,697,698],{},"    assert rows == [(1, \"Ada\", \"Lovelace\"), (2, \"Plato\", None), (3, None, None)]\n",[10,700,701,702,705],{},"The single-word name and the ",[14,703,704],{},"NULL"," are the rows that matter. A backfill written against \"first space last\" handles the common case and raises on the others, and those are precisely the rows that exist in production and not in a developer's imagination. Choosing representative awkward rows is most of the skill here, and pulling a few anonymised examples of each real shape from a production sample is the most reliable way to choose them.",[313,707,709,841],{"className":708},[316],[318,710,326,715,326,718,326,721,326,738,326,742,326,747,326,755,326,760,326,765,326,769,326,774,326,779,326,783,326,787,326,790,326,794,326,797,326,801,326,805,326,808,326,812,326,817,326,821,326,825,326,828,326,836],{"viewBox":711,"role":321,"ariaLabelledBy":712,"xmlns":325},"0 0 800 246",[713,714],"data-t","data-d",[328,716,717],{"id":713},"Sequence for testing a data-bearing migration",[332,719,720],{"id":714},"Four steps left to right. Upgrade to the revision before the one under test. Insert rows in that old schema using raw SQL, including awkward cases such as single-word names and nulls. Upgrade to the revision under test. Assert on the transformed rows.",[722,723,724,725,326],"defs",{},"\n    ",[726,727,734],"marker",{"id":728,"viewBox":729,"refX":730,"refY":731,"markerWidth":732,"markerHeight":732,"orient":733},"data-a","0 0 10 10","9","5","7","auto-start-reverse",[735,736],"path",{"d":737,"fill":351},"M0 0 L10 5 L0 10 z",[336,739],{"x":338,"y":338,"width":740,"height":741,"rx":341,"fill":342},"800","246",[344,743,746],{"x":744,"y":347,"textAnchor":348,"fontSize":745,"fontWeight":350,"fill":351},"400","15.5","Old shape in, new shape out",[336,748],{"x":749,"y":750,"width":751,"height":752,"rx":374,"fill":753,"stroke":351,"strokeWidth":754},"22","60","170","104","#f4f1de","1.6",[344,756,759],{"x":757,"y":758,"textAnchor":348,"fontSize":359,"fontWeight":350,"fill":351},"107","86","1 · upgrade to N−1",[344,761,764],{"x":762,"y":763,"fontSize":374,"fill":351},"36","112","schema has",[344,766,768],{"x":762,"y":767,"fontSize":374,"fill":351},"132","full_name only",[74,770],{"x1":771,"y1":763,"x2":772,"y2":763,"stroke":351,"strokeWidth":754,"markerEnd":773},"196","218","url(#data-a)",[336,775],{"x":776,"y":750,"width":777,"height":752,"rx":374,"fill":778,"stroke":422,"strokeWidth":361},"224","176","#f7f0da",[344,780,782],{"x":781,"y":758,"textAnchor":348,"fontSize":359,"fontWeight":350,"fill":351},"312","2 · raw INSERTs",[344,784,786],{"x":785,"y":763,"fontSize":374,"fill":351},"238","'Ada Lovelace'",[344,788,789],{"x":785,"y":767,"fontSize":374,"fill":444},"'Plato', NULL",[74,791],{"x1":792,"y1":763,"x2":793,"y2":763,"stroke":351,"strokeWidth":754,"markerEnd":773},"404","426",[336,795],{"x":796,"y":750,"width":751,"height":752,"rx":374,"fill":753,"stroke":351,"strokeWidth":754},"432",[344,798,800],{"x":799,"y":758,"textAnchor":348,"fontSize":359,"fontWeight":350,"fill":351},"517","3 · upgrade to N",[344,802,804],{"x":803,"y":763,"fontSize":374,"fill":351},"446","the migration",[344,806,807],{"x":803,"y":767,"fontSize":374,"fill":351},"under test",[74,809],{"x1":810,"y1":763,"x2":811,"y2":763,"stroke":351,"strokeWidth":754,"markerEnd":773},"606","628",[336,813],{"x":814,"y":750,"width":815,"height":752,"rx":374,"fill":816,"stroke":360,"strokeWidth":361},"634","144","#e6f0ea",[344,818,820],{"x":819,"y":758,"textAnchor":348,"fontSize":359,"fontWeight":350,"fill":351},"706","4 · assert",[344,822,824],{"x":823,"y":763,"fontSize":374,"fill":351},"648","every row,",[344,826,827],{"x":823,"y":767,"fontSize":374,"fill":392},"awkward ones too",[336,829],{"x":749,"y":830,"width":831,"height":832,"rx":833,"fill":342,"stroke":834,"strokeWidth":835},"184","756","44","10","rgba(61,64,91,0.35)","1.5",[344,837,840],{"x":744,"y":838,"textAnchor":348,"fontSize":839,"fill":351},"211","11.5","Step 2 cannot use the ORM: the model classes describe the schema after step 3, not before it.",[474,842,843],{},"The awkward rows in step two are the test. A migration that only ever sees the happy-path row has been tested against the one case that was never going to fail.",[19,845,847],{"id":846},"where-these-tests-sit-in-the-pipeline","Where these tests sit in the pipeline",[10,849,850],{},"The four checks differ enough in cost that they belong in different stages, and placing them deliberately keeps the fast suite fast without letting migration bugs through.",[10,852,853,854,857],{},"The ",[512,855,856],{},"single-head"," check needs no database and runs in milliseconds, so it belongs in the pull-request suite and, ideally, in a pre-commit hook: two developers merging revisions on the same day is the common case, and catching it before the second merge is far cheaper than after.",[10,859,853,860,862],{},[512,861,402],{}," check needs one upgrade against an empty database — a second or two with a session container — and also belongs on every pull request, because a model change without a migration is the single most frequent migration defect.",[10,864,853,865,867],{},[512,866,428],{}," walks every revision twice. On a mature project with two hundred revisions that is a minute or more, which argues for the merge queue rather than every push. A useful compromise is to walk only the revisions added since the main branch on pull requests, and the full history nightly.",[10,869,853,870,872],{},[512,871,456],{}," tests are per migration and usually fast, so they run wherever the migration's own change runs. The volume job above is nightly by nature.",[313,874,876,964],{"className":875},[316],[318,877,326,882,326,885,326,888,326,891,326,894,326,897,326,902,326,906,326,909,326,913,326,917,326,920,326,923,326,926,326,930,326,933,326,936,326,940,326,943,326,947,326,951,326,955,326,958,326,961],{"viewBox":878,"role":321,"ariaLabelledBy":879,"xmlns":325},"0 0 800 240",[880,881],"stage-t","stage-d",[328,883,884],{"id":880},"Placing migration checks by cost",[332,886,887],{"id":881},"Three pipeline stages. Every push runs the single-head check, the drift check and the data tests for changed migrations. The merge queue runs the full stairway of upgrades and downgrades. The nightly job restores a production-sized snapshot and times each revision.",[336,889],{"x":338,"y":338,"width":740,"height":890,"rx":341,"fill":342},"240",[344,892,893],{"x":744,"y":347,"textAnchor":348,"fontSize":745,"fontWeight":350,"fill":351},"Cheap checks early, expensive ones on a schedule",[336,895],{"x":355,"y":896,"width":890,"height":751,"rx":359,"fill":816,"stroke":360,"strokeWidth":361},"50",[344,898,901],{"x":815,"y":899,"textAnchor":348,"fontSize":900,"fontWeight":350,"fill":351},"76","12.5","every push",[344,903,905],{"x":904,"y":752,"fontSize":374,"fill":351},"40","• single head",[344,907,908],{"x":904,"y":378,"fontSize":374,"fill":351},"• model\u002Fmigration drift",[344,910,912],{"x":904,"y":911,"fontSize":374,"fill":351},"148","• data tests for changed",[344,914,916],{"x":356,"y":915,"fontSize":374,"fill":351},"166","migrations",[344,918,445],{"x":904,"y":919,"fontSize":374,"fill":392},"198",[336,921],{"x":922,"y":896,"width":890,"height":751,"rx":359,"fill":778,"stroke":422,"strokeWidth":361},"280",[344,924,925],{"x":744,"y":899,"textAnchor":348,"fontSize":900,"fontWeight":350,"fill":351},"merge queue",[344,927,929],{"x":928,"y":752,"fontSize":374,"fill":351},"296","• full stairway up",[344,931,932],{"x":928,"y":378,"fontSize":374,"fill":351},"• full stairway down",[344,934,935],{"x":928,"y":911,"fontSize":374,"fill":351},"• every irreversible",[344,937,939],{"x":938,"y":915,"fontSize":374,"fill":351},"308","revision marked",[344,941,942],{"x":928,"y":919,"fontSize":374,"fill":444},"about a minute",[336,944],{"x":945,"y":896,"width":890,"height":751,"rx":359,"fill":946,"stroke":450,"strokeWidth":361},"536","#fbe9e3",[344,948,950],{"x":949,"y":899,"textAnchor":348,"fontSize":900,"fontWeight":350,"fill":351},"656","nightly",[344,952,954],{"x":953,"y":752,"fontSize":374,"fill":351},"552","• restore a snapshot",[344,956,957],{"x":953,"y":378,"fontSize":374,"fill":351},"• time each revision",[344,959,960],{"x":953,"y":911,"fontSize":374,"fill":351},"• flag long locks",[344,962,963],{"x":953,"y":919,"fontSize":374,"fill":383},"tens of minutes",[474,965,966],{},"Each stage catches something the earlier ones cannot. The nightly job is the only place lock duration and backfill time become visible before a deploy.",[10,968,969,970,973],{},"Marking the slower checks keeps the split explicit in the code rather than in pipeline YAML alone — ",[14,971,972],{},"@pytest.mark.migrations_full"," on the stairway, selected in the merge-queue job and excluded elsewhere, means a developer running the suite locally gets the fast checks by default and can opt into the full walk when working on a migration.",[19,975,977],{"id":976},"running-migrations-against-realistic-volume","Running migrations against realistic volume",[10,979,980],{},"Correctness on a handful of rows is necessary and not sufficient. A migration that rewrites a column on a fifty-million-row table can hold a lock for an hour, and the only safe time to learn that is before the deploy.",[10,982,983,984,987],{},"A nightly job that restores an anonymised production snapshot into a container and times ",[14,985,986],{},"alembic upgrade head"," against it catches the two failure modes that matter: a migration that takes far longer than the deploy window allows, and one that takes an exclusive lock on a hot table. Neither shows up in the per-commit suite, and neither needs to — the job runs on a schedule, reports the duration per revision, and flags anything above a threshold.",[63,989,993],{"className":990,"code":991,"language":992,"meta":68,"style":68},"language-bash shiki shiki-themes github-light github-dark","# Nightly: restore the snapshot, then time each revision individually.\npg_restore --no-owner -d \"$DSN\" snapshot.dump\nfor rev in $(alembic history -r current:head | awk '{print $3}' | tac); do\n  \u002Fusr\u002Fbin\u002Ftime -f \"%e s  $rev\" alembic upgrade \"$rev\"\ndone\n","bash",[14,994,995,1001,1028,1075,1104],{"__ignoreMap":68},[72,996,997],{"class":74,"line":75},[72,998,1000],{"class":999},"sJ8bj","# Nightly: restore the snapshot, then time each revision individually.\n",[72,1002,1003,1007,1011,1014,1018,1022,1025],{"class":74,"line":81},[72,1004,1006],{"class":1005},"sScJk","pg_restore",[72,1008,1010],{"class":1009},"sj4cs"," --no-owner",[72,1012,1013],{"class":1009}," -d",[72,1015,1017],{"class":1016},"sZZnC"," \"",[72,1019,1021],{"class":1020},"sVt8B","$DSN",[72,1023,1024],{"class":1016},"\"",[72,1026,1027],{"class":1016}," snapshot.dump\n",[72,1029,1030,1034,1037,1040,1043,1046,1049,1052,1055,1058,1061,1064,1066,1069,1072],{"class":74,"line":87},[72,1031,1033],{"class":1032},"szBVR","for",[72,1035,1036],{"class":1020}," rev ",[72,1038,1039],{"class":1032},"in",[72,1041,1042],{"class":1020}," $(",[72,1044,1045],{"class":1005},"alembic",[72,1047,1048],{"class":1016}," history",[72,1050,1051],{"class":1009}," -r",[72,1053,1054],{"class":1016}," current:head",[72,1056,1057],{"class":1032}," |",[72,1059,1060],{"class":1005}," awk",[72,1062,1063],{"class":1016}," '{print $3}'",[72,1065,1057],{"class":1032},[72,1067,1068],{"class":1005}," tac",[72,1070,1071],{"class":1020},"); ",[72,1073,1074],{"class":1032},"do\n",[72,1076,1077,1080,1083,1086,1089,1091,1094,1097,1099,1101],{"class":74,"line":93},[72,1078,1079],{"class":1005},"  \u002Fusr\u002Fbin\u002Ftime",[72,1081,1082],{"class":1009}," -f",[72,1084,1085],{"class":1016}," \"%e s  ",[72,1087,1088],{"class":1020},"$rev",[72,1090,1024],{"class":1016},[72,1092,1093],{"class":1016}," alembic",[72,1095,1096],{"class":1016}," upgrade",[72,1098,1017],{"class":1016},[72,1100,1088],{"class":1020},[72,1102,1103],{"class":1016},"\"\n",[72,1105,1106],{"class":74,"line":99},[72,1107,1108],{"class":1032},"done\n",[10,1110,1111,1112,1115],{},"The per-revision timing is the useful output. A total of twelve minutes tells you little; one revision taking eleven of them names the migration to rewrite — usually by splitting a single ",[14,1113,1114],{},"ALTER"," into an additive change, a batched backfill and a later constraint, the same expand–migrate–contract sequence used for API evolution.",[19,1117,1119],{"id":1118},"frequently-asked-questions","Frequently Asked Questions",[10,1121,1122,1125],{},[512,1123,1124],{},"Why test migrations if the application tests already pass?","\nBecause application tests usually build the schema from model metadata, so the migrations never run until a deploy. Drift between models and migration history, an unapplyable revision or a broken downgrade are all invisible to a suite that never executes the migration chain.",[10,1127,1128,1131],{},[512,1129,1130],{},"Should every migration have a working downgrade?","\nEvery migration should either have a downgrade that has been tested or be explicitly marked irreversible with a downgrade that raises. An untested downgrade is worse than none, because it will be trusted during an incident and fail then.",[10,1133,1134,1137],{},[512,1135,1136],{},"How do I test a migration that transforms existing data?","\nStamp the database to the revision before it, insert representative rows through raw SQL that matches that older schema, run the upgrade, and assert on the transformed data. Model classes cannot be used for the setup because they describe the new schema, not the old one.",[19,1139,1141],{"id":1140},"related","Related",[24,1143,1144,1151,1158,1164],{},[27,1145,1146,1150],{},[41,1147,1149],{"href":1148},"\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002F","Database Fixtures & Transactional Tests"," — why the test database should be built from migrations at all.",[27,1152,1153,1157],{},[41,1154,1156],{"href":1155},"\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Frolling-back-every-test-with-nested-transactions\u002F","Rolling Back Every Test with Nested Transactions"," — the fixture that runs on top of the migrated schema.",[27,1159,1160,1163],{},[41,1161,1162],{"href":43},"Starting Postgres with testcontainers-python"," — the engine these tests need.",[27,1165,1166,1170],{},[41,1167,1169],{"href":1168},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fpytest-markers-for-conditional-test-execution\u002F","Pytest Markers for Conditional Test Execution"," — keeping the slower migration tests out of the fast suite.",[10,1172,1173,1174],{},"← Back to ",[41,1175,1149],{"href":1148},[1177,1178,1179],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":68,"searchDepth":81,"depth":81,"links":1181},[1182,1183,1184,1185,1186,1187,1188,1189,1190],{"id":21,"depth":81,"text":22},{"id":60,"depth":81,"text":61},{"id":479,"depth":81,"text":480},{"id":505,"depth":81,"text":506},{"id":562,"depth":81,"text":563},{"id":846,"depth":81,"text":847},{"id":976,"depth":81,"text":977},{"id":1118,"depth":81,"text":1119},{"id":1140,"depth":81,"text":1141},"Catch migration bugs before deploy: model-versus-migration drift, upgrade and downgrade round trips, data-bearing migrations, and single-head checks in pytest.","md",{"slug":1194,"type":1195,"breadcrumb":1196,"datePublished":1197,"dateModified":1197,"faq":1198,"howto":1205},"testing-alembic-migrations-in-ci","article","Alembic Migrations","2026-09-18",[1199,1201,1203],{"q":1124,"a":1200},"Because application tests usually build the schema from model metadata, so the migrations never run until a deploy. Drift between models and migration history, an unapplyable revision or a broken downgrade are all invisible to a suite that never executes the migration chain.",{"q":1130,"a":1202},"Every migration should either have a downgrade that has been tested or be explicitly marked irreversible with a downgrade that raises. An untested downgrade is worse than none, because it will be trusted during an incident and fail then.",{"q":1136,"a":1204},"Stamp the database to the revision before it, insert representative rows through raw SQL that matches that older schema, run the upgrade, and assert on the transformed data. Model classes cannot be used for the setup because they describe the new schema, not the old one.",{"name":1206,"description":1207,"steps":1208},"How to test Alembic migrations in CI","Assert there is one head, that models match the migrated schema, that every revision upgrades and downgrades, and that data migrations transform real rows.",[1209,1212,1215,1218,1221],{"name":1210,"text":1211},"Assert a single head","Fail when two branches each add a revision so the history cannot be applied linearly.",{"name":1213,"text":1214},"Detect drift between models and migrations","Run compare_metadata against a migrated database and assert the diff is empty.",{"name":1216,"text":1217},"Walk every revision up and down","Upgrade one step at a time to head, then downgrade to base, asserting each step succeeds.",{"name":1219,"text":1220},"Test data migrations with pre-migration rows","Stamp to the previous revision, insert rows in the old shape with raw SQL, upgrade, and assert on the result.",{"name":1222,"text":1223},"Run on a real engine","Use the production engine and version in a container so dialect-specific DDL is exercised.","\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Ftesting-alembic-migrations-in-ci",{"title":5,"description":1191},"integration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Ftesting-alembic-migrations-in-ci\u002Findex","GUYx0n0lWIpW_deiUdzjchKemc651FkivgrlT_I5ci8",1789718767519]