メインコンテンツへスキップ
友田 陽大
marshmallow
Python
marshmallow
マイグレーション
型安全
テスト
アーキテクチャ設計

marshmallow 3 → 4 移行完全ガイド:破壊的変更を安全に乗り越える

marshmallow 4の破壊的変更を公式アップグレードガイドに忠実に整理。missing/default→load_default/dump_default、pass_many→pass_collection、抽象基底クラスのインスタンス化禁止、validatorはValidationErrorをraise、Schema.context→contextvars、暗黙フィールド廃止までをbefore/afterで示し、段階的に移行する手順を解説します。

公開日
読了時間
24分
著者
友田 陽大
シェア
目次

導入:移行は「機械的置換 + テストで担保」という作業に分解できる

「メジャーバージョンアップ」と聞くと身構えるかもしれません。しかし marshmallow 3 → 4 の移行は、正体の分かっている有限個の破壊的変更の集合です。新しい概念を学ぶというより、既存コードの特定パターンを別のパターンへ機械的に置き換え、テストで退行(リグレッション)が起きていないことを確かめる——その作業に分解できます。本記事はその「対応表」と「手順書」です。

なぜ今あえて 4.x へ上げるのか。理由は機能追加ではなく、保守とセキュリティ更新の継続性にあります。3.x 系は保守モードに入っており、今後の不具合修正・依存ライブラリの追従・脆弱性対応は 4.x 系が主戦場です。古いバージョンに留まり続けることは、いずれ「サポート切れの基盤の上で本番を回す」という、それ自体が技術的負債でありリスクである状態に近づいていきます。バージョンアップは、上げたい時ではなく、上げられる時に小さく済ませるのが鉄則です。

💡 前提知識:marshmallow そのものの設計思想(Schema / fields / load() / dump() / バリデーションの三層 / セキュリティ境界)は、marshmallow 実践ガイド で体系的に解説しています。本記事は、その続編として 3 → 4 移行に特化したディープダイブです。各 API の意味が曖昧なときは、まずピラー記事に立ち返ってください。

💡 この記事で扱うバージョン:現行の安定版は marshmallow 4.3.0(2026年4月時点)です。3.x 系は新機能追加が止まった保守モードにあります。本記事の before/after は、すべて公式の「Upgrading to newer releases」ガイドに基づく、4.x の破壊的変更です。


1. 移行戦略の全体像:4 ステップで「壊さずに上げる」

行き当たりばったりに pip install -U marshmallow を実行し、大量のエラーに埋もれる——これが最悪の進め方です。安全な移行は、次の 4 ステップを順番に踏みます。各ステップは独立した小さな PR にし、レビューしやすく、問題が起きても切り戻しやすくします。

① バージョン固定     現状の 3.x をロックし、移行作業を「いつでも中断・再開できる」状態にする
② 警告を潰す         3.x の最終版で DeprecationWarning をエラー化し、4.x で消える機能を先に直す
③ 4.x へ上げる        固定を外して 4.x に上げ、残った破壊的変更を対応表に従って置換する
④ テストで担保       load/dump の往復をゴールデンテストで固定し、CI を緑にして完了とする

① まず 3.x にバージョンを固定する

移行を始める前に、現在動いているバージョンを明示的に固定します。これにより「いつの間にか勝手に 4.x が入って壊れた」という事故を防ぎ、移行を自分の意思でコントロールできる作業に変えます。

# 現状を 3.x 系にロック(4.0 へ勝手に上がらないようにする)
pip install "marshmallow<4"

# requirements.txt / pyproject.toml にも明示的に書く
# marshmallow>=3,<4

② 3.x の最終版で「非推奨警告」を先に潰す

4.x で削除される機能の多くは、3.x の後期バージョンで既に DeprecationWarning を出しています。4.x へ上げる前に、3.x のまま警告をすべて消しておく——これが移行を劇的に楽にする最大のコツです。警告が出ているコードは、4.x では「警告」ではなく「エラー」になるからです。

