The cell that works is not the function that ships
You have a notebook cell that takes an order line, checks it looks right, and prints a tidy result. It works. You call it twice more with values you typed yourself, and it works both times. The temptation, reasonable and common, is to wrap that cell in a function, give it a name, and hand it to a colleague who wants to call it from a web service. The colleague asks a question you did not expect: what happens when the caller sends a quantity as "6" instead of 6? You do not know, because you never sent that value. The cell was tested against inputs you chose, not inputs an external caller will actually produce.
That gap between "ran once with data I built" and "callable contract another service can depend on" is the whole subject here. A contract is not a nicer word for a function. It is a declared boundary: which inputs are accepted, which are rejected and why, and what shape the output takes when the call succeeds. Until those three things are written down and checked, a notebook result is a demonstration, not an interface.
What a contract has to name
Three commitments make up a callable contract, and each one answers a different question.
Accepted inputs. What field names, types and ranges will the function process without complaint? A quantity field typed as an integer between 1 and 50 is a commitment, not a suggestion. If the sender provides 51, the function must refuse before doing any work, not clamp the value quietly.
Rejected inputs, with a stable reason. A caller building against your function needs to know not just that 0 fails, but that it fails because quantities must be positive, distinguishable from a failure because the field was missing entirely. Missing and null are different facts. A field that can be omitted behaves differently from a field that must be present but may hold None. Collapsing that distinction into a single generic "bad request" forces every caller to guess.
Stable output shape. When the call succeeds, what does the caller receive? Same field names, same types, every time, regardless of which code path produced the answer. If a local function returns a Python integer and a web adapter for the same logic returns a JSON string, you have two contracts wearing one name.
The teaching example that anchors this, a strict Pydantic model rejecting Boolean, float, and numeric-string values for an integer quantity field, comes from an existing course lesson on runtime contracts (course lesson). That lesson builds the validation boundary; this article extends it into the boundary between a local function and something calling it over a network, which is the part a notebook rarely tests.
A worked contract: validate_line
Take a single order line: a SKU and a quantity. The notebook version might look like this, reusing the strict-mode approach from that lesson.
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from typing import Union
class Contract(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
class OrderLine(Contract):
sku: str = Field(min_length=1, max_length=40)
quantity: int = Field(ge=1, le=50)
class RejectedLine(BaseModel):
error_code: str
field: str
detail: str
def validate_line(payload: dict) -> Union[OrderLine, RejectedLine]:
try:
return OrderLine.model_validate(payload)
except ValidationError as exc:
first = exc.errors(include_input=False, include_context=False)[0]
return RejectedLine(
error_code=first["type"],
field=".".join(str(part) for part in first["loc"]),
detail="quantity or sku failed validation",
)Run this with {"sku": "vanilla", "quantity": 6} and you get back an OrderLine with an integer quantity of 6. Run it with {"sku": "vanilla", "quantity": "6"} under strict mode and you get a RejectedLine naming the quantity field and an error code describing the type mismatch, because strict mode does not convert a numeric string into an integer. That refusal is a design decision worth stating plainly: the sender is expected to produce the correct type, not for the receiver to guess it.
This function is now a contract in the sense that matters. It returns one of exactly two typed shapes, never a bare dictionary, and converts Pydantic validation failures into the rejection shape. Programming errors and resource failures can still raise exceptions. Pin the dependency version and test the error codes before treating Pydantic's codes as a permanent external API.
Where the contract breaks on the way to a service
Here is the part a notebook usually never exercises: the same logic, reached through an adapter that simulates an HTTP boundary. Below is a toy in-memory adapter, standard library only, that plays the role of "the network" for teaching purposes. It is not a claim about any deployed server, and its counts describe only calls made inside this one Python process.
import json
def request_adapter(raw_body: str) -> str:
"""Toy stand-in for an HTTP handler: decode JSON, call validate_line,
encode the result back to JSON. Runs in-process only."""
try:
payload = json.loads(raw_body)
except json.JSONDecodeError:
return RejectedLine(error_code="invalid_json", field="body",
detail="body must be valid JSON").model_dump_json()
result = validate_line(payload)
return result.model_dump_json()
good = request_adapter('{"sku": "vanilla", "quantity": 6}')
coerced_risk = request_adapter('{"sku": "vanilla", "quantity": "6"}')
print(good)
print(coerced_risk)The integer call returns the accepted order line; the string call returns RejectedLine, matching their respective direct calls. That agreement is the point of the exercise, and it is worth stating exactly what it proves and what it does not. It proves that, within this process, the adapter and the direct function call reach the same typed decision for the same JSON body. It does not prove anything about a real HTTP server, a real client library's JSON encoding quirks, or a production deployment's serialization settings, because none of those exist in this example. A counted result from an in-memory loop tells you about that loop.
The risk this experiment is built to catch is a different one: an adapter written independently of the validation function, one that parses the incoming JSON loosely and coerces "6" to 6 before calling into the validator, defeating the strict check upstream. That bug is easy to introduce when an adapter adds a coercion step. JSON does distinguish numbers from strings: 6 and "6" are different values. A hand-rolled converter can erase that difference without anyone noticing until a caller sends malformed data that the contract was supposed to catch.
The failure mode lives at the arrow from "Adapter parses body" to "validate_line checks type": if the adapter coerces the value during parsing, the validator never sees the original string and cannot reject it. The fix is not more validation code. It is making sure exactly one function owns the type decision, and every adapter, local or networked, calls that same function with the untouched payload.
Running request_adapter a thousand times in one script and counting agreements tells you the logic is internally consistent. It says nothing about a deployed API, a load balancer, a client SDK's serialization defaults, or what happens under concurrent requests against real storage. Treat this experiment as a design check, not a production guarantee.
Building the expected-results table
Before wiring any real transport, write the table you expect every implementation to satisfy. This is cheap to build and expensive to skip.
| Input quantity | Input type | Expected outcome | Reason |
|---|---|---|---|
6 | integer | accepted, quantity 6 | within range, correct type |
"6" | string | rejected, field quantity | strict mode does not coerce |
True | boolean | rejected, field quantity | Python treats bool as int subtype; strict mode still rejects |
6.0 | float | rejected, field quantity | float is not the declared int type |
0 | integer | rejected, field quantity | below minimum of 1 |
51 | integer | rejected, field quantity | above maximum of 50 |
| missing | absent | rejected, field quantity | required field not supplied |
None / JSON null | null | rejected, field quantity | field does not accept null |
Run that table against both paths. The expected codes below are Pydantic v2 codes; the article's adapter leaves JSON values untouched.
cases = [
({"sku": "vanilla", "quantity": 6}, None),
({"sku": "vanilla", "quantity": "6"}, "int_type"),
({"sku": "vanilla", "quantity": True}, "int_type"),
({"sku": "vanilla", "quantity": 6.0}, "int_type"),
({"sku": "vanilla", "quantity": 0}, "greater_than_equal"),
({"sku": "vanilla", "quantity": 51}, "less_than_equal"),
({"sku": "vanilla"}, "missing"),
({"sku": "vanilla", "quantity": None}, "int_type"),
]
for payload, expected_error in cases:
direct = validate_line(payload).model_dump()
adapted = json.loads(request_adapter(json.dumps(payload)))
assert direct == adapted
assert direct.get("error_code") == expected_error
assert json.loads(request_adapter("{"))["error_code"] == "invalid_json"
print("Eight typed cases agree; malformed JSON has a declared rejection")Every row checks a declared outcome, in addition to agreement between two paths. Any row where they disagree is a regression, full stop, regardless of whether the disagreement looks harmless. A local function returning 6 as an integer while an adapter returns "6" as a string is exactly the kind of quiet contract drift that a caller several weeks later discovers the hard way, usually while debugging an arithmetic error that turns 6 * 2 into "66".
Write down the field names, types and bounds your function actually enforces, not the ones you assume it enforces. Read them off the validation model, not from memory.
Before adding new fields or adapters, list every input you expect to fail and the specific reason. Boolean, float, numeric-string and out-of-range values each deserve a separate row.
Call the local function and any adapter with the identical payloads. A disagreement on any row is the defect to fix, not the table.
State what a caller receives on success and on rejection, including field names for the error. This is what makes the contract callable rather than merely internally correct.

Where responsibility ends
Passing this contract establishes something specific and nothing more. It confirms the payload has the declared shape, the declared types, within the declared ranges. It does not confirm that vanilla exists in a catalog, that six tubs are actually in stock, or that whichever identity sent the request is authorized to place an order. Those are separate checks against separate evidence: a stock snapshot, a permissions table, a business rule about today's shortages. Anthropic's engineering notes on writing tools for agents make a related point about tool design generally: how a tool describes its inputs, outputs and failure modes shapes how reliably an agent uses it, which is a reason to keep those descriptions explicit rather than implied (Anthropic: writing tools for agents). A contract that only says "returns an order line" without saying what a rejection looks like is under-specified in exactly this way.
The strictness itself is also narrower than it might first appear. Pydantic's own documentation on strict mode notes that coercion behavior differs by type, and that JSON input handling for cases such as dates carries its own exceptions (Pydantic: strict mode). The integer example worked through here does not generalize automatically to every field type your contract might need; check the specific type and the installed library version before assuming a new field behaves the same way a quantity field does.
Check the decision
What would prove this wrong
If you ran the expected-results table against a real deployed service, not the in-memory adapter used here, and found that the network path silently converted the string quantity into an integer while the local function still rejected it, that would falsify the claim that "the same validate_line function guarantees the same behavior everywhere." It would mean something between the caller and your function, a framework's request parsing, a serialization library, a proxy, is rewriting the payload before your validator sees it. That is a real and common failure, and finding it is exactly why the table needs to run against the actual entry point your callers will use, not just the function in isolation. Nothing in this article claims that check has been run against a production system; the fixtures described here are proposed and bounded to a single process, and confirming them against a real deployment is the next piece of work, not something already demonstrated.
The usable next action is small: take one function you currently trust because it ran correctly once, write its rejection table with at least five deliberately wrong inputs, and run that table through every way a caller can reach the function. Where the outputs disagree, you have found the actual boundary of your contract, which is more useful than the boundary you assumed it had.
Continue with the course lesson that builds the strict validation model this article extends into a callable, multi-adapter contract.

