How-to
Advanced Data Transformation And CleaningDeep dive

Validate Excel Rows with pydantic Models

Describe what a valid row looks like once, as a pydantic model, and check every row of a sheet against it. Map Excel headers to fields with aliases, constrain values, normalise text before checking, add rules that span columns, and turn validation errors into a report that names the Excel row, column and value.

Checking a sheet cell by cell with if statements works for three rules and becomes unreadable at thirty. A pydantic model turns the rules into a declaration — this column is a date, that one a positive whole number, this one one of three statuses — and validating a row against it reports every broken rule for that row, with the field name and the value that failed. Because each row is checked separately, you can map each error back to its Excel row and column and hand the person who filled in the sheet a precise list to fix. This page is part of Validating Excel Data with Python.

Validating a sheet row by row Five steps. Read the header row and each data row with openpyxl. Build a dict for each row keyed by header. Validate it with the pydantic model. Collect valid rows as model objects and invalid rows as errors with their Excel row number. Write an error report and load only the valid rows. Read rows openpyxl values Row to dict keyed by header Validate model_validate Split valid or errors Report row, column, value

Prerequisites

Bash
pip install "pydantic>=2" openpyxl pandas

orders.xlsx has six orders. The first is clean; the rest between them have a bad email address, a quantity of zero, a status in the wrong case, a date with a time, a price of "n/a", an unknown status, a discount on a small order and a missing order ID.

Step 1 — Describe a valid row as a model

Each field is a column. The type says what the value must be, Field adds constraints, and alias maps the field to the Excel header, which usually contains spaces and capitals:

Python
from datetime import date
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field

class Order(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True)

    order_id: str = Field(alias="Order ID", pattern=r"^SO-\d{4}$")
    email: str = Field(alias="Customer email", pattern=r"^[^@\s]+@[^@\s]+\.[a-z]{2,}$")
    order_date: date = Field(alias="Order date")
    qty: int = Field(alias="Qty", gt=0)
    unit_price: float = Field(alias="Unit price", gt=0)
    status: Literal["open", "paid", "void"] = Field(alias="Status")
    discount_pct: Optional[float] = Field(default=None, alias="Discount %", ge=0, le=50)

print(list(Order.model_fields))

str_strip_whitespace removes stray spaces before any check runs, so " cai@example.com " passes. Literal restricts the status to three values, pattern checks the ID and email format, and Optional[...] = None makes the discount column optional while still limiting its range when filled. For proper email validation, install email-validator and use pydantic's EmailStr instead of the pattern.

Step 2 — Validate each row and keep the errors

Read the header and rows with openpyxl — empty cells arrive as None, which is what pydantic expects for a missing value — and validate one row at a time. ValidationError.errors() lists every failure for the row:

Python
from openpyxl import load_workbook
from pydantic import ValidationError

ws = load_workbook("orders.xlsx", read_only=True)["Orders"]
rows = ws.iter_rows(values_only=True)
header = next(rows)

def validate_sheet(model, header, rows):
    valid, errors = [], []
    for excel_row, values in enumerate(rows, start=2):
        record = dict(zip(header, values))
        try:
            valid.append(model.model_validate(record))
        except ValidationError as exc:
            for err in exc.errors():
                errors.append({"row": excel_row, "column": err["loc"][0] if err["loc"] else None,
                               "value": err.get("input"), "problem": err["msg"]})
    return valid, errors

valid, errors = validate_sheet(Order, header, rows)
print(len(valid), "valid rows")
for e in errors:
    print(e["row"], e["column"], repr(e["value"]), "-", e["problem"])

Two rows pass. Row 3 fails on two fields at once — both reported, not only the first. Row 4's only error is its status, "Paid", which is not one of the lower-case choices; its quantity, the text "12", and its date, the text "2026-09-03", were accepted, because pydantic's default lax mode converts compatible values. Row 5 shows an Excel subtlety: a date field rejects a datetime with a time part, so a timestamp typed into a date column is caught. Row 7 has no order ID, and None is not a string. Numbering from 2 makes row the real Excel row, because row 1 is the header.

Step 3 — Normalise values before checking

Some values are acceptable once tidied: a status in the wrong case, a code with lower-case letters. A field_validator with mode="before" runs on the raw value before type checks, so it can clean up what the model should accept:

Python
from pydantic import field_validator, model_validator

class Order(Order):
    @field_validator("status", mode="before")
    @classmethod
    def lower_status(cls, v):
        return v.lower() if isinstance(v, str) else v

    @model_validator(mode="after")
    def discount_needs_volume(self):
        if self.discount_pct and self.qty < 10:
            raise ValueError("a discount needs a quantity of 10 or more")
        return self

rows = load_workbook("orders.xlsx", read_only=True)["Orders"].iter_rows(values_only=True)
header = next(rows)
valid, errors = validate_sheet(Order, header, rows)
print(len(valid), "valid rows:", [o.order_id for o in valid])
for e in errors:
    print(e["row"], e["column"], repr(e["value"]), "-", e["problem"])

Row 4 now passes. The model_validator with mode="after" sees the whole validated row, so it can express rules that span columns — here, that only orders of 10 or more may carry a discount, which catches row 6. Its error has no field, so column is None; the report calls it a row-level problem. A model validator only runs when every field is valid, so a row with field errors is reported for those first.