警告を確実に検出するには、Python の -W error::DeprecationWarning フラグで警告を例外に昇格させ、テストを走らせます。

# DeprecationWarning をエラー化してテストを実行 → 非推奨 API の使用箇所が例外として顕在化する
python -W error::DeprecationWarning -m pytest

pytest を使っているなら、設定ファイルでも同じことができます。

# pytest.ini / setup.cfg / pyproject.toml の [tool.pytest.ini_options]
[pytest]
filterwarnings =
    error::DeprecationWarning

💡 なぜこのステップを分けるのか:3.x のまま警告を潰せば、各修正が「4.x へ上げた後の挙動変化」と混ざらず、原因の切り分けが圧倒的に容易になります。@post_dump(pass_many=True) のような変更は 3.x の段階でも新名称を受け付けるものがあり、先に直しておけば 4.x 移行時の差分が小さくなります。「警告ゼロの 3.x」という中間地点を必ず経由してください。

③ 4.x へ上げて、残りの破壊的変更を置換する

警告を潰し終えたら、固定を外して 4.x に上げます。ここで本記事の第 2 章以降の対応表を使い、残った破壊的変更を機械的に置換していきます。

pip install "marshmallow>=4,<5"

④ テストで退行を止める

最後に、移行前後で load() / dump() の入出力が変わっていないことをテストで固定します(第 12 章で詳述)。型シグネチャは通っても、出力 JSON の中身が静かに変わるのが移行で最も怖い退行です。これを CI で機械的に検出できる状態を作って、初めて「移行完了」と呼べます。


ここからは、対応表の各行を before / after で 1 つずつ展開していきます。まず全体像を俯瞰したい場合は、本章末の早見表を先に眺めてから読み進めてください。

2. missing / defaultload_default / dump_default(最頻出)

最も出現頻度が高く、最も重要な変更です。3.x の missing=(load 時のデフォルト)と default=(dump 時のデフォルト)は、4.x では load_default= / dump_default= に改名されました。

# ❌ 3.x(旧)
from marshmallow import Schema, fields


class UserSchema(Schema):
    name = fields.Str(missing="Monty")    # load 時に欠落していたらこの値
    answer = fields.Int(default=42)       # dump 時に欠落していたらこの値
# ✅ 4.x(新)
from marshmallow import Schema, fields


class UserSchema(Schema):
    name = fields.Str(load_default="Monty")    # load 時のデフォルト
    answer = fields.Int(dump_default=42)        # dump 時のデフォルト

なぜこれが優れているのか? 3.x の missing / default という名前は、「いつ」適用されるデフォルトなのかが名前から読み取れませんでした。missing は入力時、default は出力時——この対応を覚えていなければコードを誤読します。load_default / dump_default は、marshmallow の二大操作である load() / dump() と名前が一対一で対応します。「load_defaultload() のデフォルト、dump_defaultdump() のデフォルト」と、名前を読むだけで意味が確定する。これは「意図を明らかにする命名」という可読性原則の、教科書的な改善です。

⚠️ 意味の取り違えに注意:「入力時のデフォルト」と「出力時のデフォルト」は別物です。load_default を設定すべき箇所に dump_default を書くと、欠落入力が補われずバリデーションの挙動が変わります。機械置換ではなく、1 つずつ load 用か dump 用かを判断してください(grep の使い方は第 10 章)。


3. 抽象基底クラスのインスタンス化禁止:Number() / Mapping() / Field() は使えない

fields.Number()fields.Field()fields.Mapping()抽象基底クラスであり、4.x では直接インスタンス化できなくなりました。具体的な型を表す具象フィールドを使います。

# ❌ 4.x ではエラーになる(抽象基底クラスの直接利用)
from marshmallow import Schema, fields


class ProductSchema(Schema):
    price = fields.Number()       # 整数か小数か曖昧
    meta = fields.Mapping()       # キー・値の型が不明
    raw = fields.Field()          # 何でも通す
# ✅ 4.x:意図する具象型を明示する
from marshmallow import Schema, fields


