# Complete Guide to marshmallow 3 → 4 Migration: Crossing the Breaking Changes Safely

> Organizing marshmallow 4's breaking changes faithfully to the official upgrade guide. From missing/default→load_default/dump_default, pass_many→pass_collection, the ban on instantiating abstract base classes, validators must raise ValidationError, Schema.context→contextvars, to the abolition of implicit fields—shown before/after, with a procedure for migrating in stages.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: Python, marshmallow, マイグレーション, 型安全, テスト, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/marshmallow-3-to-4-migration-guide
- Category: marshmallow
- Pillar guide: https://tomodahinata.com/en/blog/marshmallow-python-serialization-validation-production-guide

## Key points

- marshmallow 4 includes many breaking changes at the core of validation. This article's correspondence table lets you mechanically replace before/after
- The most frequent are missing/default→load_default/dump_default and pass_many→pass_collection. Find every occurrence with grep
- The validator method of returning False is abolished; it's unified to always raising ValidationError
- Schema.context is abolished. Migrate to contextvars.ContextVar / experimental.Context
- Stop regressions with version pinning and tests while migrating in stages, safely. Guarantee it in CI

---

## **Introduction: a migration decomposes into "mechanical replacement + guaranteeing with tests"**

Hearing "major version upgrade" may make you brace yourself. But the marshmallow 3 → 4 migration is a set of **a finite number of breaking changes whose identities are known.** Rather than learning new concepts, it **decomposes into the work of mechanically replacing specific patterns in existing code with other patterns, and confirming with tests that no regression occurred.** This article is that "correspondence table" and "procedure manual."

Why bother raising to 4.x now? The reason is not feature additions but **the continuity of maintenance and security updates.** The 3.x line has entered maintenance mode, and future bug fixes, dependency-library following, and vulnerability response will be the main battlefield of the 4.x line. Staying on an old version forever approaches the state of "running production on a foundation past support"—which is itself technical debt and a risk. **The iron rule is to finish version upgrades small, not when you want to upgrade but when you can.**

> 💡 **Prerequisite knowledge**: marshmallow's own design philosophy (`Schema` / `fields` / `load()` / `dump()` / the three layers of validation / the security boundary) is systematically explained in the [marshmallow practical guide](/blog/marshmallow-python-serialization-validation-production-guide). This article is, as its sequel, a **deep dive specialized in the 3 → 4 migration.** When the meaning of an API is unclear, first return to the pillar article.

> 💡 **Versions covered in this article**: the current stable version is marshmallow **4.3.0** (as of April 2026). The 3.x line is in maintenance mode with new features stopped. All before/after in this article are 4.x breaking changes, based on the official "[Upgrading to newer releases](https://marshmallow.readthedocs.io/en/stable/upgrading.html)" guide.

---

## **1. The whole picture of the migration strategy: "upgrade without breaking" in 4 steps**

Running `pip install -U marshmallow` haphazardly and getting buried in a mountain of errors—this is the worst way to proceed. A safe migration takes the following 4 steps **in order.** Make each step an independent small PR, easy to review and easy to revert if a problem arises.

```text
① Pin the version    Lock the current 3.x, putting the migration into a state you can "pause and resume anytime"
② Crush the warnings  Turn DeprecationWarning into errors on the final 3.x and fix the features that disappear in 4.x first
③ Raise to 4.x        Remove the pin, raise to 4.x, and replace remaining breaking changes per the correspondence table
④ Guarantee with tests  Fix the load/dump round-trip with golden tests, make CI green, and call it done
```

### **① First, pin the version to 3.x**

Before starting the migration, explicitly pin the currently-running version. This prevents the accident of "4.x got in unnoticed and broke things," turning the migration into **work you control by your own will.**

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

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

### **② Crush "deprecation warnings" first on the final 3.x**

Many features removed in 4.x already emit a **`DeprecationWarning`** in late 3.x versions. **Erasing all warnings while still on 3.x, before raising to 4.x**—this is the single biggest trick that dramatically eases the migration. Code emitting warnings becomes, in 4.x, not a "warning" but an "error."

