Input Validation

Validating input as early and strictly as possible — checking type, range, and shape before acting on data — turns silent corruption and crashes into clear, immediate errors.

Input validation is the practice of checking that data meets your program's expectations before it's used, rather than discovering a problem deep in unrelated code once the bad value has already propagated. Good validation checks type, range/format, and overall shape (schema), and does so fail-fast — as close to where the data enters the program as possible.

Why Validate

Unvalidated input causes two kinds of trouble: crashes far from the actual bad value (a TypeError deep in some helper function three calls later, with a confusing stack trace) and, worse, silent corruption (the bad value is used without error but produces a wrong result nobody notices until later). Validating at the boundary converts both failure modes into one clear, immediate, well-located error.

Type Validation

Type validation confirms a value is the kind of object your code expects before operating on it. Because Python is dynamically typed (a name can be rebound to any type; see the related Dynamic Typing topic), a function that expects an int can be called with a str unless it checks. Use isinstance() for runtime checks (never type(x) == int, which rejects legitimate subclasses); use type hints plus a static checker like mypy (third-party) to catch mismatches during development, but remember hints are not enforced at runtime — a rogue caller can still pass the wrong type unless you check.

Checking isinstance() at the top of the function catches a wrong-typed argument at the call site, with a message naming exactly what was expected and what was received.

bool is a subclass of int in Python, so isinstance(True, int) is True. If your validation must reject booleans specifically (e.g. a quantity field), check isinstance(x, bool) explicitly before the broader int check.

def apply_discount(price: float, percent: float) -> float:
    if not isinstance(price, (int, float)):
        raise TypeError(f"price must be a number, got {type(price).__name__}")
    if not isinstance(percent, (int, float)):
        raise TypeError(f"percent must be a number, got {type(percent).__name__}")
    return price * (1 - percent / 100)

apply_discount(100, 10)     # 90.0
apply_discount("100", 10)   # raises TypeError immediately, with a clear message

Range Validation

Range validation confirms a numeric or sized value falls within acceptable bounds — an age isn't negative, a percentage is between 0 and 100, a list isn't empty when the operation requires at least one element. This is best expressed as explicit, named conditions rather than buried arithmetic, so the error message can say exactly which bound was violated.

def set_volume(level: int) -> None:
    if not (0 <= level <= 100):
        raise ValueError(f"volume must be between 0 and 100, got {level}")
    print(f"volume set to {level}")

def process_batch(items: list) -> None:
    if not items:
        raise ValueError("items must not be empty")
    print(f"processing {len(items)} items")

Schema Validation

Schema validation checks the overall shape of structured data — which keys a dict must have, their types, and nested structure — typically after parsing JSON, form data, or a config file. For small, self-contained cases, hand-written checks over a dict are perfectly reasonable. For anything larger or reused across a codebase, the third-party library pydantic (not part of the standard library) is widely used to define a schema as a class and get automatic parsing plus validation; dataclasses (standard library) combined with manual checks in __post_init__ is a lighter, stdlib-only alternative when you don't want the dependency.

The dataclass's __post_init__ runs automatically after construction, so UserRegistration(...) cannot exist in an invalid state — schema shape (missing keys) is checked separately, before construction, with a message naming exactly which fields were missing.

from dataclasses import dataclass

@dataclass
class UserRegistration:
    username: str
    age: int

    def __post_init__(self) -> None:
        if not isinstance(self.username, str) or not self.username.strip():
            raise ValueError("username must be a non-empty string")
        if not isinstance(self.age, int) or not (0 <= self.age <= 150):
            raise ValueError(f"age must be an int in [0, 150], got {self.age!r}")

def parse_registration(data: dict) -> UserRegistration:
    required = {"username", "age"}
    missing = required - data.keys()
    if missing:
        raise ValueError(f"missing required fields: {sorted(missing)}")
    return UserRegistration(username=data["username"], age=data["age"])

parse_registration({"username": "ada", "age": 30})  # OK
# parse_registration({"username": "ada"})            # raises: missing required fields: ['age']

Parsing as Validation

Parsing — converting a raw string into a typed value — is itself a validation step, and often the most natural one: int("abc") raises ValueError on its own, so wrapping a conversion in a try/except with a clear message is frequently simpler than pre-checking with a regex. Prefer letting the conversion function do the checking (int(), float(), datetime.strptime()) over hand-rolled format checks that can drift out of sync with what the conversion actually accepts.

from None suppresses chaining the original ValueError from int(), so the caller sees one clear, custom message instead of two stacked tracebacks.

def parse_port(raw: str) -> int:
    try:
        port = int(raw)
    except ValueError:
        raise ValueError(f"port must be an integer, got {raw!r}") from None
    if not (1 <= port <= 65535):
        raise ValueError(f"port must be between 1 and 65535, got {port}")
    return port

parse_port("8080")   # 8080
# parse_port("abc")   # raises: port must be an integer, got 'abc'
# parse_port("99999") # raises: port must be between 1 and 65535, got 99999

Fail-Fast Behavior