class ProductSchema(Schema):
    # Number → 整数 / 小数 / 10進数のいずれかを明示
    price = fields.Decimal(as_string=True)   # 金額なら Decimal が安全(誤差なし)
    quantity = fields.Integer()
    ratio = fields.Float()

    # Mapping → Dict にキー・値の型を与える
    meta = fields.Dict(keys=fields.Str(), values=fields.Str())

    # Field → 通したい型の具象フィールドを選ぶ(型を限定できないなら Raw を検討)
    raw = fields.Raw()

なぜこれが優れているのか? fields.Number() のような抽象型は「整数なのか小数なのか」をスキーマ定義から読み取れず、検証も緩くなります。具象型への移行を強制されることは一見手間ですが、実際には**「このフィールドが本当は何型なのか」をコードに明記する好機です。priceDecimal なのか Float なのかは、金額計算の正確性(誤差の有無)に直結する設計判断です。曖昧な基底クラスを禁じることで、marshmallow は型の意図をスキーマに刻むこと**を促しています。これは型安全の原則そのものです。


4. バリデータは必ず ValidationErrorraise する(False を返す方式は廃止)

3.x では、バリデータ関数が False を返すことで検証失敗を表現できました。4.x ではこの方式は廃止され、検証に失敗したら必ず ValidationErrorraise しなければなりません。

# ❌ 3.x(旧):False を返して失敗を表現
from marshmallow import Schema, fields


class LoginSchema(Schema):
    # 一致しなければ False が返り、それが検証失敗として扱われていた
    secret = fields.Str(validate=lambda x: x == "password")
# ✅ 4.x(新):失敗時は ValidationError を raise
from marshmallow import Schema, fields, ValidationError


def must_be_password(value: str) -> None:
    if value != "password":
        # メッセージを持った例外を送出する(戻り値ではなく例外で失敗を伝える)
        raise ValidationError("合言葉が一致しません。")


class LoginSchema(Schema):
    secret = fields.Str(validate=must_be_password)

なぜこれが優れているのか? False を返す方式には致命的な弱点が 2 つありました。第一に、None を返したり、戻り値を書き忘れた関数が「成功」と誤判定される——return を忘れた関数は暗黙に None(偽値ではない真偽不明値)を返し、検証が素通りします。これはバリデーション特有の、最も発見しにくいバグの一つです。第二に、False には「なぜ失敗したか」の情報が乗りませんValidationErrorraise する方式なら、失敗には必ずメッセージが伴い、戻り値の書き忘れは「例外を投げない=成功」として明確に振る舞います。「失敗は戻り値ではなく例外で伝える」という規律は、エラーを握り潰しにくくする、信頼性のための正しい設計です。


5. デコレータ引数 pass_manypass_collection

@pre_load / @post_load / @pre_dump / @post_dump の各デコレータで、コレクション全体(many=True の入出力)を一括処理する際に使う引数が、pass_many から pass_collection に改名されました。

# ❌ 3.x(旧)
from marshmallow import Schema, post_dump


class EnvelopeSchema(Schema):
    @post_dump(pass_many=True)
    def wrap(self, data, many, **kwargs):
        key = "results" if many else "result"
        return {key: data}
# ✅ 4.x(新):引数名だけが変わる(メソッドのシグネチャは同じ)
from marshmallow import Schema, post_dump


class EnvelopeSchema(Schema):
    @post_dump(pass_collection=True)
    def wrap(self, data, many, **kwargs):
        key = "results" if many else "result"
        return {key: data}

引数名の変更だけで、メソッドが many を受け取るシグネチャは変わりません。これは第 2 章の load_default と並ぶ機械置換の代表例で、pass_many=pass_collection= に一括置換すれば済みます。「コレクション全体を渡す」という意図が pass_collection という名前で明確になった、自然な改名です。


6. 暗黙のフィールド生成は廃止:class Meta: fields / additional は使えない