Which pydantic feature expresses which rule A table of rule kinds. A type such as int or date checks the kind of value. Field with gt, ge, le or pattern constrains its range or format. Literal restricts it to a fixed set of choices. A before field_validator cleans the raw value first, for example lower-casing. An after model_validator checks rules that span several columns. pydantic feature Example Kind of value type annotation qty: int Range or format Field(gt=, pattern=) gt=0 Fixed choices Literal open, paid, void Tidy first field_validator before lower-case status Across columns model_validator after discount needs qty ≥ 10

Step 4 — Write an error report people can act on

Turn the errors into a sheet with the Excel row, the column letter and header, the value and the problem. Mapping the header back to its letter lets the reader jump straight to the cell:

Python
import pandas as pd
from openpyxl.utils import get_column_letter

report = pd.DataFrame([{
    "cell": f"{get_column_letter(header.index(e['column']) + 1)}{e['row']}" if e["column"] else f"row {e['row']}",
    "column": e["column"] or "(whole row)",
    "value": e["value"] if e["column"] else None,
    "problem": e["problem"],
} for e in errors])
report.to_excel("orders_errors.xlsx", index=False)
print(report.to_string(index=False))

Pydantic's messages are clear enough to show as they are; replace them with your own wording by mapping on err["type"] — greater_than, literal_error, string_pattern_mismatch and so on — if the audience needs friendlier text. Highlight Invalid Cells in Excel with Python marks the same cells in a copy of the workbook.

The error report, one line per broken rule The orders_errors.xlsx sheet. B3 Customer email ben@example does not match the pattern. D3 Qty 0 should be greater than 0. C5 Order date has a time part. E5 Unit price n/a is not a valid number. F5 Status shipped is not open, paid or void. Row 6 as a whole has a discount with a quantity under 10. A7 Order ID is missing. orders_errors.xlsx A B C D 1 cell column value problem 2 B3 Customer email ben@example pattern mismatch 3 D3 Qty 0 must be > 0 4 C5 Order date 2026-09-04 14:30 has a time 5 E5 Unit price n/a not a number 6 F5 Status shipped not a choice 7 row 6 (whole row) discount needs qty ≥ 10 8 A7 Order ID not a string

Step 5 — Load only the valid rows

Valid rows are model objects with clean, typed values: dates are date, quantities int, text stripped and statuses lower-case. model_dump() turns them back into dicts for a DataFrame, a database insert or an API call:

Python
clean = pd.DataFrame([o.model_dump() for o in valid])
print(clean.to_string(index=False))
print(clean.dtypes.to_dict())

The field names, not the Excel headers, become the columns — convenient for loading into a database. Pass by_alias=True to model_dump to get the original headers back.

Reading with pandas instead

If the rows come from pd.read_excel, empty cells arrive as NaN, a float — not None. An optional field then receives NaN, and a constraint such as le=50 rejects it with a confusing "less than or equal to 50" message; an unconstrained optional float would silently accept it. Replace NaN with None before validating:

Python
df = pd.read_excel("orders.xlsx")
records = df.astype(object).where(df.notna(), None).to_dict("records")
print(df.loc[0, "Discount %"], "->", records[0]["Discount %"], "->", Order.model_validate(records[0]).discount_pct)

pandas also turns the date column into Timestamps at midnight, which pydantic accepts for a date field.

Strict or lax

Lax mode accepted "12" as the quantity 12. That is usually what you want for spreadsheet data, where numbers are often typed as text, but when a text number signals a real problem — a column of IDs imported as text that should be numeric — use Field(strict=True) on that field, or ConfigDict(strict=True) for the whole model, and the row is rejected instead.

Common pitfalls

  • Stopping at the first error. Validate row by row and collect exc.errors(); each lists every failed field.
  • Losing the Excel row number. Start the row counter at 2 so it matches the sheet.
  • Passing pandas NaN. Convert to None first, or read with openpyxl.
  • Datetimes in date fields. A time part fails date; decide whether that is an error or truncate it in a before-validator.
  • Cross-column rules hidden by field errors. Model validators run only when fields pass; fix field errors first.

Performance notes

pydantic v2 validates tens of thousands of rows a second. For very large sheets, validate with TypeAdapter(list[Order]) in one call when you only need the valid/invalid split, or use a column-based tool such as pandera, which checks whole columns at once.

Conclusion

Describe a valid row as a pydantic model: types for kinds of value, Field for ranges and patterns, Literal for choices, aliases for Excel headers, before-validators to tidy raw values and a model validator for rules across columns. Read rows with openpyxl so blanks are None, validate each row, and collect every error with its Excel row and column into a report. Load only the valid rows, using model_dump() for clean, typed records.

Frequently asked questions

Why use pydantic to validate Excel data? A pydantic model states the rules for a row in one place — types, ranges, allowed values, patterns and rules across columns — and reports every broken rule for a row at once, with the field and the offending value. That makes a precise error report straightforward.

How do I map Excel headers with spaces to pydantic fields? Give each field an alias matching the header, such as order_id: str = Field(alias="Order ID"), and validate a dict built from the header row and the data row with Model.model_validate(record).

Why does pydantic reject an empty Excel cell read with pandas? pandas turns empty cells into NaN, which is a float, not None. An optional field receives NaN instead of None, and constraints such as le=50 fail on it. Read rows with openpyxl, where empty cells are None, or replace NaN with None before validating.

Does pydantic convert "12" to 12? Yes, by default pydantic coerces compatible values — the text "12" becomes the integer 12. Set strict=True on a field or in the model config to reject text where a number is required.

More on validation & data quality

More on validation & data quality →