Pydantic Input Validation
Validate user input with pydantic in this Secure development tutorial — learn to enforce data integrity and prevent malformed input in Python apps.
Focus: validate user input with pydantic
You've just built a REST API endpoint that accepts a JSON payload from the internet. It works beautifully in your local tests — but what happens when a real user sends {"age": "not a number"}, a missing field, or an extra key that your database column doesn't have? Without validation, that malformed input can crash your application, corrupt your data, or become a vector for injection attacks. This lesson shows you how to validate user input with pydantic, a Python library that turns unstructured, untrusted data into strongly typed, verified models — making your application both more robust and more secure.
The problem this lesson solves
Unvalidated input is the root cause of countless security vulnerabilities: SQL injection, NoSQL injection, command injection, path traversal, and denial-of-service through massive payloads. Even if you escape every string, you still face data integrity issues — a float where an integer belongs, an email address that isn't one, a date that's actually a potato. Manually writing if statements to check every field is tedious, error-prone, and impossible to maintain as your schema grows.
Pydantic solves this by declaring schemas — Python classes that define what your data should look like. When data arrives, Pydantic validates it against the schema and gives you either a clean, typed object or a detailed error. You stop trusting input and start proving it's safe.
Pro tip: Validation is a security control, not just a convenience. Treat every HTTP request, file upload, and CLI argument as hostile until proven otherwise.
Core concept / mental model
Think of pydantic as a bouncer at a club. Your code is the VIP lounge — only properly dressed (validated) guests get in. The bouncer checks every ID, rejects fakes, and knows exactly what to let through. In pydantic terms:
- Model — the class that defines the allowed structure (the club's dress code).
- Field — each attribute of the model, with its own type and constraints.
- Validation — the automatic check that happens when you instantiate a model with data.
- Error — a
ValidationErrorlisting every problem in a structurederrors()format.
Pydantic uses Python's type hints (int, str, EmailStr, etc.) as the validation rules. Best of all, it performs coercion — converting input to the declared type when safe. For example, the string "42" becomes the integer 42 — as long as the conversion is unambiguous. This is a double-edged sword: convenient for APIs, dangerous if you aren't precise.
How it works step by step
Here's the flow of a pydantic validation, from raw input to trusted object:
- Define a model — subclass
BaseModeland declare fields with type hints and optional validators. - Receive raw input — from a JSON request body, a form, a CLI argument, or a config file.
- Instantiate the model — pass the data to the constructor. Pydantic immediately validates.
- Handle success — if valid, you get a model instance with attributes you can trust.
- Handle failure — if invalid, a
ValidationErroris raised. You catch it and return a 422 (or your own error shape) to the client. - Use the validated data — no more type checks, no more
if 'key' in data— the model guarantees it.
Pydantic also supports optional fields, defaults, custom validators, and nested models for complex structures. You can even use model_dump() to convert back to a dict — perfect for sending to a database.
Hands-on walkthrough
Let's start with the basics. First, install pydantic:
pip install pydantic
Now, define a simple model for a user registration form:
from pydantic import BaseModel, ValidationError, field_validator
class UserRegistration(BaseModel):
username: str
email: str
age: int
# Valid input
user = UserRegistration(username="alice", email="alice@example.com", age=30)
print(user.username, user.email, user.age)
# alice alice@example.com 30
# Invalid input — age is a string, but coercible
user2 = UserRegistration(username="bob", email="bob@example.com", age="25")
print(user2.age, type(user2.age))
# 25 <class 'int'>
# Invalid input — age cannot be parsed
from pydantic import ValidationError
try:
UserRegistration(username="carol", email="carol@example.com", age="old")
except ValidationError as e:
print(e)
Expected output:
alice alice@example.com 30
25 <class 'int'>
1 validation error for UserRegistration
age
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='old', input_type=str]
Notice how pydantic coerces "25" to an integer — that's powerful but also a security consideration. If you want to reject any string for age, you can use strict=True on the field.
Now, let's add custom validation for an email format and a username pattern:
from pydantic import BaseModel, field_validator, EmailStr
class UserRegistration(BaseModel):
username: str
email: EmailStr # requires `email-validator` package
age: int
@field_validator('username')
@classmethod
def username_must_be_valid(cls, v):
if not v.isalnum():
raise ValueError('username must be alphanumeric')
if len(v) < 3:
raise ValueError('username must be at least 3 characters')
return v
try:
UserRegistration(username="ab", email="not-an-email", age=30)
except ValidationError as e:
print(e.errors())
Expected output:
[
{'type': 'email_parsing', 'loc': ('email',), 'msg': 'value is not a valid email address', ...},
{'type': 'value_error', 'loc': ('username',), 'msg': 'Value error, username must be at least 3 characters', ...}
]
For a real API, you'd return a 422 Unprocessable Entity with a structured error body. Here's a FastAPI-style example (though the concept applies anywhere):
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ValidationError
app = FastAPI()
class Order(BaseModel):
item_id: int
quantity: int = 1
promo_code: str | None = None
@app.post("/orders")
def create_order(payload: dict):
try:
order = Order.model_validate(payload)
except ValidationError as e:
raise HTTPException(status_code=422, detail=e.errors())
# Now `order` is validated — safe to use
return {"ok": True, "order": order.model_dump()}
Compare options / when to choose what
Pydantic isn't the only validation tool in Python. Here's how it stacks up:
| Option | Type safety | Performance | Ease of use | Best for |
|---|---|---|---|---|
| Pydantic v2 | Excellent (Rust core) | Very fast | High | APIs, configs, data-heavy apps |
| Marshmallow | Good, but more manual | Slower | Medium | ORM integration, serialization |
| Cerberus | Basic | Medium | Simple | Small scripts, schema validation |
Built-in dataclasses |
No validation without manual checks | Fast | Low | Internal data with no external input |
Manual if checks |
None | Fast | Low | Tiny scripts with one or two inputs |
Choose pydantic when your data crosses a trust boundary — APIs, CLI inputs, config files that may be edited by users. Its integration with FastAPI is seamless, but it works standalone too. If you're stuck on Python <3.10, use pydantic v1 (the structure is similar but some methods differ). For high-throughput data pipelines where performance dominates, pydantic v2's Rust core wins.
Pro tip: Use
model_dump()instead ofmodel_dump_json()when you need a dict — the latter serializes to a JSON string, which is slower and can produce subtle type differences.
Troubleshooting & edge cases
1. Pydantic silently coerces types you didn't intend. For example, bool accepts the string "false" as True because bool("false") is True. Fix: use StrictBool from pydantic.types or set strict=True.
2. Union types can behave unexpectedly. If you define field: int | str, Python's str will swallow everything and int will never be checked. Use Union with a constrained type or validate at a higher level.
3. Custom validators must be classmethods. Forget the @classmethod decorator and you'll get a TypeError at runtime.
4. Handling missing fields. By default, missing fields raise an error. If a field should be optional, use field: int | None = None. But be careful — None can silently slip through if you later do arithmetic.
5. Performance with large payloads. Pydantic v2 is fast, but nested models with deep validators can still cost. Profile if you see latency. Use model_validate (not parse_obj) in v2.
6. Error messages leak internals. Pydantic's default errors include the input value, which can leak sensitive data in logs. Sanitize before logging.
What you learned & what's next
You can now validate user input with pydantic — from a simple model to custom validators and nested schemas. You understand how to turn untrusted JSON into a typed, trusted object and how to fail gracefully with structured errors. This is a cornerstone of secure development: validators are your first line of defense against malformed data, injection, and data corruption.
You also saw how to compare pydantic with other tools and choose the right one for your context, and you can troubleshoot the common edge cases that trip up beginners.
Next up in the Secure development track: you'll apply these validation principles to SQL injection prevention — using pydantic to sanitize inputs before they reach your database queries. You'll see how clean validation makes parameterized queries even safer, and how a layered defense keeps your data perimeter secure.
Practice recap
Try extending the Order model to include a created_at datetime field with a default, and add a model_validator to ensure quantity > 0. Then, write a small script that reads a JSON file, validates it, and prints a formatted report of any errors — this simulates an external data feed you might encounter in production.
Common mistakes
- Relying on pydantic's automatic type coercion without realizing
boolaccepts any non-empty string (e.g.,'false'becomesTrue) — useStrictBoolorstrict=Trueto prevent this. - Thinking pydantic validates at runtime only — it also validates at model definition time for class-level validators, so a bug there can break your whole app before any input arrives.
- Ignoring the
Nonecase for optional fields — a missing field becomesNone, which can later causeAttributeErroror SQLNULLissues if you don't handle it explicitly.
Variations
- Use
model_dump(serialize_as_any=True)(v2) when you need to preserve non-JSON types likeDecimalordatetimewhile serializing. - For API frameworks, you can skip manual try/except and let FastAPI or Django's integration with pydantic automatically return 422 errors.
- Replace
field_validatorwithmodel_validator(v2) when you need to validate relationships between fields (e.g.,end_date>start_date).
Real-world use cases
- REST API endpoints that accept JSON payloads — ensure required fields, types, and constraints are enforced before touching the database.
- CLI tools that parse environment variables or config files — validate
os.environentries with pydantic to fail fast on typos. - Data ingestion pipelines that parse CSV or JSON files from external sources — guarantee schema compliance to prevent downstream crashes.
Key takeaways
- Pydantic turns untrusted input into typed, validated objects — your first line of defense against malformed data and injection.
- Every field declaration is a security and data-integrity rule; use
strict=Trueto disable unsafe coercions. - Custom
field_validatorslet you enforce patterns like alphanumeric usernames and email formats beyond basic types. - Always catch
ValidationErrorand return a structured error response — never let raw validation errors leak to the client. - Pydantic integrates natively with FastAPI, but works standalone for any Python project.
- Layer validation with other defenses (parameterized SQL, escaping) — validation is necessary but not sufficient.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.