# marshmallow Custom Fields and Advanced Validation: Designing Reusable Domain Types

> Explains marshmallow's custom fields faithfully to the official spec. Shown with real code: fields.Field[T] and _serialize/_deserialize, the i18n of make_error and error_messages, reusable domain types like amount (Decimal) / phone (E.164) / enum (fields.Enum), fields.Method/Function/Constant, and extending existing fields.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: Python, marshmallow, バリデーション, 型安全, ドメインモデリング, DRY, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/marshmallow-custom-fields-advanced-validation-guide
- Category: marshmallow
- Pillar guide: https://tomodahinata.com/en/blog/marshmallow-python-serialization-validation-production-guide

## Key points

- Inherit fields.Field[T] and implement _serialize/_deserialize, and you can confine domain-specific type conversion and validation in one place and reuse it
- Send a ValidationError inside _deserialize for invalid input. Make messages i18n-able with default_error_messages and make_error
- Design values with business rules — amount (Decimal rounding), phone number (E.164 normalization), enum (fields.Enum) — as custom fields
- Declare computed values with fields.Method/Function and fixed values with fields.Constant, preventing the scattering of business logic
- Assemble validation logic DRY-ly with subclassing existing fields and composing with validate.And

---

## **Introduction: when the built-in `fields` aren't enough**

`fields.Str()`, `fields.Int()`, `fields.Email()` — marshmallow's built-in fields are powerful, and the majority of an API's input/output can be covered with just these. But a real-world domain inevitably has **values with business rules** that the built-ins can't fully express.

- Amount: hold the currency as `Decimal`, round to 2 decimal places, and never handle it as `float`.
- Phone number: a user inputs both `090-1234-5678` and `09012345678`, but internally you want to always normalize to the E.164 form `+819012345678`.
- Postal codes, SKU codes, internal IDs — values where "the format is fixed, accompanied by validation and normalization."

"Validating such a value with an `if` statement each time and shaping it with `.replace()`" in a view function or the service layer is a typical anti-pattern. The same normalization logic is copied per API, and in one place `+81` is attached but in another it's forgotten — **the knowledge scatters and will definitely clash someday.**

The idea of a **value object** in domain-driven design pays off here. "Amount" and "phone number" aren't mere strings but types with inherent constraints. Apply those constraints **once** at the system boundary, and flow only normalized values inside. marshmallow's **custom fields** are exactly the mechanism for expressing this "one-time normalization / validation at the boundary" as a reusable part.

> 💡 This article is a sequel to [the marshmallow practical guide (v4-compatible)](/blog/marshmallow-python-serialization-validation-production-guide), which explained marshmallow's basics — `Schema` / `load()` / `dump()` / three-layer validation / the security boundary. It proceeds on the premise that `load()` is the entrance to boundary validation and the responsibility separation of `@validates` / `@validates_schema`. If you haven't read it, please go there first.

> 💡 **The version covered in this article**: it assumes marshmallow **4.3.0** (the stable version as of April 2026). As an important v4 change, `fields.Field` was **genericized**, letting you declare with a type argument, `class MyField(fields.Field[T])`, "the Python type `T` this field returns after deserialization." This isn't mere decoration; it has the practical benefit of raising the accuracy of IDE completion and static analysis.

---

## **1. Anatomy of a custom field: `_serialize` and `_deserialize`**

The implementation of a custom field, boiled down, comes down to **writing just 2 methods.** Inherit `fields.Field[T]` and implement the output-direction `_serialize` and the input-direction `_deserialize`.

```python
from marshmallow import ValidationError, fields


class PinCode(fields.Field[list[int]]):
    """暗証番号を「内部では int のリスト、外向きには文字列」として双方向変換するフィールド。"""

    def _serialize(self, value, attr, obj, **kwargs):
        # dump 方向：内部の [1, 2, 3, 4] を "1234" という文字列にする
        if value is None:
            return ""
        return "".join(str(d) for d in value)

    def _deserialize(self, value, attr, data, **kwargs):
        # load 方向：外部の "1234" を内部の [1, 2, 3, 4] に変換し、不正なら弾く
        try:
            return [int(c) for c in value]
        except ValueError as error:
            raise ValidationError("Pin codes must contain only digits.") from error
```

The roles and arguments of the 2 methods are fixed by the official spec as follows.