Fail-fast means validating as early as possible — ideally at the very entry point where external data first arrives — and raising immediately rather than letting a bad value travel deeper into the program where the eventual failure (or worse, silent wrong answer) is harder to trace back to its source. The alternative, checking 'defensively' scattered throughout many functions, duplicates logic and still risks a path that forgot to check.

All validation happens before total is computed or anything is persisted, so an invalid order never partially executes.

def create_order(customer_id: int, quantity: int, unit_price: float) -> dict:
    # validate everything up front, before any side effect (e.g. a DB write)
    if not isinstance(customer_id, int) or customer_id <= 0:
        raise ValueError(f"customer_id must be a positive int, got {customer_id!r}")
    if not isinstance(quantity, int) or quantity <= 0:
        raise ValueError(f"quantity must be a positive int, got {quantity!r}")
    if not isinstance(unit_price, (int, float)) or unit_price < 0:
        raise ValueError(f"unit_price must be non-negative, got {unit_price!r}")

    total = quantity * unit_price
    return {"customer_id": customer_id, "quantity": quantity, "total": total}

Practical Usage

Validate at trust boundaries: where a web request, CLI argument, config file, or external API response first enters your program. Once data has passed validation and become a well-typed internal object (a dataclass instance, a validated dict), the rest of your code can generally trust its shape and stop re-checking — that's the payoff of validating once, at the edge, rather than defensively everywhere.

Edge Cases

Watch for boundary values specifically: off-by-one errors in range checks (< vs <=), empty strings/collections that pass a naive truthiness check differently than expected ("" and [] are falsy, but "0" is truthy even though it represents zero), None where a value is expected, and Unicode/whitespace surprises (" " is non-empty but likely not meaningful input — consider .strip() before an emptiness check). Also be explicit about whether NaN and infinity are acceptable for a float field; math.isnan(x) and math.isinf(x) are the correct checks, since float("nan") == float("nan") is False.

Complete Example

from dataclasses import dataclass

@dataclass
class SignupForm:
    email: str
    age: int
    referral_code: str | None = None

    def __post_init__(self) -> None:
        if not isinstance(self.email, str) or "@" not in self.email:
            raise ValueError(f"invalid email: {self.email!r}")
        if not isinstance(self.age, int) or not (13 <= self.age <= 120):
            raise ValueError(f"age must be an int in [13, 120], got {self.age!r}")
        if self.referral_code is not None and not self.referral_code.strip():
            raise ValueError("referral_code, if provided, must not be blank")

def parse_signup(data: dict) -> SignupForm:
    required = {"email", "age"}
    missing = required - data.keys()
    if missing:
        raise ValueError(f"missing required fields: {sorted(missing)}")

    try:
        age = int(data["age"])
    except (TypeError, ValueError):
        raise ValueError(f"age must be an integer, got {data['age']!r}") from None

    return SignupForm(email=data["email"], age=age, referral_code=data.get("referral_code"))

form = parse_signup({"email": "[email protected]", "age": "34"})
print(form)  # SignupForm(email='[email protected]', age=34, referral_c
…

Mental Model

Think of validation as airport security, not a series of random spot-checks throughout the building: everyone is checked once, thoroughly, at a single checkpoint before entering the secure area, and nobody inside re-checks IDs at every hallway. Put the checkpoint at the boundary, make it strict, and let the interior trust what already passed through.

Quick Reference

isinstance(x, T)              - type validation (never type(x) == T)
0 <= x <= 100                 - explicit, named range checks
required - data.keys()        - missing-field / schema checks
int(raw) / float(raw)         - parsing IS validation; catch ValueError
dataclass.__post_init__       - validate once, at construction
raise ... from None           - suppress noisy chained tracebacks
Validate at the boundary, trust the interior

Key Takeaways

Input validation checks type, range, and overall shape before data is used, converting silent corruption or a distant crash into a clear, immediate error.

Use isinstance() for runtime type checks; type hints alone are not enforced at runtime.

Express range checks as explicit, named conditions so error messages can say exactly which bound failed.

Parsing (int(), float(), datetime.strptime()) doubles as validation — let the conversion raise, then wrap it with a clearer message.

Fail fast: validate everything at the entry point, before any side effect, so invalid data never partially executes.

Validate once at the trust boundary; code past that point can trust the data's shape instead of re-checking defensively.

def set_age(age): person.age = age # no check at all -- accepts anything, including -5 or "abc" def set_age(age: int) -> None: if not isinstance(age, int) or isinstance(age, bool): raise TypeError(f"age must be an int, got {type(age).__name__}") if not (0 <= age <= 150): raise ValueError(f"age must be between 0 and 150, got {age}") person.age = age

Without validation, a wrong-typed or out-of-range value is stored silently and only causes trouble later, far from where the bad value actually entered — and a type hint alone (age: int) is not enforced at runtime.

if type(count) == int: process(count) if isinstance(count, int) and not isinstance(count, bool): process(count)

