DEV Community

The Practical Developer
The Practical Developer

Posted on Originally published at practicaldevblog.blogspot.com

Pydantic: Catch Bad Data Before It Reaches Your Database

A library lets members borrow books through a web form. One day, a member types -5 into the loan period field, another mistypes her email, and a third pastes an ISBN with a letter in it. The form saves all three loans without complaint. The reminder email bounces, the -5 loan shows as overdue, and nobody can find the book with the broken ISBN. The database stored what it was given. The problem is that nobody checked what was given first. That is the gap Pydantic fills. You describe the shape of the data once, as a Python class, and Pydantic checks every record against it before your code trusts it.

What I would check before using it

My judgement is that Pydantic is worth adding the moment your code receives data it did not create: a form submission, a JSON body from an API, a row read from a spreadsheet. Inside your own code, a model is extra ceremony. At a boundary, where the data comes from a stranger, it is the cheapest insurance you can buy. The first thing I would check is which version you are installing. Pydantic v2 is a rewrite of v1, with different syntax, and v1 tutorials will mislead you. Install v2 and read v2 docs only.

The second check is the email field. Pydantic's EmailStr needs an extra package, or your model refuses to load, with an error saying email-validator is not installed. I hit exactly that error while preparing this post, and the fix is one install: pip install "pydantic[email]". The third check is how strict the validation should be. By default Pydantic is forgiving: it converts the string "14" into the number 14, and the string "2026-11-09" into a real date. That is usually what you want from form data, but if you need strict types, say so explicitly with strict mode, or the coercion will hide sloppy input.

The fourth check is the alternative. Plain dataclasses plus hand-written if statements do the same job for one form, but they scatter the rules across your code and never produce a useful error message. Marshmallow does similar work and is a fine choice if your project already uses it. The verdict: use Pydantic for data that crosses a boundary into Python code, especially forms and API payloads. That flips if the data never leaves your own code, or if your team already validates with another library and switching means rewriting working code.

A library loan, checked end to end

A Pydantic model is a small Python class that declares what a piece of data must look like: this field is an email, that field is a whole number between 1 and 30, this one is a date. You write it once, and you build a model instance from every record that comes in. Valid data passes through as a typed object, with strings already converted to numbers and dates. Invalid data is rejected with a list of exactly what was wrong, field by field, so the form can tell the member what to fix instead of saving a broken loan.

The worked example is the library from the opening. A loan has a member name, a member email, a book ISBN, a loan period in days, and a loan date. The model says what each of those must be: the name must not be empty, the email must look like an email, the ISBN must be exactly 13 digits, the loan period must be a whole number from 1 to 30 days, and the loan date must be a real date. Here is the model and a valid loan, run with Python 3.12.3 and Pydantic 2.14.3:

from datetime import date, timedelta
from pydantic import BaseModel, EmailStr, Field


class Loan(BaseModel):
    member_name: str = Field(min_length=1)
    member_email: EmailStr
    book_isbn: str = Field(pattern=r"^\d{13}$")
    loan_days: int = Field(gt=0, le=30)
    loan_date: date


loan = Loan(
    member_name="Ayesha Khan",
    member_email="ayesha@example.com",
    book_isbn="9780134685991",
    loan_days=14,
    loan_date="2026-11-09",
)
print("Loan accepted for", loan.member_name)
print("Book ISBN:", loan.book_isbn)
print("Loan days:", loan.loan_days)
due = loan.loan_date + timedelta(days=loan.loan_days)
print("Due back:", due)
Enter fullscreen mode Exit fullscreen mode

Running it gave this real output:

Loan accepted for Ayesha Khan
Book ISBN: 9780134685991
Loan days: 14
Due back: 2026-11-23
Enter fullscreen mode Exit fullscreen mode

The due date is right: 9 November plus 14 days is 23 November. The loan date arrived as the string "2026-11-09" and became a real date object, so adding days to it works, and the rest of the code can trust the types.

Now the part the tidy tutorials skip: something goes wrong. A web form sends every value as text, so the same loan arrives with the loan period as the string "-5", typed by a member who meant 5 and hit the wrong key. The model expects a number greater than zero, and Pydantic refuses the whole loan. The real output is:

Loan rejected:
1 validation error for Loan
loan_days
  Input should be greater than 0 [type=greater_than, input_value='-5', input_type=str]
    For further information visit https://errors.pydantic.dev/2.14/v/greater_than
Enter fullscreen mode Exit fullscreen mode

One problem, naming the exact field, the rule it broke, and the value that broke it. Nothing was saved, no reminder was scheduled for a loan that ends before it starts, and the form knows what to tell the member. The fix is to correct the data: the member meant 14 days, so the same payload with "14" in place of "-5" passes at once, and the string is converted to the number 14 before the checks run. Running the corrected payload gave:

Loan accepted for Ayesha Khan
Book ISBN: 9780134685991
Loan days: 14
Due back: 2026-11-23
Enter fullscreen mode Exit fullscreen mode

The lesson: data from a form is text until proven otherwise, and a negative number is a valid integer that happens to be nonsense. A model that checks the range, not just the type, catches both problems in one place, before the database ever sees them.

Common mistakes and how to fix them

1. Creating a model without catching ValidationError. Bad data raises a ValidationError, and an uncaught raise inside a request handler crashes the request. Fix it with try/except around the model call: show the errors when validation fails, carry on when it passes.

2. Leaving a required field out of the payload. A loan with no member_email fails with Field required [type=missing, ...], naming the missing field. The fix is to mark the field optional with a default, or to check the form sends it. Do not invent a placeholder email to satisfy the model.

3. Assuming extra fields are rejected. Pydantic ignores unknown keys by default, so a loan with an extra notes field passes and the notes quietly vanish. If extra data should be an error, set model_config = ConfigDict(extra="forbid") on the model so unknown keys fail instead of disappearing.

4. Trusting a date string that looks right. The date "2026-13-40" fails with Input should be a valid date or datetime, month value is outside expected range of 1-12, because there is no month 13. Let the model reject it and ask for a real date, rather than parsing dates by hand.

Where this leaves you

Pydantic turns a hope into a check you can run: describe the data you expect, and every record gets measured against it before your code trusts it. It costs one small class per form or endpoint, and it pays that back the first time a mistyped email or a negative loan period is caught at the door instead of found in the database later. It will not fix a form that collects the wrong fields to begin with. The next step: pick one form in your own project, write a model for it, and feed it a bad payload. When the error points at the exact field, you will know whether your data needs a gatekeeper.

Further reading

Originally published at https://practicaldevblog.blogspot.com/2026/11/pydantic-catch-bad-data-before-it.html

Top comments (1)

Collapse
 
launchgatecheck profile image
Launch Gate •

Good end-to-end treatment, and "a negative number is a valid integer that happens to be nonsense" is the sentence to keep.

Two additions for the spreadsheet case you mention. First: reject per row, not per file. A 5,000-row import with 6 bad rows should come back with all six row numbers and their field errors in one report, not die on the first one - wrap each row's model build in try/except ValidationError and collect. The gap between "row 412: loan_days must be greater than 0" times six and one opaque failure is the gap between a user fixing their file and a support ticket.

Second: identifiers are strings, not numbers. The ISBN pattern in the model is right; the trap arrives in the next model, where someone declares an article number as int and the coercion you described silently eats the leading zero - "014" becomes 14 and stops matching anything. Anything you compare but never do arithmetic on stays str with a pattern.