Make the Data Contracts Solid

A dict trusts everything you hand it. Everything. Pass a feature record around as a plain dict and it takes any key and any value without a word, so a serving request that arrives with annual_inc as the string "45000" sails straight into the model and scores nonsense with no error anywhere. I have lost the better part of a day, more than once, to exactly this: a shape bug that a contract at the boundary would have caught at the door. Working code that trusts its inputs is one malformed record away from silent garbage.

Lesson 4 made the package import the same everywhere and stop running its job the moment something touched it. So we have where the code resolves and when it runs nailed down. The next thing that breaks is what flows through it: the data. And here’s why this class of bug hurts so much. Nothing raises at the point of the mistake. A wrong type flows downstream and surfaces, if it surfaces at all, as a weird number three functions deep, hours into a training run. In this lesson we will harden what the package passes around so a shape problem fails at the boundary in seconds instead of detonating late, working up through hints, then a fixed shape, then validation at the edge.

Hints state the shape, they do not enforce it

Let’s start with the most common misconception, because believing it is dangerous. Type hints are not enforced at runtime. They are a contract that a separate static checker, mypy, verifies before the program runs. A hinted project with no checker in CI has documentation, not enforcement. Treat the hint as a runtime guard and you’re trusting inputs that were never actually checked.

Mechanically, annotations are metadata and nothing more. Python stores them on __annotations__ and never consults them while executing. The interpreter binds whatever you actually pass, hint or no hint. Verification is a separate phase that only happens if you run mypy: it reads those annotations and flags inconsistencies ahead of runtime. Which is why the specificity of the hint matters. A bare dict tells the checker only “some mapping,” so a typo’d key sails through. dict[str, float] gives it the value type and makes content misuse checkable. Go too tight and you force Any and noisy unions. The real signal is whether the next engineer can know the shape from the signature alone.

Let’s watch a wrong-typed value pass straight through a fully hinted signature at runtime, then watch mypy refuse the same line.

"""Lesson 4.1 Show — a type hint is documentation, not a runtime guard.

A hinted function happily accepts the wrong type at runtime: Python does not check
hints when the code runs. The hint's value is that a separate checker (mypy, run in
CI) reads it and flags the drift before the code ever runs. The print below proves
the wrong-typed value sailed through; mypy — not the interpreter — is what catches it.
"""


def average_income(incomes: list[float]) -> float:
    # The hint says list[float]; nothing here enforces it at runtime.
    return sum(incomes) / len(incomes)


# A str is the wrong type, but Python runs it anyway (sum of a list of one str? no —
# pass a list with a numeric-looking string to show the silent mistype slipping in).
mixed = [45000.0, 52000.0]
print(f"correct call: {average_income(mixed):.1f}")

# Wrong type passes the function signature with no error — the hint did not stop it.
wrong: list = ["45000", "52000"]  # strings, not floats
try:
    average_income(wrong)  # type: ignore[arg-type]
    print("wrong-typed input did NOT raise from the hint (it only failed in the math)")
except TypeError as e:
    print(f"failed only when the VALUES were used, not at the boundary: {e}")
print("the hint is for mypy (run separately in CI), not the interpreter")

Look at what happened. The wrong-typed value passed straight through the hinted signature at runtime, and the hint did nothing to stop it. What does stop it is mypy, run separately in CI, reading those same annotations and refusing the mismatch before the code ever runs. The hint is written for the checker and the next reader, not for the interpreter. A hint with no checker behind it is a comment that happens to have good syntax.

The non-obvious cost shows up when you assume the reverse, that the annotation is doing work at runtime. That assumption fails in two ML-specific ways, and they pull in opposite directions. The first failure is assuming the hint enforces when the checker is absent. A hint with mypy missing from CI is exactly as enforced as no hint, so the “wrong type reached production” incident happens with fully annotated code, and the fix is the CI step, not more annotations. The second failure is the mirror image: assuming the hint stays inert when some runtime is reading it. Some libraries you’ll reach for, pydantic and FastAPI among them, read the annotations at runtime to build their validators, so for them the annotation is live code, not a comment. from __future__ import annotations turns every annotation into a string, which is why this used to bite hard under pydantic v1: stringized hints broke validator-building while mypy stayed green. Pydantic v2 closed most of that gap. It resolves those string annotations when it builds the model, so the common cases (module-level models, ordinary field types) work fine under from __future__ import annotations. Where it still bites is a forward reference v2 cannot resolve in scope: a type defined locally inside a function, or a name not yet defined when the model is built. There the class definition itself does not raise. Pydantic defers, and the failure surfaces loudly the first time you use the model, as a PydanticUserError telling you the model is not fully defined, which you fix by calling model_rebuild() once the referenced name is in scope. The point for this lesson is only that the failure is loud and recoverable, not a silent wrong-validation. You get an explicit error pointing at the unresolved type, not a model that quietly skips checking that field. So the hint is inert to the interpreter and load-bearing to those libraries at once, and which of the two situations you’re in decides whether a string annotation is harmless or an error you have to resolve.