3.x では class Metafields タプルや additional を使い、クラス属性として宣言していないフィールドを暗黙的に生成できました。4.x ではこの仕組みが廃止され、使うフィールドはすべて明示的に宣言する必要があります。

# ❌ 3.x(旧):Meta.fields / additional による暗黙生成
from marshmallow import Schema, fields


class UserSchema(Schema):
    email = fields.Email()

    class Meta:
        # name / created_at は属性宣言なしに「いい感じに」生成されていた
        fields = ("name", "email", "created_at")
        additional = ("nickname",)
# ✅ 4.x(新):すべてのフィールドを明示宣言する
from marshmallow import Schema, fields


class UserSchema(Schema):
    name = fields.Str()
    email = fields.Email()
    created_at = fields.DateTime()
    nickname = fields.Str()

なぜこれが優れているのか? Meta.fields = ("name", ...) で生成されるフィールドは型が不明瞭で、name が文字列なのか整数なのかをスキーマ定義から読み取れませんでした。明示宣言を強制することで、各フィールドの型・検証・dump_only などの属性がすべてスキーマ本体に現れます。これは可読性と型安全の両方を底上げします。なお、SQLAlchemy モデルから自動生成したい正当なユースケースは、marshmallow-sqlalchemySQLAlchemyAutoSchema が引き続き担います——「暗黙のフィールド生成」の廃止は、その正規ルートへの集約でもあります。


7. Schema.context 廃止 → contextvars / experimental.Context

3.x では schema.context = {...} を通じて、シリアライズ/デシリアライズ処理に外部の文脈(リクエストユーザー、ロケールなど)を渡せました。4.x では Schema.context は廃止され、Python 標準の contextvars.ContextVar を使うか、marshmallow が提供する実験的(experimental)な Context マネージャを使います。

# ❌ 3.x(旧):schema.context に状態を詰める
schema = UserSchema()
schema.context["current_user"] = current_user
result = schema.dump(user)
# メソッド内では self.context["current_user"] で参照していた
# ✅ 4.x(新):experimental.Context の with 構文で文脈を束縛する
from typing import TypedDict
from marshmallow import Schema, fields
from marshmallow.experimental.context import Context


class CtxDict(TypedDict):
    current_user: object


class UserSchema(Schema):
    name = fields.Str()
    # フィールドのシリアライズ中に Context.get() で文脈を取り出す
    is_self = fields.Function(
        lambda obj: obj is Context[CtxDict].get()["current_user"]
    )


# with ブロックの中だけ文脈が有効になる(ブロックを抜ければ自動で破棄)
with Context[CtxDict]({"current_user": current_user}):
    result = UserSchema().dump(user)

contextvars ベースの低レベルな書き方も可能です。

# ✅ 4.x(新):標準ライブラリの ContextVar を直接使う
import contextvars

_current_user: contextvars.ContextVar = contextvars.ContextVar("current_user")

# 値をセットしてから dump/load を呼ぶ
token = _current_user.set(current_user)
try:
    result = UserSchema().dump(user)
finally:
    _current_user.reset(token)   # 後始末を忘れない

⚠️ experimental.Context は実験的 API:名前のとおり安定保証の対象外で、将来のマイナーバージョンで変わる可能性があります。本番採用するなら、呼び出しを薄いラッパー関数に閉じ込め、仕様変更時の影響を一箇所に局所化しておくのが安全です。

なぜこれが優れているのか? schema.contextスキーマインスタンスに可変状態を持たせる設計でした。同じスキーマインスタンスを使い回すと前の文脈が残り、並行処理では状態が混線します(典型的な共有可変状態のバグ)。contextvars実行コンテキストごとに独立した値を保持するため、async / スレッドをまたいでも状態が漏れません。with Context(...) の構文は、文脈の有効範囲がブロックとして可視化され、ブロックを抜ければ自動で破棄される——スコープと生存期間が一致する、より安全で読みやすいモデルです。


8. @validates が複数フィールド対応+メソッドが data_key を受け取る

