Skip to main content
友田 陽大
marshmallow
Python
marshmallow
バリデーション
型安全
ドメインモデリング
DRY
アーキテクチャ設計

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
Reading time
20 min read
Author
友田 陽大
Share

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), 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.

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.

MethodDirectionThe value it receivesWhat it should return
_serialize(self, value, attr, obj, **kwargs)dump (output)The internal Python valueA JSON-compatible outward representation
_deserialize(self, value, attr, data, **kwargs)load (input)The external raw dataA 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.
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.

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.
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.

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.

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.

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")
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.

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.

DeclarationInput load acceptsdump's output
fields.Enum(OrderStatus)"PAID" (the member name)"PAID"
fields.Enum(OrderStatus, by_value=True)"paid" (the member value)"paid"
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

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.

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.

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.

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.

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
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.

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.

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.

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.

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

MeansSuited sceneContext other than the value
validate= (a validator / composition)A static rule that completes with the value alone (length, range, regex). Easy to reuseCan't be used (sees only the value)
@validates("field")Field validation needing to reference the schema instance or other stateCan access the schema's self
@validates_schemaAn 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.


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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading