Skip to content

    Forward Deployed Engineer

    Dataclasses and Pydantic: Validate Data at the Boundary

    Compare Java records, dataclasses and Pydantic models, then validate untrusted input at the boundary using the capstone's real Product and Requirements models.

    By Published 7 min read

    • fde
    • python
    • pydantic
    • dataclasses
    • validation
    • data-models
    Share

    This lesson is part of Forward Deployed Engineer: Python and FastAPI Foundations. Enroll for free, then sign in, to read it in full.

    What you'll learn: how Python holds structured data (dataclasses, Pydantic models), why only Pydantic checks what it is given, how the capstone's Product and Requirements models express rules as field metadata, and how to validate once at the edge.

    At a glance

    Figure 1. Validation at the boundary

    flowchart LR accTitle: Validation at the boundary accDescr: Untrusted input such as JSON from a client, a catalog file or a model reply goes through model validation. A valid input becomes a typed object that business code trusts. An invalid input raises a validation error that lists field paths and is turned into a rejected request, a repair attempt or a skipped row. A["Untrusted input: request JSON, catalog file, model reply"] --> B["Model.model_validate"] B -- valid --> C["Typed object"] C --> D["Business code trusts the types"] B -- invalid --> E["ValidationError: field paths"] E --> F["Reject, repair or skip the row"]

    How to read this: all checking happens in the middle box, so the code to its right never asks whether a price is a number.

    • A dataclass stores fields and generates a constructor. It does not check input.
    • A Pydantic model validates on construction and converts compatible input by default.
    • Rules live next to the field: Field(ge=0), Field(pattern=...), a Literal.
    • Validate where data enters (HTTP, files, model replies), then trust the object.
    • Report where the problem is, not what was submitted: the value may be private.

    Scenario

    Composite scenario, not a real company. Lumen Industrial Supply, the fictional distributor, exports its product catalog from an old system as JSON. Last week a row arrived with the price "call us", another with a lowercase SKU, and one with a negative price. The old loader accepted all three, and a sales engineer saw a quote line costing minus 5 euros. All rows and prices here are illustrative.

    What you will be able to do

    • Choose between a dataclass and a Pydantic model for a piece of data.
    • Express a range, a pattern and a closed set of values as field rules.
    • Validate untrusted rows and report which rows and fields failed without echoing values.
    • Explain lax versus strict validation.

    Prerequisites

    Python for Java and Node engineers explains why a hint alone is not enforced. For why model replies also need validating, see AI Architect: agents, tools and MCP; this lesson covers the Python mechanics only.

    Core ideas

    Records, schemas, dataclasses and models

    Question Java record TypeScript with Zod Python dataclass Pydantic model
    Stores named fields Yes A type, plus a schema Yes Yes
    Generates constructor, equality, text form Yes No (plain objects) Yes Yes
    Rejects a wrong type at runtime The compiler checks your own code; JSON input is a separate step Only if you call the schema No Yes
    Range and pattern rules Bean Validation annotations, run by a validator In the schema Not provided In the field
    Parses JSON text With a JSON library parse on the schema Not provided model_validate_json

    Sources:

    • A Java record is a data carrier: the compiler generates the constructor, accessors, equals, hashCode and toString.
    • Jakarta Bean Validation defines constraints such as @NotNull, @Min and @Pattern.
    • Zod is TypeScript-first schema validation with static type inference.
    • @dataclass adds __init__, __repr__ and __eq__, and apart from two special markers "nothing in @dataclass examines the type specified in the variable annotation".

    Spot the flaw. A teammate says "the types make this safe: price is a float". Is that true?

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L2/m1_l2_boundary.py

    @dataclass(frozen=True)
    class PlainProduct:
        """A dataclass stores whatever it is given. Nothing here checks a type or a range."""
    
        sku: str
        price_eur: float
        in_stock: bool = True
    Answer

    No. The annotation is never examined, so PlainProduct(sku=12, price_eur="free") builds an object with a number as the SKU and text as the price; test_dataclass_accepts_nonsense runs exactly that. A dataclass suits data you create yourself, not outside input.

    Pydantic: validation is the constructor

    The Pydantic documentation says: "Initialization of the object will perform all parsing and validation." Three entry points matter:

    • Model(...) and Model.model_validate(data) take Python values.
    • model_validate_json(text) takes JSON text.
    • model_dump() goes the other way, from the object back to plain values.

    Two behaviours surprise Java and Node engineers.

    • It converts by default. Pydantic "may cast input data to force it to conform to model field types". A string such as "123" is valid for an int in lax mode, not in strict mode. Pass strict=True for no conversion.
    • It reports every error at once. One ValidationError is raised "regardless of the number of errors found", so you can show all bad fields in one reply.

    Its guarantee is narrow: "Pydantic guarantees the types and constraints of the output, not the input data".

    The capstone's Product model

    Figure 2. The Product model

    erDiagram accTitle: The Product model accDescr: A product has a SKU as its identifier, a name, a price, an optional IP rating and flags for stock and visibility. It belongs to one of five categories, has zero or more protocols and zero or more specification entries. CATEGORY ||--o{ PRODUCT : classifies PRODUCT ||--o{ PROTOCOL : supports PRODUCT ||--o{ SPEC : "described by" PRODUCT { string sku PK string name string category float price_eur string ip_rating boolean in_stock string tenant_visibility } CATEGORY { string name } PROTOCOL { string name } SPEC { string key string value }

    How to read this: the middle box is the fields of one Python class; PROTOCOL and SPEC are a list and a dictionary inside the product, not tables.

    Verified: capstone/fde-product-discovery-copilot/app/models.py

    class Product(BaseModel):
        model_config = ConfigDict(frozen=True)
    
        sku: str = Field(pattern=r"^LUM-[A-Z]{2}-\d{3}
    quot;
    ) name: str category: Category price_eur: float = Field(ge=0, description="Illustrative price, not a real quote") specs: dict[str, SpecValue] = Field(default_factory=dict) protocols: list[str] = Field(default_factory=list) ip_rating: str | None = Field(default=None, pattern=r"^IP\d{2}
    quot;
    ) in_stock: bool = True tenant_visibility: Visibility = "public"

    Read it field by field:

    • sku must match a pattern, and price_eur must be at least 0.
    • category must be one of five literal strings.
    • ip_rating is optional but must be "IP" plus two digits if present.
    • specs and protocols default to fresh empty containers (default_factory, a callable that generates the default).
    • frozen=True makes instances read only, but the docs' own example shows a frozen model does not stop a dictionary field being changed in place, so specs can still be modified.
    NOTE

    A price of 0 passes ge=0. Whether a free product is valid is a business decision; ask the catalog owner before tightening it.

    Requirements is the opposite shape: every field is optional, because a request rarely names everything.

    Verified: capstone/fde-product-discovery-copilot/app/models.py

    class Requirements(BaseModel):
        """What the customer asked for, as structured data. Every field is optional."""
    
        category: Category | None = None
        quantity: int | None = Field(default=None, gt=0)
        budget_eur: float | None = Field(default=None, gt=0)
        protocols: list[str] = Field(default_factory=list)
        environment: list[str] = Field(default_factory=list)
        must_have: list[str] = Field(default_factory=list)
        nice_to_have: list[str] = Field(default_factory=list)
        keywords: list[str] = Field(default_factory=list)  # topic words, for example "vibration"
        skus: list[str] = Field(default_factory=list)  # products named in the request (resolved in code)

    Here quantity and budget_eur use gt=0, so a request for zero pumps is rejected, while leaving them out is fine.

    Worked example

    Assumptions: the three rows are illustrative; the output below is from an actual run of the lesson's parse_catalog.

    Rows: a valid product; one with SKU vs-100 and a price of -5; one with category toasters.

    [RowError(row=1, fields=('sku', 'price_eur')), RowError(row=2, fields=('category',))]

    Row 0 passes. Row 1 fails on two fields at once, and row 2 on one. The full catalog, data/products.json, validates with 40 products and 0 errors, 5 of them partner-only.

    Printing the exception itself is different: Pydantic's default message includes the submitted value. A run on a bad SKU and a text price printed these lines, documentation links left out:

    2 validation errors for Product
    sku
      String should match pattern '^LUM-[A-Z]{2}-\d{3}
    #x27; [type=string_pattern_mismatch, input_value='vs-100', input_type=str] price_eur Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='free', input_type=str]

    If the field were a customer's free text, it would land in your logs. The capstone's error handler therefore sends back field names only, and so does bad_fields.

    In the interview: "Why use Pydantic instead of plain type hints or dataclasses?" A good answer: "Hints and dataclasses describe data but do not check it. Pydantic validates at construction, so I check untrusted input once at the boundary and the rest of the code can rely on the types. I would still use a dataclass for internal values I create myself."

    Build it

    The loader validates each row once and collects problems instead of stopping at the first.

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L2/m1_l2_boundary.py

    def bad_fields(exc: ValidationError) -> tuple[str, ...]:
        """Where the problems are, never what was submitted (the value may be private)."""
        return tuple(".".join(str(part) for part in e["loc"]) or "(whole body)" for e in exc.errors())
    
    
    def parse_catalog(rows: list[dict[str, object]]) -> tuple[list[Product], list[RowError]]:
        """Validate every row once, at the edge. Good rows become Products, bad rows become RowErrors."""
        products: list[Product] = []
        errors: list[RowError] = []
        for index, row in enumerate(rows):
            try:
                products.append(Product.model_validate(row))
            except ValidationError as exc:
                errors.append(RowError(index, bad_fields(exc)))
        return products, errors

    This lesson test shows lax and strict mode differ.

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L2/test_m1_l2_boundary.py

    def test_lax_mode_converts_and_strict_mode_does_not():
        assert Product.model_validate({**GOOD, "price_eur": "349"}).price_eur == 349.0
        with pytest.raises(ValidationError):
            Product.model_validate({**GOOD, "price_eur": "349"}, strict=True)

    Run ./.venv/Scripts/python -m pytest lessons/M1-L2 -q from the capstone folder.

    Predict first. What does parse_catalog return for the three rows "just text", None and {}? Compare with the last test in the file.

    Answer

    No products and three errors. The first two rows are not objects, so each fails as a whole body ("(whole body)"). The empty object fails on every required field with no default: sku, name, category and price_eur.

    Exercise

    Write parse_requirements_json(raw: str): it takes JSON text and returns a Requirements object or the tuple of wrong field paths.

    Acceptance criteria:

    • Valid JSON with quantity: 40 returns a Requirements whose quantity is 40.
    • quantity: 0 and an unknown category both appear in the returned paths.
    • Text that is not JSON returns one entry that says the whole body was wrong.
    • No submitted value appears in the result.

    Done when your function passes those four cases. A reference solution is in lessons/M1-L2/m1_l2_solution_requirements.py; write yours first.

    Hint 1: one method does it all

    Pydantic parses and validates JSON in one call. See the entry points above.

    Hint 2: reuse the error helper

    Catch the ValidationError and pass it to the lesson's bad_fields, which turns each loc into a dotted path.

    Common mistakes

    • Assuming a dataclass validates. It does not. Use Pydantic for outside input.
    • Validating twice. Re-checking deep in the code hides the real boundary.
    • Logging the exception text. It carries submitted values. Log paths and error types.
    • Missing lax conversion. "349" becomes 349.0. Use strict mode if partners must send numbers.
    • A shared mutable default. Use default_factory for lists and dictionaries.
    • Encoding policy as a type. ge=0 is a rule someone owns. Confirm it.

    Knowledge check

    1. Why can a dataclass with `price_eur: float` hold the text "free"?

    The dataclass uses the annotation to find fields, not to check types, so the constructor stores whatever it is given. Only a validator such as Pydantic, or a static checker, will object.

    2. What is the difference between lax and strict validation, and which does Pydantic use by default?

    Lax mode converts compatible input, so "349" becomes 349.0; strict mode rejects it. Pydantic is lax by default, which is risky when a contract says "numbers must be numbers".

    3. Where does the capstone say a SKU must look like `LUM-VS-100`?

    In the Product model, on the sku field, as a pattern in Field. Every place that builds a Product therefore enforces the same format.

    4. Why report only field paths in a validation reply?

    The submitted value may be private, such as free text or a token typed into the wrong field. Pydantic's default message includes it, so printing it spreads it into logs. A path such as sku tells the caller what to fix without repeating it.

    5. When would you still choose a plain dataclass over a Pydantic model?

    For small internal values your own code creates and trusts, such as RowError in this lesson. They cross no boundary, so validation adds only cost. Untrusted input gets a Pydantic model; internal values can stay plain.

    You can now

    • Say which of a record, dataclass and Pydantic model checks its input.
    • Write field rules for a range, a pattern and a closed set.
    • Validate rows once and report failures by row and field.
    • Explain lax versus strict mode.
    • Keep submitted values out of logs.

    Further reading

    Last verified: 2026-11-02

    Next

    FastAPI: an AI-ready service

    Enroll to keep reading

    Lesson 5 of 9 · Forward Deployed Engineer: Python and FastAPI Foundations

    Compare Java records, dataclasses and Pydantic models, then validate untrusted input at the boundary using the capstone's real Product and Requirements models.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Python and FastAPI Foundations, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments