Consider a small service that extracts an invoice line: item code, quantity, unit price, and total. The response arrives as JSON, and Pydantic creates an object without an error. It is tempting to pass that object straight to the write step and call the job finished.
Look at the numbers. The quantity is three, the unit price is 2500, and the line total is also 2500. Every value is a positive integer. But three units at 2500 should give 7500. The line needs a closer look before we save it.
For this case, I would separate the work into three steps: parse the response, check the rules of the task, and prepare a result for the next stage. A small Python function will show the boundary. It returns a draft or a reason for human review, without calling a model or a database.
What we asked Pydantic to check
Start with the schema. Prices and totals in this example use the minor units of a single currency. Taxes, discounts, and rounding are outside this simplified calculation.
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class ExtractedLine(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
sku: str = Field(min_length=1)
quantity: int = Field(gt=0)
unit_price_minor: int = Field(ge=0)
line_total_minor: int = Field(ge=0)
We have described the fields and constraints: a nonempty item code, a positive quantity, and nonnegative prices and totals. Extra fields are forbidden. In this example, strict mode prevents the string "3" from being quietly converted to an integer. The strict mode documentation explains that behavior.
Now pass an object with the wrong total:
payload = {
"sku": "KB-01",
"quantity": 3,
"unit_price_minor": 2500,
"line_total_minor": 2500,
}
line = ExtractedLine.model_validate(payload)
print(line.quantity, line.line_total_minor)
The output is:
3 2500
Pydantic performed the checks we specified. The schema contained no rule connecting quantity, price, and total. The guarantee described in the model documentation also concerns the declared types and constraints. We still have to express the rule for our task.
The order comes from the application
We need context as well as arithmetic. Does the item belong to the selected order? Does the price match? Does the quantity exceed the amount still outstanding?
For this example, assume the application has already selected an order and loaded its trusted line. A price difference, an unknown item, or a quantity above the remaining amount goes to review. These are the rules of our teaching example, rather than universal rules for processing invoices.
The model does not choose the order ID or its revision. The application obtains them from its own system. Words in the document or a field in the JSON must not switch processing to a different order.
A small check of the task's rules
Add the following classes and functions to the ExtractedLine definition. Together, they form invoice_validation.py:
from dataclasses import dataclass
@dataclass(frozen=True)
class OrderLine:
order_id: str
revision: int
sku: str
unit_price_minor: int
remaining_quantity: int
@dataclass(frozen=True)
class DraftLine:
order_id: str
order_revision: int
sku: str
quantity: int
line_total_minor: int
@dataclass(frozen=True)
class NeedsReview:
reason: str
def check_line(line: ExtractedLine, order: OrderLine) -> DraftLine | NeedsReview:
if line.sku != order.sku:
return NeedsReview("unknown_item")
if line.quantity > order.remaining_quantity:
return NeedsReview("quantity_exceeds_order")
if line.unit_price_minor != order.unit_price_minor:
return NeedsReview("price_differs")
expected_total = line.quantity * order.unit_price_minor
if line.line_total_minor != expected_total:
return NeedsReview("total_mismatch")
return DraftLine(
order_id=order.order_id,
order_revision=order.revision,
sku=order.sku,
quantity=line.quantity,
line_total_minor=expected_total,
)
def prepare_line(payload: dict[str, object], order: OrderLine) -> DraftLine | NeedsReview:
try:
line = ExtractedLine.model_validate(payload)
except ValidationError:
return NeedsReview("invalid_shape")
return check_line(line, order)
prepare_line receives an already decoded JSON object. Obtaining the response and handling JSON syntax errors sit outside this function. It checks the shape first, then passes the object to check_line, where the rules for the selected order are visible.
OrderLine is trusted application context. This function does not check how it was loaded or who authorized access to the order. It must not be built from the same model response we are trying to validate.
Notice what happens to the inconsistent total. The function does not silently replace it with 7500. It returns total_mismatch, because the mistake could be in the quantity, price, or total. Multiplication alone cannot tell us which field to change.
See the difference without calling a model
Use an order with a unit price of 2500 and five units outstanding:
order = OrderLine("PO-17", 4, "KB-01", 2500, 5)
print(prepare_line(payload, order))
corrected = {**payload, "line_total_minor": 7500}
print(prepare_line(corrected, order))
The result is:
NeedsReview(reason='total_mismatch')
DraftLine(order_id='PO-17', order_revision=4, sku='KB-01', quantity=3, line_total_minor=7500)
We changed the second object manually to check an allowed case. It is not another response from a real model, and it tells us nothing about extraction quality.
Alongside that case, I would test a string in place of the quantity, an unknown item, an excessive quantity, and a different price. Extra order_id or order_revision fields should also be rejected: the application has already selected that context.
These checks need neither a live database nor an external API. The accompanying example has eight tests covering an allowed result, review reasons, and unchanged inputs. The code was checked with Python 3.12.14 and Pydantic 2.14.1.
Arithmetic does not verify the document
Now take a more troublesome case. The original invoice says three units, but the model returns two and correctly multiplies the price by two:
plausible = {**payload, "quantity": 2, "line_total_minor": 5000}
result = prepare_line(plausible, order)
assert result == DraftLine("PO-17", 4, "KB-01", 2, 5000)
This result passes our checks. The item is known, the quantity fits within the outstanding amount, the price matches, and the arithmetic is correct. The quantity still disagrees with the original invoice.
That boundary needs to stay visible. We checked the fields against the specified rules; comparison with the original still needs a place in the process. I would show the specialist the source document alongside the proposed values. Ambiguous or contradictory extraction calls for clarification or manual review. Filling a missing required field with zero just to complete processing would hide the problem.
Some comparisons may be automated against a reliable source. Another call to the same model, however, does not by itself make the check independent or establish that the numbers are right. The two-instead-of-three case is included in the tests specifically to show this limit.
These rules can also live inside Pydantic
Pydantic supports field and model validators. For example, a model_validator could check that the total equals quantity multiplied by price.
I used a separate function here so that the order context and the decision are visible together: a draft or a reason for review. If a team finds it clearer to express some conditions inside Pydantic, that is a reasonable choice. What matters is which rules actually run and which information they rely on.
Receiving a DraftLine does not finish the job either. Before an actual write, the application must check permissions, the current order state, and acceptance conditions. Keeping the order revision can help detect a change, but it does not lock the order or replace a check at write time. Transactions, repeated invoice processing, and the specialist's final decision need their own process; this function does not implement them.
I want the calling code to understand the result precisely: the fields were parsed, the listed rules passed, and a draft was prepared for the selected order. If we later discover that the model misread the quantity, we can identify the comparison we have yet to arrange. A successful model_validate call should leave that question visible.
References
- Pydantic. Models.
- Pydantic. Strict Mode.
- Pydantic. Validators.
Top comments (0)