| Method | Direction | The `value` it receives | What it should return |
| --- | --- | --- | --- |
| `_serialize(self, value, attr, obj, **kwargs)` | `dump` (output) | The internal Python value | A JSON-compatible outward representation |
| `_deserialize(self, value, attr, data, **kwargs)` | `load` (input) | The external raw data | A validated, normalized internal value `T` |

- `_serialize`'s `obj` is the **whole source object**, and `_deserialize`'s `data` is the **whole input dict.** You can use them when you want to reference another field's value, but the basic rule is to complete it with just the 1st argument `value`.
- The value `_deserialize` returns goes into `load()`'s result dict. For `PinCode`, `load({"pin": "1234"})` returns `{"pin": [1, 2, 3, 4]}`, and `dump({"pin": [1, 2, 3, 4]})` returns `{"pin": "1234"}` — the key is that **the round-trip is closed.**

```python
from marshmallow import Schema


class LoginSchema(Schema):
    pin = PinCode()


LoginSchema().load({"pin": "1234"})   # → {'pin': [1, 2, 3, 4]}
LoginSchema().dump({"pin": [1, 2, 3, 4]})  # → {'pin': '1234'}
LoginSchema().load({"pin": "12ab"})   # → ValidationError: Pin codes must contain only digits.
```

**Why is this superior?**
The knowledge of "convert the string `"1234"` into a list of `int`, and reject anything non-numeric" coheres in **one type** called `PinCode`. Every schema using this type automatically gets the same conversion and validation — a typical practice of DRY. Furthermore, thanks to the type argument `fields.Field[list[int]]`, the contract "this field's `_deserialize` returns `list[int]`" is made explicit as a type, paying off for ETC (Easy To Change) too. Even if the conversion rule changes, the fix is only inside `PinCode`.

---

## **2. Error design and i18n: manage messages centrally with `make_error`**

In `PinCode` I hard-wrote the error message inside `_deserialize`. It works, but because **the message is embedded in the implementation logic**, multilingual support and unifying the wording are difficult. marshmallow solves this problem with `default_error_messages` and `make_error`.

```python
from marshmallow import ValidationError, fields


class PinCode(fields.Field[list[int]]):
    # フィールドクラスにエラーメッセージを「キー → 文言」の辞書として宣言する
    default_error_messages = {
        "invalid": "暗証番号は数字のみで構成してください。",
        "length": "暗証番号は4桁である必要があります。",
    }

    def _serialize(self, value, attr, obj, **kwargs):
        if value is None:
            return ""
        return "".join(str(d) for d in value)

    def _deserialize(self, value, attr, data, **kwargs):
        try:
            digits = [int(c) for c in value]
        except (TypeError, ValueError) as error:
            # メッセージ本文ではなく「キー」で参照する
            raise self.make_error("invalid") from error
        if len(digits) != 4:
            raise self.make_error("length")
        return digits
```

`self.make_error("invalid")` pulls the wording corresponding to the key `"invalid"` from `default_error_messages`, constructs a `ValidationError`, and returns it. You could also write `raise ValidationError("...")` directly, but `make_error` has the next advantages.

- **Central management of messages**: all the wording coheres in the `default_error_messages` dict, separated from the implementation logic.
- **Per-instance override**: if the call site passes `error_messages={...}` at field creation, it can replace only the wording of the same key. It's the same mechanism as translating a built-in field's `required` message into Japanese.

```python
from marshmallow import Schema, fields


class CheckoutSchema(Schema):
    # 組み込みフィールドも同じ仕組み。キー単位でメッセージを上書きできる
    email = fields.Email(
        required=True,
        error_messages={"required": "メールアドレスは必須です。", "invalid": "メール形式が不正です。"},
    )
    # 自作フィールドの "length" だけ、この画面用に差し替える
    pin = PinCode(error_messages={"length": "4桁の暗証番号を入力してください。"})
```

**Why is this superior?**
`make_error("invalid")` references the **meaning (the key)** of the wording, not the wording's **substance.** With this, the validation logic of "something non-numeric came" and the display concern of "what to tell the user" are separated (SRP). The requirement to insert i18n resources, or to change the wording per screen — that can be realized with just replacing `error_messages`, without touching the contents of `_deserialize` at all. This is the dividend of "keeping logic and messages separate."