Does a hint stop a wrong-typed value at runtime? If not, what catches it and when, and what does a bare dict fail to tell the next engineer?


Try It 1

This function is hinted with a bare dict, which tells a checker almost nothing. Tighten the hint so mypy could catch a wrong value type, and note in a comment what the tighter hint now lets it verify, and the one thing it still cannot.

python
def total_exposure(features: dict) -> float:
    return features["loan_amnt"] * features["n_loans"]


# Retype the parameter so a checker knows the value type.
# Comment: what can mypy now catch -- and what can it still not catch?
print(total_exposure({"loan_amnt": 10000.0, "n_loans": 2.0}))
Hint 1 Look at how the parameter is annotated and how every value gets used inside the body. Does the bare annotation tell a checker anything at all about the values it holds?
Hint 2 A checker only knows what the annotation describes. If the annotation says nothing about the value type, the checker cannot object when a value is the wrong type. So the gap is that the annotation is too vague to constrain what flows through.
Hint 3 Re-read the section above on why the *specificity* of the hint matters, and what it says a more specific mapping annotation can verify. Then ask the question that section raises about which keys are valid.

Solution

python
def total_exposure(features: dict[str, float]) -> float:
    return features["loan_amnt"] * features["n_loans"]


# dict[str, float] lets mypy verify the values are used as floats (so a str value
# is flagged) and the return is float-valued. It still cannot catch a typo'd KEY --
# the key space is open. Closing that needs a fixed-field type (next section).
print(total_exposure({"loan_amnt": 10000.0, "n_loans": 2.0}))

dict[str, float] upgrades the contract from “some mapping” to “string keys, float values.” The gap it leaves, a typo’d key like features["loan_amt"], is exactly what a dataclass closes next.

Dataclasses and pydantic: fix the shape, validate at the boundary

A dict has no fixed shape, so a typo’d key is a silent failure waiting for the worst possible moment. Two tools close that gap from different ends. A dataclass fixes the field set at definition, so typos become checker errors. A pydantic model goes one step further and validates at construction, so malformed input is rejected or coerced the moment it enters, before any logic touches it. The principle underneath both: validate at the boundary, so the interior can trust its inputs. Validating deep inside the model code is too late. By then the bad value has already corrupted whatever it flowed through.

Here’s why a dict is so leaky. Its key space is open and resolved at runtime by hashing, so row["loan_amt"] is a perfectly valid lookup for a key that simply does not exist. It raises KeyError only on the code path that actually hits it, which might be deep in production. A @dataclass closes that key space at definition time. The decorator reads the class’s annotated attributes and generates the boilerplate from them: an __init__ whose parameters are exactly those fields, plus __repr__ and __eq__. Because the fields are now real named attributes, a typo’d access (record.loan_amt) is an AttributeError a checker flags before the program runs, and the field set is fixed instead of open.

But a plain dataclass does no type enforcement. Its generated __init__ assigns whatever you hand it, so Record(annual_inc="45000") cheerfully stores the string. A pydantic model adds the runtime layer. Its __init__ runs each field through a validator during construction, coercing compatible inputs ("45000"45000.0) and, when coercion is impossible ("forty-five"), raising a structured ValidationError that names the offending field and the expected type. The dividing line is when enforcement happens. The dataclass gives you a fixed shape the checker verifies statically. Pydantic gives you a validated shape checked the moment data enters. Put the pydantic boundary at the edge of your system and every interior function gets to assume well-formed inputs.

Here are both at the boundary, the dataclass closing the field set, pydantic coercing and rejecting at construction.

"""Lesson 4.2 Show — fix the shape with a dataclass, validate the boundary with pydantic.

A `@dataclass` fixes the field names and types of a record, so a typo'd attribute is
an `AttributeError`, not a silently-created field. But a dataclass does not validate
or coerce incoming values. A pydantic model does: it coerces `annual_inc="45000"`
(a string off a form/JSON) into `45000.0`, and rejects `annual_inc="not a number"`
with a field-named error — exactly what you want at the serving boundary, where input
arrives untyped.
"""