3.x の @validates は 1 つのフィールド名しか取れませんでした。4.x では複数のフィールド名を渡せるようになり、検証メソッドはどのフィールドを処理中かを示す data_key を引数で受け取ります。

# ❌ 3.x(旧):フィールドごとに同じ検証を重複定義しがち
from marshmallow import Schema, fields, validates, ValidationError


class ItemSchema(Schema):
    quantity = fields.Integer()
    reserved = fields.Integer()

    @validates("quantity")
    def validate_quantity(self, value):
        if value < 0:
            raise ValidationError("0 以上にしてください。")

    @validates("reserved")
    def validate_reserved(self, value):
        if value < 0:
            raise ValidationError("0 以上にしてください。")
# ✅ 4.x(新):複数フィールドを 1 メソッドで検証し、data_key でメッセージを動的化
from marshmallow import Schema, fields, validates, ValidationError


class ItemSchema(Schema):
    quantity = fields.Integer()
    reserved = fields.Integer()

    @validates("quantity", "reserved")
    def validate_non_negative(self, value, data_key):
        if value < 0:
            # data_key で「どのフィールドのエラーか」をメッセージに反映できる
            raise ValidationError(f"{data_key} は 0 以上である必要があります。")

なぜこれが優れているのか? 3.x では「同じルールを複数フィールドに適用する」たびにメソッドを複製する必要があり、DRY 違反の温床でした。複数フィールド対応により、共通の検証ロジックを 1 箇所に集約できます。さらに data_key を受け取れることで、エラーメッセージを処理中のフィールド名で動的に組み立てられ、「どこで落ちたか」が利用者に伝わりやすくなります。重複の削減と情報量の増加を同時に達成する、よく考えられた拡張です。

⚠️ シグネチャ変更に注意:3.x の def v(self, value): は、4.x では def v(self, value, data_key): に変わります。既存の @validates メソッドはすべて引数 data_key を追加する必要があります。これも grep で網羅的に洗い出してください。


9. カスタムフィールドのジェネリック化と _bind_to_schema の引数名

独自フィールドを定義している場合、2 つの変更があります。

9-1. カスタムフィールドは fields.Field[T] でジェネリックに

カスタムフィールドは、デシリアライズ後の型 T をジェネリックパラメータとして指定するようになりました。

# ❌ 3.x(旧)
from marshmallow import fields


class PositiveInt(fields.Field):
    def _deserialize(self, value, attr, data, **kwargs):
        return int(value)
# ✅ 4.x(新):デシリアライズ後の型を型引数で明示する
from marshmallow import fields


class PositiveInt(fields.Field[int]):   # → load 後の型は int
    def _deserialize(self, value, attr, data, **kwargs) -> int:
        return int(value)

9-2. _bind_to_schema の第 2 引数 schemaparent

スキーマへのバインド時に呼ばれる _bind_to_schema の引数名が schema から parent に変わりました。フィールドは別のフィールド(ListNested)の内側にもバインドされ得るため、「親」を指す parent の方が正確です。

# ❌ 3.x(旧)
class MyField(fields.Field):
    def _bind_to_schema(self, field_name, schema):
        super()._bind_to_schema(field_name, schema)
        # schema を参照する処理...
# ✅ 4.x(新):引数名が parent に(バインド先はスキーマとは限らないため)
class MyField(fields.Field[str]):
    def _bind_to_schema(self, field_name, parent):
        super()._bind_to_schema(field_name, parent)
        # parent を参照する処理...

なぜこれが優れているのか? fields.Field[T] のジェネリック化は、型チェッカー(mypy / pyright)がカスタムフィールドの戻り型を理解できることを意味します。load() の結果が Any ではなく int として推論され、IDE 補完と静的解析が効きます。_bind_to_schemaparent への改名は、バインド先が必ずしもトップレベルの Schema ではない(fields.List の中など)という実態に名前を合わせたもので、誤解を招く命名の是正です。


10. 標準ライブラリ化された処理:日付ユーティリティと TimeDelta

marshmallow が内部に抱えていた一部のユーティリティが削除され、Python 標準ライブラリを直接使う方針になりました。

