Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 11 – Type hints and static type checking

Validating external data at run time

Validate JSON, forms and files at the edge of a Python program with explicit checks, dataclasses and __post_init__, and see when pydantic is worth it.

  • Advanced
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: Annotations do nothing with bad data · One validator at the edge reports every problem · Every way of writing the checks ends in a typed object · Check yourself. With a pass: 2 in-depth parts and 3 interview answers, about 12 minutes.

What you will learn

  • Explain why annotations, TypedDicts and dataclasses check nothing while a program runs
  • Validate untrusted input with explicit checks that report every problem at once
  • Validate dataclasses in __post_init__ and keep the result immutable
  • Reproduce the Python-specific inputs that slip past validators, and refuse each one
  • Decide when a library such as pydantic is worth adding, with measured numbers

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain why annotations, TypedDicts and dataclasses check nothing while a program runs
  • Validate untrusted input with explicit checks that report every problem at once
  • Validate dataclasses in __post_init__ and keep the result immutable
  • Reproduce the Python-specific inputs that slip past validators, and refuse each one
  • Decide when a library such as pydantic is worth adding, with measured numbers

Before you start

On this page

A dataclass that declares username: str and age: int accepted a list and the string "twenty" in this lesson’s first run, and the mistake surfaced only later, as a TypeError in code that had nothing to do with the input. Type hints describe data, but nothing checks them while the program runs.

A JSON body from a client, a form field, a command-line argument, a row of a file or an environment variable is whatever the sender sent, so a program must validate it where it enters. Good validation checks every field against what is allowed, reports every problem at once so the sender can fix them all, and returns a clean, typed object, usually a frozen dataclass: A dataclass declared with frozen=True. Assigning to a field after the object is created raises FrozenInstanceError, and with the default eq=True the decorator also writes a __hash__ from the fields, so the objects can be dict keys. More in “Dataclasses for clean data objects”. You can write the checks by hand, put them in a dataclass’s __post_init__, or let a library such as pydantic generate them from type hints. The runs below compare the three, on CPython 3.14.8 and with pydantic 2.14.1, including what each one costs.

Annotations do nothing with bad data

Step through the program: a dataclass built from bad JSON, and the line where the bad age finally causes trouble.

Interactive · line by line A dataclass built from bad JSON Step through the program and watch what signup holds after line 14, then where the bad age finally causes an error.

Program · hints/not_checked.py

"""What annotations do with bad data while the program runs: nothing."""
import json
from dataclasses import dataclass


@dataclass(frozen=True)
class Signup:
    username: str
    age: int


body = '{"username": ["asha"], "age": "twenty"}'
data = json.loads(body)
signup = Signup(**data)
print(signup)
print(type(signup.username).__name__, type(signup.age).__name__)
try:
    print("next year:", signup.age + 1)
except TypeError as error:
    print("TypeError, far from the input:", error)

Variables