To reliably detect warnings, **promote warnings to exceptions** with Python's `-W error::DeprecationWarning` flag and run the tests.

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

If you use pytest, you can do the same in the config file.

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

> 💡 **Why separate this step**: crushing warnings while on 3.x keeps each fix from mixing with "behavior changes after raising to 4.x," making **root-cause isolation overwhelmingly easier.** Changes like `@post_dump(pass_many=True)` are accepted under the new name even at the 3.x stage in some cases, and fixing them first shrinks the diff at 4.x migration time. Always pass through the waypoint of "3.x with zero warnings."

### **③ Raise to 4.x and replace the remaining breaking changes**

Once you've finished crushing warnings, remove the pin and raise to 4.x. Here, use **the correspondence table from Chapter 2 onward** to mechanically replace the remaining breaking changes.

```bash
pip install "marshmallow>=4,<5"
```

### **④ Stop regressions with tests**

Finally, **fix with tests** that the input/output of `load()` / `dump()` hasn't changed before and after the migration (detailed in Chapter 12). The most frightening regression in a migration is that **the type signature passes but the content of the output JSON quietly changes.** Only once you've built a state where CI can mechanically detect this can you call it "migration complete."

---

From here, I'll expand each row of the correspondence table **before / after**, one at a time. If you want to survey the whole picture first, glance at the quick-reference table at the end of this chapter before reading on.

## **2. `missing` / `default` → `load_default` / `dump_default` (most frequent)**

This is **the most frequently occurring and most important change.** 3.x's `missing=` (the load-time default) and `default=` (the dump-time default) were renamed in 4.x to `load_default=` / `dump_default=`.

```python
# ❌ 3.x（旧）
from marshmallow import Schema, fields


class UserSchema(Schema):
    name = fields.Str(missing="Monty")    # load 時に欠落していたらこの値
    answer = fields.Int(default=42)       # dump 時に欠落していたらこの値
```

```python
# ✅ 4.x（新）
from marshmallow import Schema, fields


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

**Why is this better?**
3.x's names `missing` / `default` **didn't let you read from the name "when" the default applies.** `missing` is at input, `default` is at output—if you didn't remember this correspondence, you'd misread the code. `load_default` / `dump_default` correspond one-to-one with marshmallow's two big operations, `load()` / `dump()`. "`load_default` is `load()`'s default, `dump_default` is `dump()`'s default"—**the meaning is determined just by reading the name.** This is a textbook improvement of the readability principle "intention-revealing naming."

> ⚠️ **Beware of confusing the meaning**: "the input-time default" and "the output-time default" are different things. Writing `dump_default` where you should set `load_default` means missing input isn't filled in and the validation behavior changes. Rather than a mechanical replacement, **judge one at a time whether it's for `load` or `dump`** (grep usage is in Chapter 10).

---

## **3. The ban on instantiating abstract base classes: `Number()` / `Mapping()` / `Field()` can't be used**

`fields.Number()`, `fields.Field()`, and `fields.Mapping()` are **abstract base classes**, and in 4.x **they can no longer be instantiated directly.** Use concrete fields that express a specific type.

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


class ProductSchema(Schema):
    price = fields.Number()       # 整数か小数か曖昧
    meta = fields.Mapping()       # キー・値の型が不明
    raw = fields.Field()          # 何でも通す
```

```python
# ✅ 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()
```

**Why is this better?**
An abstract type like `fields.Number()` doesn't let you read "is it an integer or a decimal" from the schema definition, and the validation is loose too. Being forced to migrate to concrete types seems like a chore at first glance, but it's actually **a good chance to write into the code "what type this field really is."** Whether `price` is `Decimal` or `Float` is a design decision directly tied to the accuracy of money calculations (the presence or absence of error). By banning ambiguous base classes, marshmallow encourages **engraving the type's intent into the schema.** This is the principle of type safety itself.