> ⚠️ **Don't swallow exceptions in `_deserialize`**: `[int(c) for c in value]` throws a `TypeError` if `value` is `None` or `int`, and a `ValueError` if it contains non-digits. If you catch these with `try/except`, **always convert them to a `ValidationError` (or `make_error`) and re-send.** Let a raw `TypeError` leak outside as-is, and it slips past marshmallow's error-aggregation mechanism (structuring into `err.messages`, indexing with `many=True`) and is exposed to the user as a 500 error. Keep the original exception in `__cause__` with `raise ... from error`, and you don't lose the clue for log investigation either.

---

## **3. Real example ①: an amount field (`Decimal`-based)**

The domain type with the highest "value of building yourself" is the amount. Handling an amount as `float` is a dangerous choice directly connected to production incidents.

> ⚠️ **Don't handle an amount as `float`**: it's famous that `0.1 + 0.2` becomes `0.30000000000000004`, which happens because IEEE 754 floating point can't accurately represent decimal fractions in binary. Let this accumulate in amount calculations and it becomes an **accounting error that must not happen** — the billed amount being off by 1 yen, the total not matching. Always hold an amount as `decimal.Decimal`, and output it to JSON as a **string** (output as `float` and the error mixes in again on the JSON-parser side).

marshmallow has `fields.Decimal`, with `places` (decimal digits), `as_string` (string output), and `rounding` (rounding mode). Subclass this to make a `MoneyField` with **the currency domain's convention baked in as the default.**

```python
import decimal

from marshmallow import ValidationError, fields, validate


class MoneyField(fields.Decimal):
    """通貨額を表すフィールド。小数第2位・四捨五入（ROUND_HALF_UP）・文字列出力をデフォルトに固定する。"""

    default_error_messages = {
        "negative": "金額は0以上である必要があります。",
    }

    def __init__(self, *, allow_negative: bool = False, **kwargs):
        self.allow_negative = allow_negative
        # ドメイン規約をデフォルト値として設定する。呼び出し側が明示すれば上書き可能
        kwargs.setdefault("places", 2)
        kwargs.setdefault("rounding", decimal.ROUND_HALF_UP)
        kwargs.setdefault("as_string", True)  # JSON には文字列で出す（精度保持）
        super().__init__(**kwargs)

    def _deserialize(self, value, attr, data, **kwargs):
        # まず親（fields.Decimal）に Decimal への変換・桁丸めを任せる
        amount = super()._deserialize(value, attr, data, **kwargs)
        if not self.allow_negative and amount < 0:
            raise self.make_error("negative")
        return amount
```

Usage is no different from a built-in field.

```python
from marshmallow import Schema


class OrderSchema(Schema):
    subtotal = MoneyField(required=True)
    # 値引きはここでは正負の検証だけ。Range など追加ルールは validate= で合成できる
    discount = MoneyField(allow_negative=False, validate=validate.Range(min=0))


OrderSchema().load({"subtotal": "1980.005", "discount": "200"})
# → {'subtotal': Decimal('1980.01'), 'discount': Decimal('200.00')}  ← 桁丸め済み
OrderSchema().dump({"subtotal": decimal.Decimal("1980.01")})
# → {'subtotal': '1980.01', ...}  ← float ではなく文字列で出力
```

The point is that in `_deserialize`, you **first delegate the conversion to the parent** (`super()._deserialize(...)`), then add domain-specific validation (the negative check) to the result. Without reinventing the "wheel" of conversion to `Decimal` and digit rounding, you only add the business rule on top.

**Why is this superior?**
The currency-domain convention of "amounts are 2 decimal places, round, string output, no negatives" is confined in **one value object** called `MoneyField`. Declare all amount fields in the project with `MoneyField`, and the **fraying of the convention** — outputting `float` in only one API, forgetting rounding in only one screen — becomes structurally impossible. When I designed billing / payments in the lumber-distribution B2B SaaS too, unifying the handling of amounts at the type level was the foundation that ensured billing accuracy.

---

## **4. Real example ②: a phone-number field (E.164 normalization)**

A phone number is a typical example where "the user's input format is diverse" and "internally you want to unify to one normal form." `090-1234-5678`, `09012345678`, `+81 90 1234 5678` — make a field that normalizes all of these to the E.164 form `+819012345678` and rejects invalid input.