from dataclasses import dataclass

from pydantic import BaseModel, ValidationError


@dataclass
class LoanRecord:
    loan_amnt: float
    annual_inc: float
    purpose: str


record = LoanRecord(loan_amnt=10000.0, annual_inc=45000.0, purpose="debt_consolidation")
try:
    # Typo'd field name — caught immediately, not silently set.
    record.anual_inc  # type: ignore[attr-defined]  # noqa: B018
except AttributeError as e:
    print(f"dataclass typo caught: {e}")


class LoanRequest(BaseModel):
    loan_amnt: float
    annual_inc: float
    purpose: str


# Coercion: a numeric string from JSON/form input becomes a float.
coerced = LoanRequest(loan_amnt="10000", annual_inc="45000", purpose="car")
print(
    f"pydantic coerced annual_inc: {coerced.annual_inc!r} (type {type(coerced.annual_inc).__name__})"
)

# Rejection: a non-numeric string fails with a field-named error.
try:
    LoanRequest(loan_amnt="10000", annual_inc="not a number", purpose="car")
except ValidationError as e:
    print(
        f"pydantic rejected bad annual_inc: {e.errors()[0]['loc']} -> {e.errors()[0]['type']}"
    )

The dataclass turns a typo’d field into an immediate AttributeError, so the shape is closed. The pydantic model coerces the numeric string into a float and rejects the non-numeric one with a field-named error, both at construction. That is the boundary doing its job. The malformed annual_inc from the opening story never reaches the scorer, because it is refused at the door with a message you can act on.

That coercion is a convenience and a trap in the same breath, and the framing that keeps you safe is exact. Pydantic is, by its own description, a parsing library more than a validation one. It guarantees what comes out of the model, not what went in. It’ll do whatever it can to bend the input into the declared type. So loan_amnt: float does not mean “reject anything that is not a float.” It means “produce a float if there is any reasonable way to.” "45000" becomes 45000.0, which you wanted. But the same eagerness produces values the caller never literally sent. A model with n_payments: int will take the string "5" and store the integer 5, and it’ll take the boolean True and store 1. No error, because parsing succeeded. So a True that slipped in where a count belonged is now a silent 1 in your features. (Pydantic v2 does draw one line here that v1 did not: a float with a fractional part, like 5.5, is rejected for an int field rather than truncated. A whole-valued float like 5.0 is still accepted as 5.) The contract did exactly what it promised, which was to coerce, not to guard. When the distinction matters, when you’d rather a stringified or cross-type value for a field be rejected than silently converted, that’s what strict mode is for. Declare the field Strict (or set the model to strict) and pydantic refuses the cross-type coercion instead of performing it, turning the silent conversion into a ValidationError you can see. The judgment is per field. Coerce where the input is genuinely a stringified version of the right thing (a JSON request body, a CSV cell), be strict where a type mismatch signals a real upstream bug you want surfaced rather than smoothed over.

There is a second default that bites in exactly the train/serve setting this lesson cares about: by default, pydantic silently ignores fields you did not declare. Send a request with a typo, anual_inc instead of annual_inc, and the model does not complain about the unknown anual_inc. It drops it, and annual_inc falls back to its default or raises only if required. The intended feature arrives missing while validation passes green, which is the precise shape of the skew bug the next section is about. The one-line fix is to forbid unknown fields. Set model_config = ConfigDict(extra="forbid"), and now a misspelled or unexpected field is a loud ValidationError at the boundary instead of a silent drop downstream. For a serving contract, where a malformed request is exactly what you want to catch, extra="forbid" should be the default posture, not the exception.


Try It 2

Write a pydantic model for a loan request with loan_amnt: float and purpose: str. Show it coercing a numeric-string loan_amnt and rejecting a non-numeric one with a field-named error.

python
from pydantic import BaseModel, ValidationError


class LoanRequest(BaseModel):
    pass  # declare loan_amnt: float and purpose: str


# Construct with loan_amnt="10000"  -> should coerce to 10000.0
# Construct with loan_amnt="lots"   -> should raise ValidationError
Hint 1 Notice that you are handing the model two different bad-looking inputs: one that resembles a number written as text, and one that is plainly not a number at all. Watch how the model treats each of them differently at the moment you build it.
Hint 2 The model is not passive about the values it receives. When an input *can* be turned into the declared type, it quietly does so; when it cannot, it refuses and tells you which field was wrong. So the two inputs should not end up in the same place: one becomes valid, the other is rejected.
Hint 3 Look back at the section on validating at the boundary, and what it says happens *during construction* and what the resulting error names. That is the behavior you are demonstrating here.