---

## **4. Validators must always `raise` `ValidationError` (the method of returning `False` is abolished)**

In 3.x, a validator function could express validation failure by **returning `False`.** In 4.x **this method is abolished**, and on validation failure you must always **`raise`** a `ValidationError`.

```python
# ❌ 3.x（旧）：False を返して失敗を表現
from marshmallow import Schema, fields


class LoginSchema(Schema):
    # 一致しなければ False が返り、それが検証失敗として扱われていた
    secret = fields.Str(validate=lambda x: x == "password")
```

```python
# ✅ 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)
```

**Why is this better?**
The "return `False`" method had two fatal weaknesses. First, **a function that returns `None` or forgets to write its return value is misjudged as "success"**—a function forgetting `return` implicitly returns `None` (not a falsy value but a truth-unknown value), and validation passes through. This is one of the hardest-to-find bugs peculiar to validation. Second, **`False` carries no information about "why it failed."** With the method of `raise`ing a `ValidationError`, a failure always comes with a message, and a forgotten return value clearly behaves as "doesn't throw an exception = success." The discipline of "convey failure by exception, not return value" is the correct design for reliability, making errors harder to swallow.

---

## **5. The decorator argument `pass_many` → `pass_collection`**

In the `@pre_load` / `@post_load` / `@pre_dump` / `@post_dump` decorators, the argument used to batch-process the whole collection (the input/output of `many=True`) was renamed from `pass_many` to **`pass_collection`.**

```python
# ❌ 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}
```

```python
# ✅ 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}
```

Only the argument name changes; the signature where the method receives `many` is unchanged. This is, alongside Chapter 2's `load_default`, **a representative example of mechanical replacement**—just batch-replace `pass_many=` with `pass_collection=`. A natural rename where the intent "pass the whole collection" became clear with the name `pass_collection`.

---

## **6. Implicit field generation is abolished: `class Meta: fields` / `additional` can't be used**

In 3.x, using `class Meta`'s `fields` tuple or `additional`, you could **implicitly generate fields not declared as class attributes.** In 4.x this mechanism is abolished, and **all fields you use must be declared explicitly.**

```python
# ❌ 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",)
```

```python
# ✅ 4.x（新）：すべてのフィールドを明示宣言する
from marshmallow import Schema, fields


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

**Why is this better?**
Fields generated by `Meta.fields = ("name", ...)` had **unclear types**—you couldn't read from the schema definition whether `name` is a string or an integer. Forcing explicit declaration makes each field's type, validation, `dump_only`, and other attributes all appear in the schema body. This raises both readability and type safety. Note that the legitimate use case of **wanting to auto-generate from a SQLAlchemy model** continues to be handled by `marshmallow-sqlalchemy`'s `SQLAlchemyAutoSchema`—the abolition of "implicit field generation" is also a consolidation onto that canonical route.

---

## **7. `Schema.context` abolished → `contextvars` / `experimental.Context`**

In 3.x, via `schema.context = {...}`, you could pass external context (the request user, locale, etc.) to serialization/deserialization. In 4.x **`Schema.context` is abolished**, and you use Python's standard `contextvars.ContextVar` or marshmallow's **experimental `Context` manager.**

```python
# ❌ 3.x（旧）：schema.context に状態を詰める
schema = UserSchema()
schema.context["current_user"] = current_user
result = schema.dump(user)
# メソッド内では self.context["current_user"] で参照していた
```

```python
# ✅ 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)
```

A lower-level way of writing based on `contextvars` is also possible.

```python
# ✅ 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` is an experimental API**: as the name says, it's outside the stability guarantee and may change in a future minor version. If you adopt it in production, **confine the calls in a thin wrapper function** to localize the impact of spec changes to one place—that's safe.