10-1. 日付ユーティリティの削除

marshmallow.utils.from_iso_date のような独自の日付パーサ/フォーマッタは削除されました。標準ライブラリの同等機能に置き換えます。

# ❌ 3.x(旧):marshmallow の内部ユーティリティを呼んでいた
from marshmallow.utils import from_iso_date

d = from_iso_date("2026-06-26")
# ✅ 4.x(新):標準ライブラリを使う
from datetime import date
from email.utils import parsedate_to_datetime

d = date.fromisoformat("2026-06-26")     # ISO 日付のパース
s = d.isoformat()                        # ISO 文字列へ
dt = parsedate_to_datetime("Wed, 26 Jun 2026 12:00:00 GMT")  # RFC 822 形式

10-2. TimeDelta はマイクロ秒を保持/serialization_type 削除

fields.TimeDelta は、float から変換する際にマイクロ秒精度を保持するよう挙動が変わり、serialization_type 引数が削除されました。

# ✅ 4.x:TimeDelta は精度を保ったまま timedelta を扱う
from datetime import timedelta
from marshmallow import Schema, fields


class JobSchema(Schema):
    # precision で出力単位を指定(serialization_type は廃止)
    duration = fields.TimeDelta(precision="seconds")


# dump/load でマイクロ秒以下が丸められず保持される
JobSchema().load({"duration": 1.5})   # → {'duration': timedelta(seconds=1, microseconds=500000)}

なぜこれが優れているのか? 日付処理を標準ライブラリへ寄せることは、marshmallow の依存とメンテナンス範囲を縮小します。date.fromisoformat は CPython 本体がメンテナンスする枯れた実装であり、ライブラリ独自実装より信頼性が高く、Python のバージョンアップとともに改善されます。「自前で持つより、標準にあるものを使う」というのは、コスト効率と信頼性の両面で正しい判断です。TimeDelta の精度保持は、丸め誤差による「1.5 秒が 1 秒になる」類のデータ欠損を防ぎます。

💡 Python のバージョン:4.x はやや新しめの Python を要求します。3.x からの移行と同時に、サポートの切れた古い Python ランタイムを使っていないかも確認しておくと、依存解決の衝突を避けられます。


11. 早見表:grep して機械的に置換する

ここまでの全変更を 1 枚にまとめます。移行作業はこの表を「チェックリスト」として、各行を grep で洗い出しながら潰していきます。

3.x(旧)4.x(新)種別
fields.Str(missing=...)fields.Str(load_default=...)入力時デフォルト
fields.Int(default=...)fields.Int(dump_default=...)出力時デフォルト
fields.Number() / fields.Mapping() / fields.Field() の直接利用fields.Integer() / Float() / Decimal() / fields.Dict()抽象基底クラスのインスタンス化禁止
validate=False を返すValidationErrorraise するバリデータの返り値
@post_dump(pass_many=True)@post_dump(pass_collection=True)デコレータ引数名
class Meta: fields = (...) / additionalフィールドを明示的に宣言暗黙のフィールド生成廃止
schema.context = {...}contextvars.ContextVar / experimental.Contextコンテキスト受け渡し
@validates("name") を個別に複数定義@validates("name", "nickname") + メソッドが data_key を受け取る複数フィールド対応
class MyField(fields.Field)class MyField(fields.Field[T])カスタムフィールドのジェネリック化
_bind_to_schema(self, field_name, schema)_bind_to_schema(self, field_name, parent)カスタムフィールドの引数名
marshmallow.utils.from_iso_date標準ライブラリ(date.fromisoformat 等)日付ユーティリティ削除
fields.TimeDelta(serialization_type=...)fields.TimeDelta(precision=...)(マイクロ秒保持)TimeDelta の仕様変更

洗い出し用の grep パターン

リポジトリ全体に対して、以下のパターンで該当箇所を機械的に列挙します。

# 最頻出:missing / default(ヒット件数が最も多い)
grep -rn "missing=" --include="*.py" .
grep -rn "default=" --include="*.py" .

