← Python Code Modern OOP
Browse Python Concepts

Pydantic v2 — Defining and Validating Data Models

Mental Model

Think of Pydantic's validation as a bouncer at the entrance of a club. It checks everyone coming in (during __init__), but once inside, there's no continuous monitoring unless you specifically hire another bouncer to watch for rule-breaking behavior on the dance floor.

Rule: Always set validate_assignment=True in your Pydantic model config if your application logic mutates model fields post-instantiation.

The Setup

You are building an e-commerce checkout service. You leverage a Pydantic model to guarantee that items have positive integer values for quantities to prevent negative balances. An internal function updates the shopping cart item dynamic count based on user inputs.

What Does This Print?

Broken code
Python
from pydantic import BaseModel, Field

class CartItem(BaseModel):
    item_id: int
    quantity: int = Field(gt=0)

item = CartItem(item_id=101, quantity=1)
# Modifying the attribute during a checkout adjustment flow
item.quantity = -5
print(item)
What does the code print? Does Pydantic raise a ValidationError when the quantity is updated to a negative number, or does it accept the change?

The Output

What actually happens
item_id=101 quantity=-5

The code executes without errors and prints a configuration containing a negative quantity! Even though quantity was defined with a strict validation constraint of gt=0 (greater than zero), Pydantic's default behavior only validates data at the instantiation boundary (during __init__). Direct mutations to attributes are unchecked, introducing potential state corruption.

Why Python Does This

Pydantic's core validation logic is written in Rust (via pydantic-core) to maximize speed. During instantiation, the engine parses parameters through specialized deserializers. However, to keep standard attribute lookup and assignment as fast as pure Python classes, Pydantic's default __setattr__ wrapper behaves like standard object assignment, bypassing the validation pipeline. Enabling validation on every assignment requires explicit opt-in config so that __setattr__ routes the new value through the Rust validation layer, which carries a performance overhead.

The Fix

Corrected pattern
Python
from pydantic import BaseModel, Field, ConfigDict

class CartItem(BaseModel):
    # Enable validate_assignment to protect against dynamic mutations
    model_config = ConfigDict(validate_assignment=True)
    
    item_id: int
    quantity: int = Field(gt=0)

try:
    item = CartItem(item_id=101, quantity=1)
    item.quantity = -5
except Exception as e:
    # This block successfully captures a ValidationError
    print(f"Validation intercepted: {e}")

Setting validate_assignment=True in model_config = ConfigDict(validate_assignment=True) makes Pydantic override __setattr__ to route every field assignment through the Rust validation layer. This re-triggers the constraint checks (gt=0) on every write, so any attempt to set an invalid value raises a ValidationError immediately.

How This Fails in Real Systems

An inventory allocation engine mutated parsed models inside an asynchronous consumer loop. A bug set transaction prices to negative values during currency conversions. Because validation did not run on assignment, raw negative balances passed directly into database writes, resulting in $12,000 of ledger discrepancy before it was detected by end-of-day accounting scripts.

Key Takeaway

Always set validate_assignment=True in your Pydantic model config if your application logic mutates model fields post-instantiation.
Common mistake: Developers expect Pydantic's validation rules to apply automatically to attribute assignments after initial model instantiation, assuming continuous enforcement of schema constraints.