Solution

python
from pydantic import BaseModel, ValidationError


class LoanRequest(BaseModel):
    loan_amnt: float
    purpose: str


ok = LoanRequest(loan_amnt="10000", purpose="car")
print(f"coerced: loan_amnt={ok.loan_amnt!r} ({type(ok.loan_amnt).__name__})")

try:
    LoanRequest(loan_amnt="lots", purpose="car")
except ValidationError as e:
    err = e.errors()[0]
    print(f"rejected at the boundary: field={err['loc']} type={err['type']}")

The numeric string coerces to 10000.0, and the non-numeric one is refused at construction with the field named. Downstream code never sees a malformed loan_amnt. That is what “validate at the boundary” buys you.

One contract for train and serve

The same typed contract that loads your training rows should describe the serving request. One source of truth for the shape is the first line of defense against train/serve skew. If train and serve quietly disagree about the shape, the model rots while every test stays green.

The way skew sneaks in is that training data and production data get built by different code that drifts apart over time: a column renamed on one side, a default that differs, an encoding applied on one path only. Two shape definitions are two things that can diverge. One shared contract, imported by both paths, cannot silently disagree, because there is only one definition to change. Be clear about the bound, though. This eliminates the shape-mismatch class of skew. It does nothing about distribution shift, the values themselves drifting, which is M2’s problem. But shape mismatch is the cheapest class to prevent and the most embarrassing to debug, so closing it here is worth the one shared class.

Here’s the one shared model parsing both a training row and a serving request.

"""Lesson 4.3 Show — one contract for both a training row and a serving request.

The same pydantic model parses a row read from the training CSV and a request that
arrives at serve time, so both paths produce the *same typed object*. Define the
schema once and a renamed field breaks validation in both places at once — you cannot
drift train and serve apart silently. What it does NOT prevent is value-distribution
skew (the train and serve data being differently shaped); the contract guarantees the
shape, not the statistics — that is M2/M4's job.
"""

from pydantic import BaseModel

from ml_pipeline.datasets import load_loans


class LoanFeatures(BaseModel):
    loan_amnt: float
    annual_inc: float
    purpose: str


# Train path: one row off the CSV, parsed through the contract.
row = load_loans().iloc[0]
train_obj = LoanFeatures(
    loan_amnt=row["loan_amnt"], annual_inc=row["annual_inc"], purpose=row["purpose"]
)

# Serve path: a request dict (e.g. from JSON), parsed through the SAME contract.
request = {"loan_amnt": "10000", "annual_inc": "45000", "purpose": "car"}
serve_obj = LoanFeatures(**request)

print(f"train row  -> {type(train_obj).__name__}: amnt={train_obj.loan_amnt}")
print(f"serve req  -> {type(serve_obj).__name__}: amnt={serve_obj.loan_amnt}")
print(f"same contract class for both: {type(train_obj) is type(serve_obj)}")
print("a renamed field would break BOTH paths at once — they cannot drift apart")

One model parses both a training row and a serving request into the same typed object. Rename a field in one place and validation breaks in both at once. You cannot drift them apart silently, because there is nothing to drift. The failure variant is the one to fear: each path keeps its own copy of the field list, someone renames annual_inc to income on the serving side only, and the model serves on an always-default feature while the offline metrics look perfectly healthy.

One practical caution about where you run this contract, because the obvious placement is a performance trap. A pydantic model validates one record at a time, which is exactly right at the serving boundary, where requests arrive one row at a time and the per-row cost is invisible. It is exactly wrong at the training boundary, where you have a frame of a million rows. Looping over the DataFrame and constructing a model per row pays the per-row construction cost a million times, and iterating a pandas frame row by row is already an order of magnitude slower than working with it column-wise (the same reason vectorized feature code matters in M2). A contract you run per-row over a training set can turn a few-second load into minutes. The shape of the fix is to match the validator to the boundary. The pydantic model guards the single-request serving edge, and the frame gets validated in bulk with a column-aware schema check: a dtype-and-range pass over whole columns at once, the job of a frame-schema tool rather than a per-row model. Same contract idea, two enforcement points sized to their data. One row at the request edge, whole columns at the frame edge.