**Why is this better?**
`schema.context` was a design that **gave the schema instance mutable state.** Reusing the same schema instance leaves the previous context, and in concurrent processing the state gets crossed (a typical shared-mutable-state bug). `contextvars` holds **an independent value per execution context**, so state doesn't leak across `async` / threads. The `with Context(...)` syntax is a safer, more readable model where **the context's valid range is visualized as a block and automatically discarded on exiting the block**—scope and lifetime coincide.

---

## **8. `@validates` supports multiple fields + the method receives `data_key`**

3.x's `@validates` could take only one field name. In 4.x you can pass **multiple field names**, and the validation method receives **`data_key`** indicating which field is being processed.

```python
# ❌ 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 以上にしてください。")
```

```python
# ✅ 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 以上である必要があります。")
```

**Why is this better?**
In 3.x, every time you "apply the same rule to multiple fields" you had to duplicate the method—a breeding ground for DRY violations. Multi-field support lets you consolidate common validation logic in one place. Further, being able to receive `data_key` lets you dynamically build the error message with the field name being processed, making "where it failed" easier to convey to the user. A well-considered extension that achieves both reduced duplication and increased information at once.

> ⚠️ **Beware of the signature change**: 3.x's `def v(self, value):` becomes `def v(self, value, data_key):` in 4.x. **All existing `@validates` methods need the `data_key` argument added.** Find these exhaustively with grep too.

---

## **9. Making custom fields generic, and the argument name of `_bind_to_schema`**

If you define custom fields, there are two changes.

### **9-1. Custom fields become generic with `fields.Field[T]`**

Custom fields now specify the post-deserialization type `T` as a generic parameter.

```python
# ❌ 3.x（旧）
from marshmallow import fields


class PositiveInt(fields.Field):
    def _deserialize(self, value, attr, data, **kwargs):
        return int(value)
```

```python
# ✅ 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`'s second argument `schema` → `parent`**

The argument name of `_bind_to_schema`, called at bind-to-schema time, changed from `schema` to **`parent`.** Because a field can also be bound inside another field (`List` or `Nested`), `parent` indicating the "parent" is more accurate.

```python
# ❌ 3.x（旧）
class MyField(fields.Field):
    def _bind_to_schema(self, field_name, schema):
        super()._bind_to_schema(field_name, schema)
        # schema を参照する処理...
```

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

**Why is this better?**
Making `fields.Field[T]` generic means **the type checker (mypy / pyright) can understand the custom field's return type.** The result of `load()` is inferred as `int` rather than `Any`, and IDE completion and static analysis work. The rename of `_bind_to_schema` to `parent` **matches the name to the reality** that the bind target isn't necessarily the top-level `Schema` (inside `fields.List`, etc.)—a correction of misleading naming.

---

## **10. Processing moved to the standard library: date utilities and `TimeDelta`**

Some utilities marshmallow held internally were removed, and the policy moved to **using Python's standard library directly.**

### **10-1. Removal of date utilities**

Custom date parsers/formatters like `marshmallow.utils.from_iso_date` were removed. Replace them with the standard library's equivalents.

```python
# ❌ 3.x（旧）：marshmallow の内部ユーティリティを呼んでいた
from marshmallow.utils import from_iso_date

d = from_iso_date("2026-06-26")
```

```python
# ✅ 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` retains microseconds / `serialization_type` removed**

`fields.TimeDelta`'s behavior changed to **retain microsecond precision** when converting from a `float`, and the `serialization_type` argument was removed.

```python
# ✅ 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)}
```

**Why is this better?**
Moving date processing to the standard library **shrinks marshmallow's dependency and maintenance scope.** `date.fromisoformat` is a mature implementation maintained by CPython itself, more reliable than a library's own implementation, and improves with Python version upgrades. "Use what's in the standard rather than holding it yourself" is the correct judgment on both cost efficiency and reliability. `TimeDelta`'s precision retention prevents data loss of the kind "1.5 seconds becomes 1 second" from rounding error.

> 💡 **Python version**: 4.x requires a somewhat newer Python. Migrating from 3.x is a good time to also check you aren't using an out-of-support old Python runtime, avoiding dependency-resolution conflicts.