```python
import re

from marshmallow import ValidationError, fields

# 国内番号（0始まり、10〜11桁）を想定したシンプルな正規化例
_DOMESTIC_RE = re.compile(r"^0\d{9,10}$")


class PhoneField(fields.Field[str]):
    """日本国内の電話番号を E.164 形式（+81...）に正規化するフィールド。"""

    default_error_messages = {
        "invalid": "電話番号の形式が正しくありません。",
    }

    def _serialize(self, value, attr, obj, **kwargs):
        # 内部では常に +81... の正規形なので、そのまま出力する
        return value if value is not None else None

    def _deserialize(self, value, attr, data, **kwargs):
        if not isinstance(value, str):
            raise self.make_error("invalid")
        # 数字以外（ハイフン・空白・括弧）を除去してから判定する
        digits = re.sub(r"[^\d]", "", value)
        if _DOMESTIC_RE.match(digits):
            # 先頭の 0 を国番号 +81 に置き換える（E.164 化）
            return "+81" + digits[1:]
        # 既に +81 始まりの国際形式で来たケースも受理する
        if value.startswith("+81") and re.fullmatch(r"\+81\d{9,10}", value):
            return value
        raise self.make_error("invalid")
```

```python
from marshmallow import Schema


class ContactSchema(Schema):
    phone = PhoneField(required=True)


ContactSchema().load({"phone": "090-1234-5678"})  # → {'phone': '+819012345678'}
ContactSchema().load({"phone": "09012345678"})    # → {'phone': '+819012345678'}
ContactSchema().load({"phone": "+819012345678"})  # → {'phone': '+819012345678'}
ContactSchema().load({"phone": "1234"})           # → ValidationError: 電話番号の形式が正しくありません。
```

> 💡 The above is a minimal implementation to show the **design pattern** of normalization. To strictly handle international numbers, landlines, and regional digit-count differences in real operation, a configuration of calling `phonenumbers` (a Python port of Google libphonenumber) inside `_deserialize` and returning the result of `phonenumbers.format_number(..., E164)` is robust. The point is that the structure of "confining the normalization and validation logic inside `_deserialize`" is the essence, and the internal algorithm is swappable.

**Why is this superior?**
The responsibility of absorbing the wobble of input formats (with/without hyphens, international notation) and aligning to **the single normal form** coheres in the single `PhoneField`. Use it for all APIs' phone-number input, and the invariant that the phone number saved in the DB **is always E.164** is guaranteed at the boundary. Downstream processing — SMS sending, duplicate checks, deduplication — can trust the premise that "phone numbers are normalized," and each process no longer needs to re-implement normalization individually. This is the value-object philosophy of "normalize once at the boundary" itself.

---

## **5. Real example ③: enums (`fields.Enum`) — safely handle Python's `Enum` at the boundary**

A "one of the predetermined options" like a status or a kind is type-safe to express with Python's standard `enum.Enum`. marshmallow has a dedicated `fields.Enum`, and using it lets you **safely convert a string from outside into an `Enum` member at the boundary.** `fields.Enum` needs no self-building — the built-in is enough.

```python
import enum

from marshmallow import Schema, fields


class OrderStatus(enum.Enum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"
    CANCELLED = "cancelled"


class OrderSchema(Schema):
    # デフォルト：メンバーの「名前」でシリアライズ／デシリアライズする
    status_by_name = fields.Enum(OrderStatus)
    # by_value=True：メンバーの「値」でシリアライズ／デシリアライズする
    status_by_value = fields.Enum(OrderStatus, by_value=True)
```

The difference between the 2 modes is whether the outward representation **uses the name or the value.**

| Declaration | Input `load` accepts | `dump`'s output |
| --- | --- | --- |
| `fields.Enum(OrderStatus)` | `"PAID"` (the member name) | `"PAID"` |
| `fields.Enum(OrderStatus, by_value=True)` | `"paid"` (the member value) | `"paid"` |

```python
schema = OrderSchema()
schema.load({"status_by_name": "PAID", "status_by_value": "paid"})
# → {'status_by_name': <OrderStatus.PAID: 'paid'>, 'status_by_value': <OrderStatus.PAID: 'paid'>}

# 列挙にない値は ValidationError で弾かれる
schema.load({"status_by_name": "UNKNOWN", "status_by_value": "paid"})
# → ValidationError: Must be one of: PENDING, PAID, SHIPPED, CANCELLED.
```