# デコレータ引数の改名
grep -rn "pass_many=" --include="*.py" .

# 抽象基底クラスの直接利用
grep -rn "fields\.Number(" --include="*.py" .
grep -rn "fields\.Mapping(" --include="*.py" .
grep -rn "fields\.Field(" --include="*.py" .

# コンテキスト受け渡し(Schema.context の参照・代入)
grep -rn "\.context" --include="*.py" .

# 暗黙フィールド生成・カスタムフィールド
grep -rn "additional" --include="*.py" .
grep -rn "_bind_to_schema" --include="*.py" .

⚠️ default=missing= は誤検出が多いgrep "default=" は marshmallow と無関係なコード(dict.get の引数、関数のデフォルト引数など)も大量にヒットします。機械的に sed で一括置換してはいけません。ヒットした各行が「marshmallow のフィールド定義か」を目視で確認し、第 2 章の区別(load 用か dump 用か)に従って 1 つずつ置換してください。grep は「見つける」ための道具であり、「置き換える」のは人間の判断です。


12. テストで退行を止める:往復のゴールデンテスト

移行で最も怖いのは、型シグネチャは通るのに出力 JSON の中身が静かに変わる退行です。dump_default の付け忘れ、pass_collection の漏れ、context 移行のミス——これらはエラーを出さず、本番のレスポンスだけが変わります。これを止める唯一の方法は、移行前の入出力をテストで固定(ゴールデン化)することです。

# tests/test_user_schema.py
import pytest
from app.schemas import UserSchema


def test_dump_golden():
    """dump の出力を「既知の正解」として固定する(移行で中身が変われば落ちる)"""
    user = {"name": "友田", "email": "tomoda@example.com", "role": "member"}
    result = UserSchema().dump(user)
    assert result == {
        "name": "友田",
        "email": "tomoda@example.com",
        "role": "member",
    }


def test_load_roundtrip():
    """load → dump の往復で値が保存されることを確認する"""
    payload = {"name": "友田", "email": "tomoda@example.com"}
    loaded = UserSchema().load(payload)
    dumped = UserSchema().dump(loaded)
    # 往復しても入力した値が失われない
    assert dumped["name"] == payload["name"]
    assert dumped["email"] == payload["email"]


def test_load_rejects_invalid_email():
    """検証失敗の挙動も固定する(移行でエラーメッセージ構造が変わっていないか)"""
    from marshmallow import ValidationError

    with pytest.raises(ValidationError) as exc:
        UserSchema().load({"name": "友田", "email": "not-an-email"})
    assert "email" in exc.value.messages

CI で「バージョンを上げる PR」を緑にする

移行は 1 つの PR で完結させ、その PR の CI が緑になることを「移行完了」の定義にします。理想は、依存を上げる差分とコード修正を同じ PR に含め、テストスイートが全部通ることをマージ条件にすることです。

# .github/workflows/test.yml(要点のみ)
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install -r requirements.txt   # marshmallow>=4,<5 を固定済み
      # 非推奨警告をエラー化し、移行漏れを検出する
      - run: python -W error::DeprecationWarning -m pytest

なぜこれが優れているのか? ゴールデンテストは、「型は通るが意味が変わった」という、移行で最も見つけにくいクラスのバグを機械的に検出します。CLAUDE.md の「検証パスを実装前に用意する」という原則のとおり、移行作業そのものよりも先に、現状の入出力をテストで固定しておくのが理想です。固定された期待値があれば、移行は「テストを赤から緑に戻す作業」という、ゴールの明確なタスクに変わります。これが「機械的置換 + テストで担保」という本記事の主題の核心です。


13. よくある落とし穴

移行で実際につまずきやすいポイントを整理します。

load_defaultdump_default の意味の取り違え

最頻出かつ最も静かなバグです。「missing は load 用、default は dump 用」という 3.x の対応を覚えていないと、置換時に逆を当ててしまいます。load_default = 入力が欠けたときに補う値、dump_default = 出力が欠けたときに補う値。grep でヒットした各箇所が missing 由来(→ load_default)か default 由来(→ dump_default)かを、必ず元の引数名で判断してください。