Now let’s be precise about what a shared contract does not buy you, because the gaps are where skew still ships. It pins field names and types. It does not pin meaning. Two kinds of skew slip through a green validation. The first is coercion-masked: train sends annual_inc=45000 and serving sends "45000", and a permissive contract coerces both to the same float, so the types agree and the values silently differ in how they were produced. The validator passes on a path that is already drifting. The second is units and encoding: the contract accepts loan_amnt as a float whether it arrives in dollars or cents, or purpose as a string whether the serving side started emitting a new category the training data never had. Same type, different distribution, no error. A shared contract closes the shape-mismatch door. The unit, encoding, and category-set doors are still open, and they’re the ones that pass review precisely because validation stays green. Knowing which class of skew the contract does and does not stop is the difference between a guardrail you trust correctly and one you trust blindly.

[LoanFeatures\n(one contract)] as contract
package "train path" {
  [loader] as loader
  [fit] as fit
}
package "serve path" {
  [request] as req
  [score] as score
}
loader --> contract : parse
contract --> fit
req --> contract : parse
contract --> score

Both paths import the one contract, so they share a single definition of the shape and cannot disagree on it. The failure variant, each path owning its own copy, is precisely how a one-sided rename becomes a silent skew.


Try It 3

A training loader and a serving handler each define their own field list, and someone renamed annual_inc to income on only one side, so the model trains on one shape and serves on another. Rewrite them to share one contract, and note in a comment which class of bug that closes and which it does not.

python
# train path knows:  loan_amnt, annual_inc, purpose
# serve path knows:  loan_amnt, income, purpose    <- drifted name!
# Result: trains on annual_inc, serves on a missing field -> silent default.
#
# Define ONE contract and point both paths at it.
contract = "???"
print(contract)
Hint 1 Read the two comment lines describing what each path "knows." The field lists are nearly identical except for one name. Notice that there are two separate lists at all, and ask what that duplication makes possible.
Hint 2 Two independent definitions of the same shape can drift apart, and nothing forces them to stay in sync; a rename on one list leaves the other untouched and silent. So the fix is to remove the second definition entirely rather than try to keep two lists matching.
Hint 3 Re-read the section on one contract for train and serve: it states exactly which class of bug a single shared definition closes, and the one class it explicitly does not. Your closing comment should name both.

Solution

python
from pydantic import BaseModel


class LoanFeatures(BaseModel):  # one source of truth for the shape
    loan_amnt: float
    annual_inc: float
    purpose: str


train_row = LoanFeatures(loan_amnt=10000, annual_inc=45000, purpose="car")
serve_req = LoanFeatures(loan_amnt=10000, annual_inc=45000, purpose="car")
print(f"same contract on both paths: {type(train_row) is type(serve_req)}")
# Closes the SHAPE-mismatch class: a renamed field now breaks both paths at once,
# not silently on one. Does NOT close distribution skew (values drifting) -- M2.

A renamed field is now a single edit that fails validation everywhere, instead of a one-sided drift that quietly serves an always-default feature. The contract guarantees the shape, not the statistics.


What you hardened

The package now refuses malformed input at the boundary instead of trusting it. Hints document the shape for a checker, a dataclass fixes the field set, pydantic validates at construction, and one shared contract keeps train and serve from disagreeing about shape. Each step pushes the failure earlier, from “weird number deep in training” to “rejected at the door, with the field named.” One more boundary deserves the same treatment, and the module project points the identical machinery at it: the configuration the package reads at startup. A Settings class built on pydantic-settings is this lesson’s BaseModel aimed at the environment, with typed fields, coercion from env strings, and a ValidationError naming a missing required value. So the project’s settings.py is not a new tool to learn. It is this contract applied to config.

  • Type hints are metadata, not runtime enforcement: mypy verifies them, and only if you run it. Bare dict says almost nothing; dict[str, float] makes content checkable.
  • A dataclass fixes the field set at definition, turning a typo’d field into a checker error; it does no runtime type enforcement.
  • A pydantic model validates at construction, coercing what it can and raising a field-named ValidationError otherwise, so the boundary rejects bad input before interior logic runs.
  • One shared contract for train and serve kills the shape-mismatch class of skew; it does not catch distribution shift.

Check your understanding:

  • Does a type hint stop a wrong-typed value at runtime? If not, what does, and when?
  • What does a dataclass catch that a dict allows, and what does pydantic add that a dataclass does not?
  • How does sharing one contract prevent a class of silent bug, and which class does it not prevent?