type(x) == int rejects legitimate int subclasses and is generally considered un-Pythonic; isinstance() is the idiomatic check. The extra bool exclusion matters because bool is a subclass of int, so isinstance(True, int) is True.

data = json.loads(request_body) name = data["name"] # KeyError deep in unrelated code if "name" is missing data = json.loads(request_body) if "name" not in data: raise ValueError("missing required field: 'name'") name = data["name"]

Letting a missing key surface as an uncaught KeyError wherever it happens to be first accessed produces a confusing error far from the actual problem (malformed input). Checking for required fields immediately after parsing fails fast with a clear message.

Why is isinstance(x, int) preferred over type(x) == int for type validation?

isinstance respects subclassing, so a legitimate subclass of int still passes; type(x) == int rejects it outright, which is both un-Pythonic and can break valid polymorphic use. isinstance is also the standard idiom recommended throughout the Python ecosystem.

Why does bool need special handling when validating that a value is an int?

bool is implemented as a subclass of int in Python, so isinstance(True, int) evaluates to True. If a field must specifically exclude booleans (e.g. a quantity that shouldn't silently accept True/False), you need an explicit isinstance(x, bool) check in addition to the int check.

In what sense is calling int(raw_string) itself a form of input validation?

The conversion either succeeds and produces a genuinely well-typed integer, or raises ValueError on its own for anything that isn't a valid integer literal — so the parsing step does double duty as validation, and wrapping it in try/except with a clearer message is often simpler and more accurate than a hand-rolled pre-check regex.

What does 'fail-fast' mean in the context of input validation, and why is it preferable to scattered defensive checks throughout the codebase?

Fail-fast means validating as early as possible — right where external data enters — and raising immediately rather than letting a bad value propagate deeper. It's preferable because it produces one clear error at a known location, avoids duplicating the same checks across many functions, and prevents partial side effects (like a half-completed database write) from a value that turns out to be invalid.

Why might if not items: behave unexpectedly if items could also legitimately be None versus an empty list, in code that needs to distinguish the two?

Both None and [] are falsy, so if not items: treats 'no data was provided' and 'data was provided but happens to be empty' identically. If those two cases need different handling (e.g. 'field omitted' vs 'field explicitly empty'), the check must be if items is None: separately from if not items:.

Why is float("nan") == float("nan") not a reliable way to check for NaN, and what should be used instead?

By IEEE 754 floating-point semantics, NaN is defined to compare unequal to everything, including itself, so nan == nan is False — an equality check can never detect it. math.isnan(x) is the correct, explicit check for this case.

A schema-validated dataclass raises in __post_init__ if its fields are invalid. What guarantee does this give the rest of the codebase once an instance exists?

Because __post_init__ runs automatically right after construction and raises before the object is fully usable, any successfully constructed instance is guaranteed to satisfy its validation rules — code elsewhere can treat the mere existence of the object as proof it's valid, without re-checking.

Write a function parse_positive_int(raw: str, field_name: str) -> int that parses a string as an integer and validates it is strictly positive, raising ValueError with a message naming the field on either failure.

def parse_positive_int(raw: str, field_name: str) -> int: try: value = int(raw) except ValueError: raise ValueError(f"{field_name} must be an integer, got {raw!r}") from None if value <= 0: raise ValueError(f"{field_name} must be positive, got {value}") return value parse_positive_int("5", "quantity") # 5 # parse_positive_int("abc", "quantity") # raises: quantity must be an integer, got 'abc' # parse_positive_int("-3", "quantity") # raises: quantity must be positive, got -3

The function chains two validation stages: parsing (catching int()'s own ValueError and re-raising with a field-specific message) and then a range check on the resulting value. Using from None keeps the traceback focused on the custom message rather than stacking the original conversion error underneath it.

What does this print, and why might using truthiness (if value:) instead of an explicit type/None check be a validation bug here?

It prints 0, 0, 0, 10. The bug: process(0) and process("") both hit the falsy branch and return 0 — which happens to be correct for 0 by coincidence but is wrong for "" (a string, arguably an invalid type for this function) and would also silently 'succeed' for None, masking what is likely a missing-argument bug rather than a legitimate zero-like input. Explicit validation (if value is None: raise ... then if not isinstance(value, (int, float)): raise ...) would distinguish these cases instead of collapsing them all into the same fallback.

Write a function validate_schema(data: dict, required_fields: set[str]) -> None that raises ValueError listing (sorted) any missing required fields, and does nothing if all are present.

def validate_schema(data: dict, required_fields: set[str]) -> None: missing = required_fields - data.keys() if missing: raise ValueError(f"missing required fields: {sorted(missing)}") validate_schema({"name": "ada", "age": 30}, {"name", "age"}) # OK, no error # validate_schema({"name": "ada"}, {"name", "age"}) # raises: missing required fields: ['age']

Set difference (required_fields - data.keys()) is a concise way to compute exactly which required keys are absent from the data; sorting the result gives a deterministic, readable error message regardless of set iteration order.

def process(value):
    if value:
        return value * 2
    return 0

print(process(0))
print(process(""))
print(process(None))
print(process(5))