@validates のシグネチャ変更漏れ

def v(self, value): のままだと、4.x で data_key が渡された瞬間に TypeError になります。@validates を使っている全メソッドに data_key 引数を追加したか、第 8 章の grep で確認します。

サードパーティ・エコシステムの対応バージョン確認

marshmallow は単体で使われることは少なく、周辺ライブラリと組み合わさっています。それらが marshmallow 4 に対応したバージョンを出しているかを、上げる前に必ず確認してください。

ライブラリ確認すること
marshmallow-sqlalchemymarshmallow 4 対応バージョンか(SQLAlchemyAutoSchema を使っている場合は必須)
flask-smorestmarshmallow 4 を要求/許容するバージョンか(API 全体のスキーマ基盤)
apispecOpenAPI 生成が marshmallow 4 のフィールドに追従しているか
webargsリクエストパースで marshmallow 4 を受け付けるか

これらのいずれかが marshmallow 4 に未対応だと、marshmallow 単体は上がってもアプリ全体がインストール時に依存解決で失敗します。移行を始める前に、pip install "marshmallow>=4"ドライランや別環境で試し、依存の衝突がないかを先に確かめるのが安全です。

ネストスキーマの連鎖的な影響

fields.Nested で深く入れ子になったスキーマは、子スキーマの変更が親の出力に波及します。1 つのスキーマを直したら、それをネストしている親スキーマのゴールデンテストも走らせ、出力が変わっていないことを確認してください。


結論:恐れず、しかし機械的に、テストを盾にして上げる

marshmallow 3 → 4 の移行は、未知の難事ではなく、正体の分かった有限個の置換作業です。本記事の要点を再掲します。

  1. 戦略は 4 ステップ——①バージョン固定 → ②3.x で DeprecationWarning をエラー化して潰す → ③4.x へ上げて対応表で置換 → ④テストで担保。各ステップを小さな PR に分ける。
  2. 最頻出は missing/defaultload_default/dump_defaultpass_manypass_collection。grep で全箇所を洗い出すが、置換は人間が 1 つずつ判断する。
  3. バリデータは False を返す方式が廃止され、必ず ValidationErrorraise する方式に統一された(戻り値忘れのバグを根絶する安全な変更)。
  4. Schema.context は廃止され、contextvars.ContextVar / experimental.Context へ移行する(共有可変状態を排し、async/スレッドで安全に)。
  5. 抽象基底クラス(Number/Mapping/Field)の直接利用が禁止され、暗黙フィールド生成も廃止——型と意図をスキーマに明示することが強制される。
  6. 退行はテストで止める。移行前に load/dump の往復をゴールデン化し、CI でバージョンアップ PR を緑にして初めて「完了」とする。

メジャーバージョンアップを先送りにし続けることは、それ自体が静かに膨らむ技術的負債です。対応表と grep で機械的に洗い出し、ゴールデンテストを盾にして小さく上げる——この規律があれば、移行は「いつか来る大仕事」ではなく「今週終わらせられる作業」になります。各 API の意味で迷ったら、土台となる marshmallow 実践ガイド に立ち返ってください。

公式の一次情報も、移行作業の傍らに開いておくことを強くお勧めします。


型安全なバックエンド設計のご相談

筆者は、経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python / Flask / SQLAlchemy / PostgreSQL で設計・運用し、その境界バリデーションを marshmallow で実装してきました。ライブラリのメジャーバージョンアップは、放置すれば技術的負債、計画的に行えば品質改善の好機です。破壊的変更の影響範囲の調査、ゴールデンテストによる退行検出網の整備、段階的な移行 PR の設計と CI 整備まで、生成 AI を活用して高速かつ安全に伴走します。marshmallow を含む Python バックエンドのバージョンアップ・型安全化・リファクタリングについて、お気軽にご相談ください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る