---

## **11. Quick reference: grep and replace mechanically**

Let me summarize all the changes so far on one sheet. The migration uses this table as a "checklist," crushing each row while finding it with grep.

| 3.x (old) | 4.x (new) | Category |
| --- | --- | --- |
| `fields.Str(missing=...)` | `fields.Str(load_default=...)` | Input-time default |
| `fields.Int(default=...)` | `fields.Int(dump_default=...)` | Output-time default |
| Direct use of `fields.Number()` / `fields.Mapping()` / `fields.Field()` | `fields.Integer()` / `Float()` / `Decimal()` / `fields.Dict()` | Ban on instantiating abstract base classes |
| `validate=` returns `False` | `raise` `ValidationError` | Validator return value |
| `@post_dump(pass_many=True)` | `@post_dump(pass_collection=True)` | Decorator argument name |
| `class Meta: fields = (...)` / `additional` | Declare fields explicitly | Abolition of implicit field generation |
| `schema.context = {...}` | `contextvars.ContextVar` / `experimental.Context` | Context passing |
| Define `@validates("name")` individually, multiple | `@validates("name", "nickname")` + the method receives `data_key` | Multiple-field support |
| `class MyField(fields.Field)` | `class MyField(fields.Field[T])` | Making custom fields generic |
| `_bind_to_schema(self, field_name, schema)` | `_bind_to_schema(self, field_name, parent)` | Custom-field argument name |
| `marshmallow.utils.from_iso_date` etc. | Standard library (`date.fromisoformat` etc.) | Removal of date utilities |
| `fields.TimeDelta(serialization_type=...)` | `fields.TimeDelta(precision=...)` (microseconds retained) | TimeDelta spec change |

### **grep patterns for finding them**

Against the whole repository, mechanically enumerate the relevant places with the following patterns.

```bash
# 最頻出：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=` and `missing=` have many false positives**: `grep "default="` also hits tons of code unrelated to marshmallow (the argument of `dict.get`, default arguments of functions, etc.). **Don't batch-replace with sed mechanically.** Visually confirm whether each hit line is "a marshmallow field definition," and replace one at a time per Chapter 2's distinction (for load or for dump). grep is a tool for "finding," and "replacing" is human judgment.

---

## **12. Stop regressions with tests: round-trip golden tests**

The most frightening thing in a migration is the regression where **the type signature passes but the content of the output JSON quietly changes.** Forgetting to attach `dump_default`, missing `pass_collection`, a mistake in the `context` migration—these emit no errors, and only the production response changes. The only way to stop this is to **fix (goldenize) the pre-migration input/output with tests.**

```python
# 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
```

### **Make the "version-upgrade PR" green in CI**

Complete the migration in one PR, and make **that PR's CI going green the definition of "migration complete."** Ideally, include the dependency-upgrade diff and the code fixes in the same PR, and make the whole test suite passing a merge condition.

```yaml
# .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
```

**Why is this better?**
Golden tests mechanically detect the hardest-to-find class of bug in a migration: "the type passes but the meaning changed." Per CLAUDE.md's principle "prepare the verification path before implementing," ideally **before the migration work itself**, fix the current input/output with tests. With fixed expected values, the migration turns into a goal-clear task: "the work of turning tests from red back to green." This is the core of this article's theme, "mechanical replacement + guaranteeing with tests."

---

## **13. Common pitfalls**

Let me organize the points you actually stumble on in a migration.

### **Confusing the meaning of `load_default` and `dump_default`**

The most frequent and quietest bug. Without remembering 3.x's correspondence "`missing` is for load, `default` is for dump," you apply the reverse during replacement. **`load_default` = the value to fill in when input is missing, `dump_default` = the value to fill in when output is missing.** Always judge whether each grep hit derives from `missing` (→ `load_default`) or `default` (→ `dump_default`) by the original argument name.

### **Missing the `@validates` signature change**