Which to choose is decided by the **external API's contract.** If the front end or external system exchanges `"paid"` (the lowercase value), `by_value=True`; if `"PAID"` (the member name), leave it default.

**Why is this superior?**
Write the enum's option check as a string list with `validate.OneOf(["pending", "paid", ...])`, and the list and the actual `Enum` definition become **double-managed**, inviting a DRY violation of forgetting to change one. `fields.Enum(OrderStatus)` makes the `Enum` definition itself the single truth, and at the point it passes `load()`, the value is not a raw string but a **typed `Enum` member.** Downstream code can branch safely with an `is` comparison like `if order.status is OrderStatus.PAID:`, and a missed branch due to a string typo is prevented at the type level.

---

## **6. Computed / derived values: `fields.Method` / `Function` / `Constant`**

Not every field corresponds to input. There are scenes where you want to declare "a value computed from other fields" or "a value the server returns fixed" as **output-only.** marshmallow provides 3 lightweight mechanisms.

### **`fields.Function`: compute with an on-the-spot lambda**

```python
from marshmallow import Schema, fields


class UserSchema(Schema):
    first_name = fields.Str()
    last_name = fields.Str()
    # obj（変換元オブジェクト全体）を受け取り、その場で算出する
    upper_name = fields.Function(lambda obj: obj["first_name"].upper())
```

`fields.Function` takes a callable for `serialize`, and can also specify the reverse direction with `deserialize=` if needed. It suits **a simple computation that completes in one line.**

### **`fields.Method`: entrust it to a schema method**

If the computation logic is complex, or you want to share it across multiple fields, `fields.Method` pointing at a schema method is more readable.

```python
from marshmallow import Schema, fields


class OrderSchema(Schema):
    subtotal = MoneyField(required=True)
    tax_rate = fields.Decimal(load_default="0.10")
    # serialize 時は total_amount を、deserialize 時は parse_total を呼ぶ
    total = fields.Method("total_amount", deserialize="parse_total")

    def total_amount(self, obj):
        # 小計 × (1 + 税率) を算出して返す
        return str(obj["subtotal"] * (1 + obj["tax_rate"]))

    def parse_total(self, value):
        # 入力側で受け取りたい場合の変換（不要なら deserialize= を省略すれば dump 専用になる）
        return decimal.Decimal(value)
```

`fields.Method`'s serialize method takes the signature `def method(self, obj): ...`, and the deserialize method `def method(self, value): ...`.

### **`fields.Constant`: always output the same value**

To return **a fixed value regardless of input** — like an API version tag or a response kind — use `fields.Constant`.

```python
from marshmallow import Schema, fields


class ResponseSchema(Schema):
    api_version = fields.Constant("2026-06")  # 常にこの値を出力する
    object_type = fields.Constant("order")
```

### **Combination with `dump_only`**

Computed / derived values are essentially **output-only** in most cases. Letting the client write `total` becomes room for tampering, so use `dump_only=True` together to block input.

```python
class OrderSchema(Schema):
    subtotal = MoneyField(required=True)
    # 算出値はサーバーが決める。load では受け付けない
    total = fields.Function(lambda obj: str(obj["subtotal"] * decimal.Decimal("1.10")), dump_only=True)
```

**Why is this superior?**
Declare logic like "computing the total amount" or "attaching a version tag" as `fields.Method` / `Function` / `Constant` **in the schema definition**, and the response-shaping knowledge coheres in the schema. You no longer need to assemble it by hand inside the view function, like `response["total"] = subtotal * 1.1`, preventing business logic from leaking into the presentation layer (SRP). Even if the output spec changes, the fix completes inside the schema.

---

## **7. Extending an existing field: subclassing and `_bind_to_schema`**

Inheriting `fields.Field[T]` from scratch isn't the only hand. **Subclassing an existing field** and overriding `_deserialize` to always apply common preprocessing is an extremely practical pattern.

The representative example is a field that "always trims (removes leading/trailing whitespace from) a string." Rather than writing it per schema with `@pre_load`, **giving it as a type** is more reusable.

