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

Sponsored

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 ValidationError listing every problem in a structured errors() 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:

  1. Define a model — subclass BaseModel and declare fields with type hints and optional validators.
  2. Receive raw input — from a JSON request body, a form, a CLI argument, or a config file.
  3. Instantiate the model — pass the data to the constructor. Pydantic immediately validates.
  4. Handle success — if valid, you get a model instance with attributes you can trust.
  5. Handle failure — if invalid, a ValidationError is raised. You catch it and return a 422 (or your own error shape) to the client.
  6. 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 of model_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 bool accepts any non-empty string (e.g., 'false' becomes True) — use StrictBool or strict=True to 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 None case for optional fields — a missing field becomes None, which can later cause AttributeError or SQL NULL issues if you don't handle it explicitly.

Variations

  1. Use model_dump(serialize_as_any=True) (v2) when you need to preserve non-JSON types like Decimal or datetime while serializing.
  2. For API frameworks, you can skip manual try/except and let FastAPI or Django's integration with pydantic automatically return 422 errors.
  3. Replace field_validator with model_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.environ entries 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=True to disable unsafe coercions.
  • Custom field_validators let you enforce patterns like alphanumeric usernames and email formats beyond basic types.
  • Always catch ValidationError and 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.