If `def v(self, value):` is left as-is, you get a `TypeError` the moment 4.x passes `data_key`. Confirm with the Chapter 8 grep that you've added the `data_key` argument to all methods using `@validates`.

### **Confirm the supported version of the third-party ecosystem**

marshmallow is rarely used standalone; it combines with surrounding libraries. **Always confirm before upgrading whether they've released a version supporting marshmallow 4.**

| Library | What to confirm |
| --- | --- |
| `marshmallow-sqlalchemy` | Is it a marshmallow-4-supporting version (mandatory if you use `SQLAlchemyAutoSchema`) |
| `flask-smorest` | Is it a version that requires/allows marshmallow 4 (the schema foundation of the whole API) |
| `apispec` | Does OpenAPI generation follow marshmallow 4's fields |
| `webargs` | Does request parsing accept marshmallow 4 |

If any of these don't support marshmallow 4, **marshmallow itself may upgrade but the whole app fails dependency resolution at install time.** Before starting the migration, **try `pip install "marshmallow>=4"` in a dry run or a separate environment** and confirm there are no dependency conflicts first—that's safe.

### **The cascading impact of nested schemas**

Schemas deeply nested with `fields.Nested` **propagate a child schema's change into the parent's output.** When you fix one schema, also run the golden test of the parent schema that nests it, and confirm the output hasn't changed.

---

## **Conclusion: upgrade without fear, but mechanically, with tests as your shield**

The marshmallow 3 → 4 migration is not an unknown ordeal but **a finite number of replacement tasks whose identities are known.** Let me restate this article's points.

1. **The strategy is 4 steps**—① pin the version → ② turn `DeprecationWarning` into errors on 3.x and crush them → ③ raise to 4.x and replace per the correspondence table → ④ guarantee with tests. Split each step into a small PR.
2. **The most frequent are `missing`/`default` → `load_default`/`dump_default` and `pass_many` → `pass_collection`.** Find every occurrence with grep, but a human judges each replacement one at a time.
3. **The validator method of returning `False` is abolished**, unified to always `raise`ing `ValidationError` (a safe change eradicating the return-forgetting bug).
4. **`Schema.context` is abolished**, migrating to `contextvars.ContextVar` / `experimental.Context` (eliminating shared mutable state, safe with `async`/threads).
5. **Direct use of abstract base classes (`Number`/`Mapping`/`Field`) is banned**, and implicit field generation is abolished—you're forced to make the type and intent explicit in the schema.
6. **Stop regressions with tests.** Goldenize the load/dump round-trip before the migration, and only call it "done" once the version-upgrade PR is green in CI.

Continuing to postpone a major version upgrade is itself technical debt that quietly bloats. **Find them mechanically with the correspondence table and grep, and upgrade small with golden tests as your shield**—with this discipline, the migration turns from "a big job that comes someday" into "work you can finish this week." If you're unsure about the meaning of an API, return to the foundational [marshmallow practical guide](/blog/marshmallow-python-serialization-validation-production-guide).

I strongly recommend keeping the official primary sources open alongside the migration work too.

- [Upgrading to newer releases (the canonical 3→4 migration guide)](https://marshmallow.readthedocs.io/en/stable/upgrading.html)
- [Changelog](https://marshmallow.readthedocs.io/en/stable/changelog.html)
- [marshmallow official documentation](https://marshmallow.readthedocs.io/en/stable/)

---

### **Consultation on type-safe backend design**

I have designed and operated the backend of a Minister of Economy, Trade and Industry Award-winning B2B SaaS in **Python / Flask / SQLAlchemy / PostgreSQL**, implementing its boundary validation with marshmallow. A library's major version upgrade is technical debt if left alone, and a good chance for quality improvement if done deliberately. From **investigating the impact scope of breaking changes, building a regression-detection net with golden tests, to designing staged migration PRs and setting up CI**, I'll accompany you fast and safely, leveraging generative AI. Feel free to consult me about version upgrades, type-safety, and refactoring of Python backends including marshmallow.