```python
from marshmallow import fields


class TrimmedString(fields.String):
    """前後の空白を常に除去する文字列フィールド。空白起因のバグを型レベルで防ぐ。"""

    def __init__(self, *, lower: bool = False, **kwargs):
        self.lower = lower
        super().__init__(**kwargs)

    def _deserialize(self, value, attr, data, **kwargs):
        # まず親（fields.String）に文字列としての検証を任せる
        result = super()._deserialize(value, attr, data, **kwargs)
        result = result.strip()
        if self.lower:
            result = result.lower()
        return result
```

```python
from marshmallow import Schema, validate


class SignupSchema(Schema):
    name = TrimmedString(required=True, validate=validate.Length(min=1))
    # メールは小文字化＋トリムを型に焼き込む。validate= の検証はトリム後の値にかかる
    email = TrimmedString(lower=True, validate=validate.Email())


SignupSchema().load({"name": "  友田  ", "email": " Tomoda@Example.com "})
# → {'name': '友田', 'email': 'tomoda@example.com'}
```

Note that with `super()._deserialize(...)` you reuse the parent's "validation as a string type" while adding only the normalization of `strip()` / `lower()`. **Without reinventing the wheel, adding only the behavior** — this is the royal road of extension.

### **`_bind_to_schema`: a hook at schema binding**

When a field needs to know "the schema it belongs to" — for example, an advanced case of switching the error message according to the parent schema's setting, or resolving a reference to another field — you touch the `_bind_to_schema` hook. It's called the moment the field is bound to the schema.

```python
from marshmallow import fields


class ContextAwareField(fields.String):
    def _bind_to_schema(self, field_name, parent):
        # v4 ではフックの第2引数が `schema` から `parent` に改名された点に注意
        super()._bind_to_schema(field_name, parent)
        # ここで self.parent（属するスキーマ）や field_name にアクセスできる
        # 例：親スキーマの状態に応じてこのフィールドの挙動を初期化する
```

> 💡 `_bind_to_schema`'s **argument name changed from `schema` to `parent`** in marshmallow 4 (because a field can be bound not only to a schema but also inside another field, it became a more accurate name). When porting 3.x code, match the signature. You don't touch it day-to-day much, but it's useful in advanced extensions where you want to give a field schema context.

**Why is this superior?**
`TrimmedString` **bakes into the type** the invariant that "the input string is always trimmed." Compared to the method of writing normalization per schema with `@pre_load`, because normalization is guaranteed at the point the field is declared, forgetting to write it can't happen. This way of assembling a domain type with composition (an existing field + added behavior) is the embodiment of "composition over inheritance" and "ETC" as in CLAUDE.md.

---

## **8. Composing validators: `validate.And` and reusable functions**

Whereas a custom field handles "type conversion and normalization," the `validate=` argument declares "the constraint the value should satisfy." The two are complementary, and **you can compose multiple validators on one field.**

```python
from marshmallow import Schema, fields, validate


class ProductSchema(Schema):
    # リストで渡すと、すべてのバリデータが順に適用される
    sku = fields.Str(validate=[validate.Length(equal=8), validate.Regexp(r"^[A-Z0-9]+$")])
    # validate.And は同じ合成を1つのバリデータオブジェクトとして表現する（再利用しやすい）
    code = fields.Str(validate=validate.And(validate.Length(equal=8), validate.Regexp(r"^[A-Z0-9]+$")))
```

`validate=[a, b]` (a list) and `validate.And(a, b)` (a composition object) have the same effect, but `validate.And(...)` is superior in being able to **bind the composed validator to a variable and reuse it.** Define project-common constraints once, with a name.

```python
from marshmallow import validate

# プロジェクト共通の SKU 制約を「ひとつの再利用可能なバリデータ」として定義する
sku_validator = validate.And(
    validate.Length(equal=8),
    validate.Regexp(r"^[A-Z0-9]+$", error="SKUは英大文字と数字のみ8桁です。"),
)


class ProductSchema(Schema):
    sku = fields.Str(validate=sku_validator)  # 使い回す


class InventorySchema(Schema):
    sku = fields.Str(validate=sku_validator)  # 別スキーマでも同じ制約
```

### **A self-made validator function: `raise` a `ValidationError`**

A constraint that can't be expressed with built-in validators is written as **a function that sends a `ValidationError`.**

