Skip to content

Migration Files

Migration files are Python modules that record how your API changed between versions. They serve two purposes:

  1. Schema deltas — What operations and schemas were added, removed, or modified
  2. Data migrations — How to transform data between the old and new formats

File Structure

Migration files follow this naming pattern:

m_{sequence}_{slug}.py

For example: m_0001_initial_api.py, m_0002_add_phone_field.py

Anatomy of a Migration File

Here's a complete migration file:

"""
API migration: 1 -> 2

Add phone field to PersonOut schema
"""
from crane.delta import VersionDelta
from crane.data_migrations import (
    DataMigrationSet,
    SchemaDowngrade,
    SchemaUpgrade,
)

# Dependencies on previous migrations
dependencies: list[tuple[str, str]] = [("myapp.api_migrations.default", "1")]

# Version transition
from_version: str | None = "1"
to_version: str = "2"

# Schema delta (auto-generated)
delta = VersionDelta.model_validate_json("""
{
    "actions": [
        {
            "action": "schema_definition_modified",
            "schema_ref": "#/components/schemas/PersonOut",
            "old_schema": {
                "properties": {
                    "name": {"type": "string"}
                },
                "required": ["name"]
            },
            "new_schema": {
                "properties": {
                    "name": {"type": "string"},
                    "phone": {"type": "string"}
                },
                "required": ["name"]
            }
        }
    ]
}
""")


# Data transformers (you implement these)
def downgrade_person_out(data: dict) -> dict:
    """2 -> 1: Remove phone for v1 clients."""
    data.pop("phone", None)
    return data


def upgrade_person_out(data: dict) -> dict:
    """1 -> 2: Add default phone for v1 requests."""
    data.setdefault("phone", None)
    return data


data_migrations = DataMigrationSet(
    schema_downgrades=[
        SchemaDowngrade("#/components/schemas/PersonOut", downgrade_person_out),
    ],
    schema_upgrades=[
        SchemaUpgrade("#/components/schemas/PersonOut", upgrade_person_out),
    ],
)

Required Attributes

dependencies

A list of (module_path, version) tuples that this migration depends on:

dependencies: list[tuple[str, str]] = [("myapp.api_migrations.default", "1")]

The first migration has an empty list. Subsequent migrations depend on the previous version. Currently unused, but tracked to later allow merge API migrations, similar to python manage.py makemigrations --merge.

from_version and to_version

The version transition this migration represents:

from_version: str | None = "1"  # None for initial migration.
to_version: str = "2"

delta

A VersionDelta object containing all schema changes. This is auto-generated by makeapimigrations and shouldn't be edited manually:

delta = VersionDelta.model_validate_json("""{ ... }""")

Delta Actions

The delta.actions list contains these action types:

Operation Changes

Action Description
operation_added New endpoint added
operation_removed Endpoint deleted
operation_modified Endpoint parameters or responses changed

Schema Changes

Action Description
schema_definition_added New schema type added
schema_definition_removed Schema type deleted
schema_definition_modified Schema fields or types changed

Data Migrations

The data_migrations object contains transformers for runtime data conversion.

Note

You'll usually not have to write any of the transformers described below. In most cases they are automatically generated by the makeapimigrations command and created in your migrations file.

The documentation below is if you ever have a case you need to manually describe in your migration files, and so you can read and understand the contents of a migration file, to check if the auto-generated logic is as you'd expect.

Schema Transformers

Schema transformers apply wherever that schema appears—in responses, nested objects, or array items:

data_migrations = DataMigrationSet(
    schema_downgrades=[
        # Applied to responses when downgrading (new → old)
        SchemaDowngrade("#/components/schemas/PersonOut", downgrade_person),
    ],
    schema_upgrades=[
        # Applied to requests when upgrading (old → new)
        SchemaUpgrade("#/components/schemas/PersonIn", upgrade_person),
    ],
)

Path Rewrites

When an endpoint URL changes, the migration generator will add a path rewrite:

from crane.data_migrations import PathRewrite

data_migrations = DataMigrationSet(
    path_rewrites=[
        PathRewrite(
            old_path="/persons/{person_id}",
            new_path="/people/{person_id}",
            methods=["get", "put", "delete"],
        ),
    ],
)

Operation Transformers

For endpoint-specific transformations that can't be expressed at the schema level:

from crane.data_migrations import OperationUpgrade, OperationDowngrade

data_migrations = DataMigrationSet(
    operation_upgrades=[
        OperationUpgrade("/users", "post", upgrade_create_user),
    ],
    operation_downgrades=[
        OperationDowngrade("/users", "get", downgrade_list_users),
    ],
)

See Operation Transformers for details.

Transformer Functions

Downgrade Transformers

Convert response data from the new schema to the old schema:

def downgrade_person_out(data: dict) -> dict:
    """Remove fields that didn't exist in the old version."""
    data.pop("phone", None)  # phone was added in this version
    return data

Tip

The downgrade function receives data in the new format and must return data in the old format. That means you need to consider how to transform this schema if it is used in a response body.

Upgrade Transformers

Convert request data from the old schema to the new schema:

def upgrade_person_in(data: dict) -> dict:
    """Add fields with defaults for old clients."""
    data.setdefault("phone", None)  # provide default for new field
    return data

Tip

The upgrade function receives data in the old format and must return data in the new format. That means you need to consider how to transform this schema if it is used in a request body

Async Transformers

Transformers can be sync or async, according to your preference.

async def upgrade_person_in(data: dict) -> dict:
    if "legacy_id" in data:
        # Look up the new ID
        new_id = await lookup_new_id(data.pop("legacy_id"))
        data["id"] = new_id
    return data

Async is slightly better performance

The middleware itself is written to support async operation, and wraps non-coroutine transformers in a sync_to_async call to make sure the event loop is not blocked by blocking I/O, if any. That does mean there's a small (creating a thread) overhead to sync transformers.

If you want the best performance, write your transformers async. If you're not familiar (enough) with async python, it's still fine to write them sync. I won't judge, hell, it's the default option.

Schema References

Schema references use the OpenAPI JSON pointer format:

#/components/schemas/PersonOut

This matches the schema name in your Django Ninja schemas:

class PersonOut(Schema):  # → #/components/schemas/PersonOut
    name: str

Generation vs Manual Editing

The delta is auto-generated and shouldn't be edited. The data_migrations require manual implementation—the generator creates skeleton functions with NotImplementedError for breaking changes.

Warning

Don't edit the delta JSON manually. If you need to change what's tracked, modify your API and regenerate.

Migration Chain

Migrations form a chain through their dependencies. When reconstructing API state at a version, crane applies migrations in sequence:

empty → m_0001 (v1) → m_0002 (v2) → m_0003 (v3)

To get the API schema at v2, crane applies m_0001 and m_0002 but not m_0003.

Next Steps