After line 20: dataclass is dataclass(cls=None, /, *, init=True, repr=True, eq=True, or…; Signup is Signup; body is '{"username": ["asha"], "age": "twenty"}'; data is {'username': ['asha'], 'age': 'twenty'}; signup is Signup(username=['asha'], age='twenty').dataclassdataclass(cls=None, /, *, init=True, repr=True, eq=True, or…SignupSignupbody'{"username": ["asha"], "age": "twenty"}'data'username':['asha']'age':'twenty'signupSignup(username=['asha'], age='twenty')
  • changed in this step

Printed so far

Signup(username=['asha'], age='twenty')
list str
TypeError, far from the input: can only concatenate str (not "int") to str

Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.

The drawing shows the variables when the program ends, and the program's 17 steps are listed below.

Line run 20
Next line end
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. """What annotations do with bad data while the program runs: nothing.""" runs.
  3. Line 2. import json loads json.
  4. Line 3. from dataclasses import dataclass loads dataclass.
  5. Line 6. Line 6 runs.
  6. Line 7. class Signup: makes the class Signup. Its body runs only when it is used.
  7. Line 6. Line 6 runs.
  8. Line 7. class Signup: makes the class Signup. Its body runs only when it is used.
  9. Line 12. body = '{"username": ["asha"], "age": "twenty"}' sets body to '{"username": ["asha"], "age": "twenty"}'.
  10. Line 13. data = json.loads(body) sets data to {'username': ['asha'], 'age': 'twenty'}.
  11. Line 14. signup = Signup(**data) sets signup to Signup(username=['asha'], age='twenty'). The bug The dataclass stored a list as username and the string 'twenty' as age. Its __init__ was written from the annotations, but it never compares a value with them.
  12. Line 15. print(signup) prints Signup(username=['asha'], age='twenty').
  13. Line 16. print(type(signup.username).__name__, type(signup.age).__name__) prints list str. The types printed here are list and str, although the class declares str and int. Nothing has complained yet.
  14. Line 17. try: runs.
  15. Line 18. print("next year:", signup.age + 1) prints nothing. Key point The mistake surfaces only now, four lines after the data arrived, as a TypeError about adding an int to a str.
  16. Line 19. Line 19 runs.
  17. Line 20. print("TypeError, far from the input:", error) prints TypeError, far from the input: can only concatenate str (not "int") to str. error no longer exists. That was the last line, so the program ends. Nothing checked the data where it entered, so the error names neither the field nor the sender. Validation moves that failure to the edge, with a message the sender can act on.

The dataclass declares a str and an int, and Python built it with a list and a string without a word. A dataclass uses its annotations to write __init__, and a TypedDict: A class from typing that gives each key of a dictionary its own type, such as id: str and amount: int, for JSON-like records. The value is still a plain dict at run time, and Python checks none of its keys. More in “TypedDict, Literal, NewType and enums” uses them to describe keys, but neither compares a value with its annotation. The mistake surfaced only when the age was used, as a TypeError about concatenating text, in code that had nothing to do with the input. That distance between the cause and the crash is what validation removes: it moves the failure to the one place where the data arrives, and gives it a message the sender can act on.

The edge of a program, its trust boundary: The line between data your program made and protects and data that someone else could have written or changed, such as an upload, a shared cache or a folder others can write to. Data that crosses it is read with care, in a format that cannot run code. More in “pickle, shelve and safe serialisation”, is wherever data crosses over from something you do not control: an HTTP handler, a command-line parser, the function that reads a CSV file, the code that loads configuration or a message from a queue. Each of these needs its own validator, and code behind them should take the validated objects as parameters, never the raw dictionaries. A second check deep inside the program is usually a sign that some path skips the edge.

Checkpoint

Check: What annotations do with bad data

  1. Question 1 of 2 What did Signup(**data) do with ["asha"] as the username and "twenty" as the age?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It stored both values as they were

    A dataclass writes __init__ from its annotations, but that __init__ stores whatever it is given. The object was built with a list and a string, and nothing complained.

  2. Question 2 of 2 Where did the bad age finally cause an error in not_checked.py?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: At signup.age + 1, as a TypeError about adding an int to a str

    Without validation the failure happens wherever the bad value is first used, far from the input and with a message that names neither the field nor the sender.

One validator at the edge reports every problem

parse_signup(): every field checked, every problem reported Python · validator/signup.py
"""A validator at the edge: check every field, report every problem at once, return a typed, frozen object."""
import json
from dataclasses import dataclass


class SignupError(ValueError):
    def __init__(self, problems: list[str]) -> None:
        super().__init__("; ".join(problems))
        self.problems = problems


@dataclass(frozen=True)
class Signup:
    username: str
    email: str
    age: int


def parse_signup(data: object) -> Signup:
    if not isinstance(data, dict):
        raise SignupError(["the body must be a JSON object"])
    problems = []
    username = data.get("username")
    if not isinstance(username, str) or not 3 <= len(username) <= 20 or not username.isascii() or not username.replace("_", "").isalnum():
        problems.append("username: 3 to 20 letters, digits or _")
    email = data.get("email")
    if not isinstance(email, str) or len(email) > 254 or email.count("@") != 1 or "." not in email.split("@")[1]:
        problems.append("email: must look like name@example.com")
    age = data.get("age")
    if type(age) is not int or not 13 <= age <= 120:
        problems.append("age: a whole number from 13 to 120")
    if problems:
        raise SignupError(problems)
    return Signup(username, email.lower(), age)


print(parse_signup(json.loads('{"username": "asha_r", "email": "Asha@Example.com", "age": 21}')))
for body in ('{"username": "a!", "email": "asha", "age": "21"}', '[1, 2]'):
    try:
        parse_signup(json.loads(body))
    except SignupError as error:
        print("refused:", error.problems)

Output

Signup(username='asha_r', email='asha@example.com', age=21)
refused: ['username: 3 to 20 letters, digits or _', 'email: must look like name@example.com', 'age: a whole number from 13 to 120']
refused: ['the body must be a JSON object']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 signup.py

The function follows four rules. It tests the shape first: a JSON array is not an object, and checking keys on it would fail. It checks each field against what is allowed, an allowlist: A list of what is accepted, such as the URL schemes http and https; anything not on it is refused. Unlike a denylist, it also refuses values nobody thought of. More in “XML and HTML parsing without security holes” of characters for the username, a length limit before any other test, and exact types: type(age) is not int refuses the string "21". It collects the problems in a list and raises one error with all of them, three for the second body. And it returns a frozen Signup with normalised values, the email in lower case, so that nothing downstream has to repeat the work. Follow the second body through the checks:

Interactive · stage by stage The second body through parse_signup() Step through the checks of parse_signup() for the body {"username": "a!", "email": "asha", "age": "21"}, then for the body [1, 2].
The second body through parse_signup(), step 7 of 7. Showing a JSON object? ([1, 2]: refused here), username: "a!" (problem 1: the username), email: "asha" (problem 2: the email), age: "21" (problem 3: the age), raise SignupError, return Signup(...). In focus: a JSON object?.a JSON object?[1, 2]: refused hereusername: "a!"problem 1: the usernameemail: "asha"problem 2: the emailage: "21"problem 3: the ageraise SignupErrorevery problem in one errorreturn Signup(...)only when nothing failed

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The shape first. The body is a dict, so keys can be read. Testing the shape first means the field checks never run on something that has no keys.
  2. The username. "a!" has a character outside the allowlist of letters, digits and underscores, and it is shorter than 3. The problem goes into the list, and the checks go on.
  3. The email. "asha" has no @ at all. That is the second problem, collected in the same list.
  4. The age. "21" is a str. The exact test type(age) is not int refuses it, although it looks like a number: the validator converts nothing.
  5. One error, three problems. The list holds three problems, so parse_signup() raises one SignupError with all of them, and the run printed the three in field order.
  6. The way out that was not taken. Only a body with no problems reaches the last line, which returns a frozen Signup with the email in lower case, as the first body of the run did.
  7. A body that is not an object. The third body, [1, 2], is a list. The shape check refuses it at once with one problem, "the body must be a JSON object", and no field is read.

Checking against what is allowed, rather than hunting for what is dangerous, is the core idea. A rule such as “3 to 20 letters, digits or underscores” accepts a known set and refuses everything else, including inputs nobody thought of. A rule such as “no quotes or angle brackets” fails on the first trick it was not written for, and it refuses honest users along the way.

Outside data passes one validator at the edge; it is refused with every problem, or becomes a typed object.Data from outsideJSON bodies · form fields · command-line argumentsfile rows · environment variablesThe edge: parse_signup(data)checks every field against what is allowedcollects every problemRefusedone error listing every problemsent back to the senderA typed, frozen Signuponly values that passedThe rest of the programtrusts the types, checks nothing againthe type checker follows the valuesany problemall fields valid

Validate once, at the edge of the program

Text description of the diagram

The diagram is a flow from top to bottom.

  • It starts with data from outside the program: JSON bodies, form fields, command-line arguments, rows of files and environment variables.
  • All of it goes through one validator at the edge, parse_signup(data), which checks every field against what is allowed and collects every problem.
  • If there is any problem, the data is refused with one error that lists every problem, and that error goes back to the sender.
  • If every field is valid, the validator returns a typed, frozen Signup object that holds only values that passed.
  • The rest of the program receives only that object: it trusts the types, checks nothing again, and the type checker follows the values from there.

Key idea

Validate once, where the data enters: check every field against what is allowed, report every problem together, and hand the rest of the program a typed, frozen object.

Checkpoint

Check: One validator at the edge

  1. Question 1 of 2 Why does parse_signup() test isinstance(data, dict) before any field?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A JSON array or number is not an object, and reading keys from it would fail

    json.loads() gives a list, a number, a string or a dict, depending on the text. The shape test first makes sure the field checks run on something that has fields.

  2. Question 2 of 2 parse_signup() receives {"username": "ravi_k", "email": "ravi@example.com", "age": "21"}. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It raises SignupError with one problem, the age, because "21" is a str

    The checks run field by field and collect what fails. Here only the exact-type test of the age fails, so the error lists one problem.

Every way of writing the checks ends in a typed object

The three ways, side by side:

Approach What it gives you Best fit
A hand-written function No dependency; the rules and the error report are whatever you write A few fields at one edge
A dataclass with __post_init__ No dependency; every object is checked however it is built Domain objects that must always be valid
A pydantic model Rules taken from type hints and Field() limits, a report with a code for every problem, and conversions in lax mode Many models, nested data and JSON APIs

Each of the three ends with the same thing: a typed object that the rest of the program trusts. A hand-written function costs nothing but code; a dataclass keeps the rules next to the fields, so every object is checked however it is built; and pydantic reads the rules from the annotations themselves, which pays off once there are many models. Whichever you choose, write tests with bad input for every rule, because a validator that was only ever run on good data has not been tested at all.

Checkpoint

Check: Three ways to write the checks

  1. Question 1 of 2 An Account must never exist in an invalid state, however the code creates it. Which way fits best?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A frozen dataclass whose __post_init__ checks the fields

    The table's middle row: a dataclass with __post_init__ keeps the rules next to the fields, so every object is checked however it is built.

  2. Question 2 of 2 A JSON API receives 25 nested models, and its clients show a report of every problem per field. Which way fits best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: pydantic models

    Many models, nested data and JSON APIs are the table's third row. pydantic pays off once there are many models, while a few fields at one edge need nothing more than a function.

Check yourself

The session asks first about the idea you have shown least, and every answer comes with its reason.

Practice that adapts to you

Practise this lesson

9 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 9 A dataclass declares age: int. What happens at run time when it is built with age="twenty"?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: The object is created with the string, and nothing complains

    A dataclass uses the annotations to write __init__, but it never compares a value with its annotation. The recorded run built a Signup with a list as the username and a string as the age; the error came later, far from the input, when the age was used.

  2. Question 2 of 9 Which of these check the type of a value while the program runs?

    Choose every answer that is right.

    Show the answer: Question 2 of 9

    Answer:

    • A pydantic model built with model_validate()
    • isinstance(value, int) in a validator

    Only code that runs can check: an explicit test, or a library such as pydantic that reads the annotations and checks each value. Annotations and TypedDicts are read by type checkers before the program runs.

  3. Question 3 of 9 def handle(order: Order) takes an Order TypedDict with amount: int, and a client sends {"amount": "lots"}. What does Python do when handle() runs?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: Nothing special, until some code uses amount as a number

    A type checker can follow an Order through the program, but only if something has made sure that it really is one. That something is a validator at the edge.

  4. Question 4 of 9 Why does parse_signup() collect problems in a list instead of raising at the first one?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: So that the sender can fix every field after one request

    A form or an API client that learns about one problem per request needs as many round trips as there are mistakes. One error with every problem, as the recorded run printed, lets the sender correct everything at once.

  5. Question 5 of 9 What does signup.py print?

    What does this program print? Choose one answer.

    """A validator at the edge: check every field, report every problem at once, return a typed, frozen object."""
    import json
    from dataclasses import dataclass
    
    
    class SignupError(ValueError):
        def __init__(self, problems: list[str]) -> None:
            super().__init__("; ".join(problems))
            self.problems = problems
    
    
    @dataclass(frozen=True)
    class Signup:
        username: str
        email: str
        age: int
    
    
    def parse_signup(data: object) -> Signup:
        if not isinstance(data, dict):
            raise SignupError(["the body must be a JSON object"])
        problems = []
        username = data.get("username")
        if not isinstance(username, str) or not 3 <= len(username) <= 20 or not username.isascii() or not username.replace("_", "").isalnum():
            problems.append("username: 3 to 20 letters, digits or _")
        email = data.get("email")
        if not isinstance(email, str) or len(email) > 254 or email.count("@") != 1 or "." not in email.split("@")[1]:
            problems.append("email: must look like name@example.com")
        age = data.get("age")
        if type(age) is not int or not 13 <= age <= 120:
            problems.append("age: a whole number from 13 to 120")
        if problems:
            raise SignupError(problems)
        return Signup(username, email.lower(), age)
    
    
    print(parse_signup(json.loads('{"username": "asha_r", "email": "Asha@Example.com", "age": 21}')))
    for body in ('{"username": "a!", "email": "asha", "age": "21"}', '[1, 2]'):
        try:
            parse_signup(json.loads(body))
        except SignupError as error:
            print("refused:", error.problems)
    Show the answer: Question 5 of 9

    Answer: it prints

    Signup(username='asha_r', email='asha@example.com', age=21)
    refused: ['username: 3 to 20 letters, digits or _', 'email: must look like name@example.com', 'age: a whole number from 13 to 120']
    refused: ['the body must be a JSON object']

    The valid body comes back as a Signup with the email in lower case. The second body breaks three rules, and the age "21" counts as a problem because the validator accepts only an int. A JSON list is not an object at all.

  6. Question 6 of 9 Where should a program validate data that comes from outside?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: Once, where it enters, before any other code uses it

    Validating at the edge turns untrusted data into typed objects once. After that, the type checker follows the objects through the program, and no function needs to check them again.

  7. Question 7 of 9 A verification code field must hold exactly 6 digits. Which rule is an allowlist?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: Accept only a string of exactly 6 ASCII digits, and refuse everything else

    An allowlist states the known good set, here six characters from 0 to 9, and refuses the rest. A denylist must foresee every bad input, and never does.

  8. Question 8 of 9 A command-line script reads three fields from one JSON file. Which way does the table suggest?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: A hand-written function

    All three ways end in a typed object. For a few fields at one edge, the simplest one, a function you write, is enough.

  9. Question 9 of 9 A validator has been run only on the sample data it was written for. What does the section recommend before trusting it?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: Tests with bad input for every rule

    For every rule, at least one input that must fail. The tests document the rules and catch the day someone relaxes a check.

Validators that hold up: __post_init__, the Python surprises and allowlists, with failure drills Pro: opens with a Pro, Premium or Ultimate pass

A validator is only as good as the inputs it was tested with. This part puts the checks inside a frozen dataclass, then runs a drill of inputs that Python accepts more willingly than most validators expect, and fixes each one.

In this part:

  • Post-init validation in a frozen dataclass
  • The validation drill: inputs Python accepts too willingly
  • Exercise: a payout registration
  • Validate a payout registration and report every problem
    • Results of the sample tests

1 diagram · 3 runnable examples (Python) · 1 exercise with sample tests · about 8 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

pydantic at the edge: the same model, its error report, strict mode and the cost, measured Pro: opens with a Pro, Premium or Ultimate pass

pydantic turns type hints into validation code, which removes a lot of hand-written checks and adds a dependency with its own rules. This part writes the sign-up rules as a model, reads its error report, reproduces what lax mode converts, validates plain types and TypedDicts, and measures the cost against hand-written code.

In this part:

  • The sign-up rules as a model, and its error report
  • Validating plain types and TypedDicts with TypeAdapter
  • What validation costs, measured

3 runnable examples (Python) · about 11 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    Why do type hints not protect a program from bad input, and what does?

    Python never compares a value with its annotation. A function annotated age: int runs with the string "21", a dataclass declaring age: int stores whatever it is given, and a TypedDict is a plain dict at run time. Type checkers read the hints before the program runs, so they check the program's own code, not the data that arrives while it runs. Input from outside, such as JSON bodies, form fields, files, command-line arguments and environment variables, must be checked by code that runs: an explicit validator, checks in a dataclass's __post_init__, or a library such as pydantic that generates the checks from the hints. Validate once, where the data enters, and return typed objects that the rest of the program can trust.

  2. Mid to senior

    Design the validation layer of a public sign-up API that receives JSON with a username, an email, an age and a referral code. What do you check, where in the code, what do you return to the client, and what do you hand to the rest of the service?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Fresher to senior

    Your validator accepted {"age": true} and stored an age of 1. Why did that happen, and which other inputs does Python accept more willingly than a validator expects? Give the checks you would write instead.

    Model answer, Pro. First step and follow-ups

  4. Mid to staff

    A teammate wants to add pydantic to a service that today validates three endpoints by hand. When is that worth it, when is a hand-written validator or a dataclass enough, and how would you decide with numbers rather than taste?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Annotations, TypedDicts and dataclasses check nothing at run time; outside data needs validation.
  • Validate once, where the data enters: check every field, report every problem, return a typed, frozen object.
  • Check the shape first, then every field against an allowlist, with exact types and length limits.
  • pydantic generates the checks from type hints; hand-written checks remain the lightest option for a few fields.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.