```python
from marshmallow import ValidationError


def validate_even(value: int) -> None:
    # v4 ではバリデータは False を返すのではなく ValidationError を raise する
    if value % 2 != 0:
        raise ValidationError("偶数を指定してください。")


class LotSchema(Schema):
    quantity = fields.Int(validate=[validate.Range(min=0), validate_even])
```

> ⚠️ **A v4 validator must not return `False`**: in marshmallow 3.x you could use a validator returning a `bool`, like `validate=lambda x: x > 0`, but **in v4 you must always `raise` a `ValidationError`** on validation failure. `return False` is ignored, and the validation slips through. The code generative AI outputs and old articles are often in the 3.x style, so beware when porting.

### **Distinguishing `validate=` and `@validates`**

| Means | Suited scene | Context other than the value |
| --- | --- | --- |
| `validate=` (a validator / composition) | A static rule that completes with the value **alone** (length, range, regex). Easy to reuse | Can't be used (sees only the value) |
| `@validates("field")` | Field validation needing to reference the schema instance or other state | Can access the schema's `self` |
| `@validates_schema` | An invariant **spanning multiple fields** (start < end, etc.) | Can reference the whole input dict |

The principle is "**make value-alone static rules reusable with `validate=`, and if context is needed, with the `@validates` family.**" Write in `@validates` something you could lean to `validate=`, and reuse doesn't work, and the same logic duplicates per schema.

**Why is this superior?**
Name and compose a constraint like `sku_validator`, and that constraint becomes **one reusable part.** When the SKU's digit-count spec changes, the fix completes in one place, the definition of `sku_validator`, and it's automatically reflected to all schemas using it. Keeping "the single truth of the validation rule" — this is a design extending DRY all the way to the validation domain.

---

## **Conclusion: design domain types as "boundary parts"**

When the built-in `fields` aren't enough, that shortfall is a sign of "design a domain-specific value object." marshmallow's custom fields are a proven mechanism for expressing that value object as a reusable part at the boundary. Let me re-list this article's key points.

1. **Inherit `fields.Field[T]` and implement `_serialize` / `_deserialize`**, and you can confine domain-specific type conversion and validation in one place and reuse it across all schemas.
2. Invalid input is **converted to and sent as a `ValidationError`** inside `_deserialize`, not leaking a raw exception. Manage wording centrally with **`default_error_messages` + `make_error`**, and leave it open to i18n / per-screen replacement with `error_messages`.
3. Make values with business rules into types: **amounts as a `MoneyField` with `Decimal`, string output, and rounding baked in**, and **phone numbers as a `PhoneField` normalizing to E.164.** Don't handle amounts as `float`.
4. **Enums as `fields.Enum`** (value-based with `by_value=True`), passing the boundary as a typed member rather than a raw string.
5. Declare **computed values with `fields.Method` / `Function`, and fixed values with `fields.Constant`**, and use `dump_only` together to prevent the scattering of business logic.
6. **Subclass existing fields** (`TrimmedString`) and **compose with `validate.And`** to assemble validation logic DRY-ly. A validator must always `raise` a `ValidationError` in v4.

The difference between "working code" and "code you can operate for 10 years" lies in **where and how you normalize / validate domain values, and how much you can localize that knowledge.** A custom field elevates one-time normalization at the boundary into a reusable type, making the fraying of the convention structurally impossible.

For further exploration, I recommend re-reading the following of the official documentation with this article's design viewpoint in mind.

- [Custom Fields](https://marshmallow.readthedocs.io/en/stable/custom_fields.html)
- [API Reference: fields](https://marshmallow.readthedocs.io/en/stable/marshmallow.fields.html)
- [API Reference: validate](https://marshmallow.readthedocs.io/en/stable/marshmallow.validate.html)
- [Upgrading to newer releases (3→4 migration)](https://marshmallow.readthedocs.io/en/stable/upgrading.html)

---

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

The author has implemented and operated the discipline explained here — "normalize / validate domain values once at the boundary, and flow only typed, safe values inside" — in the production environment of a B2B SaaS that won the Minister of Economy, Trade and Industry Award. Designing values with business rules — amounts, phone numbers, various codes — as reusable custom fields directly connects to billing accuracy, data consistency, and maintainability. I build, fast and high-quality with generative AI, **the foundation directly connected to a business's reliability** — type-safe input validation, domain-type design, response shaping, and ORM integration. On backend development using Python and the type-safe-ification of existing systems, feel free to consult me.
