Production Python for Data Engineers

Module 2: Typing and Code Quality as a Safety Net

What you'll learn in this module

By the end of this module, you'll be able to:

  • Explain what type hints actually catch, and why they're more than just documentation for your editor
  • Get real code passing mypy --strict, and know what "strict" actually turns on
  • Use pydantic to check incoming data at the exact point it enters your system, so bad data fails loudly and specifically, not quietly and confusingly three functions later
  • Spot the difference between a type hint that's honest and one that's just there to make a checker stop complaining
  • Work through this module's AI-assisted round, watching for the exact way AI assistants tend to fake their way past strict typing

Let's begin.


Let's start with a question

Have you ever debugged a crash, and the error message told you almost nothing?

Something like TypeError: unsupported operand type(s). No record number. No field name. Just a crash, somewhere deep inside a function that has nothing to do with where the bad data actually came from.

You start adding print() statements. You trace backwards. Twenty minutes later, you find it — one record, out of thousands, had a string where a number should have been. Somewhere far upstream, nobody checked.

This is one of the most common, most avoidable categories of bug in real data pipelines. Not a hard algorithm problem. Not a clever edge case. Just: bad data got in, and nothing stopped it early enough to say so clearly.

This module is about fixing that — not by writing more careful code, but by making your code structurally unable to let this kind of bug hide.


Why this matters in data engineering

Think about where your data actually comes from in a real job. An API you don't control. A CSV a different team exports. A webhook from a vendor. None of these come with a guarantee. Fields go missing. Types change without warning. Someone updates an upstream system, and suddenly a field that was always a number starts arriving as a string.

Ask yourself: when bad data shows up, do you want to find out about it the moment it enters your system — with a clear, specific error? Or do you want to find out three functions later, with a confusing crash that doesn't even tell you which record was the problem?

That's the entire question this module answers.


The core idea: types as a safety net, not decoration

A type hint like def total(x: int) -> int: looks like it's just documentation. It's not — or at least, it doesn't have to be.

mypy and what "strict" actually means

mypy is a tool that reads your type hints and checks whether your code actually respects them, without running your code at all. Write a function that expects an int, then accidentally pass it a str somewhere else in your code, and mypy can catch that before you ever run anything.

Plain mypy is fairly forgiving by default. It lets a lot of things slide — functions with no type hints at all, for example, just get skipped. mypy --strict turns on a much stricter set of checks. Two of the biggest ones:

  • Every function needs type hints. No exceptions, no silently-skipped functions.
  • Any — a special type that means "could be anything, don't check this" — has to be used on purpose, not as an accident.

Here's the thing worth sitting with: mypy --strict passing doesn't prove your code is correct. It only proves your code is honest about what it does. If your types are wrong, or too loose, mypy will happily pass code that still has real bugs in it. Strict typing is a floor, not a guarantee.

Pydantic: checking data at the boundary

Type hints alone don't check anything at runtime. If a function expects an int and receives a str anyway — because the data came from outside your program, like a JSON file or an API response — mypy can't stop that. Type hints only check code you wrote against other code you wrote. They can't check data arriving from the outside world.

That's where pydantic comes in. A pydantic model is a class that describes exactly what a valid record looks like — which fields exist, what type each one must be, which are required, which are optional. The moment you try to create one from bad data, it fails immediately, with a specific error naming exactly which field was wrong and why.

The idea to hold onto: pick one place — the boundary — where untrusted data enters your system, and check it there, once, thoroughly. Everything downstream of that point can then simply trust the data, instead of every single function needing to defensively re-check it.


Manual lab: catch bad data at the door, not three functions later

Getting the lab files

Download module-2-materials.zip from the course platform. This works the same way it did in Module 1:

bash
cd ~/courses/pp4de cp /mnt/c/Users/yourname/Downloads/module-2-materials.zip course-materials/ cd course-materials unzip module-2-materials.zip

You should now have course-materials/module-2/starter/ and course-materials/module-2/solution/. Commit this the same way as before:

bash
cd ~/courses/pp4de git add course-materials/module-2 git commit -m "Add Module 2 lab materials" git push

The scenario

You've inherited a small tool that processes signup records for a SaaS product. Each record has an email, a plan, a signup date, and an optional number of trial days. The tool counts signups by plan, and works out when each person's trial ends.

It mostly works. But every so often, it crashes — and the error message never tells anyone which record caused it.

Reproduce the problem first

  1. Open a terminal and go into the starter folder:
    bash
    cd course-materials/module-2/starter
  2. Run it:
    bash
    python3 run.py
  3. Watch it crash. You'll see the signup counts print successfully first — then a TypeError about timedelta, with no mention of which of the four records actually caused it.
  4. Open sample_signups.json and look at the four records. One of them has a trial_days value that isn't really a number. Can you spot it from the crash message alone? Most people can't — that's the whole point of this exercise.
  5. Open signup_processor.py. Notice there isn't a single type hint anywhere in the file. Nothing here would stop bad data from getting this far.

Your task

Fix this so that a bad record gets caught immediately, with a clear error naming exactly which record and field is wrong — instead of a confusing crash deep inside the calculation logic. Specifically:

  1. Create a pydantic model describing what a valid signup record looks like.
  2. Write a loading function that turns raw records into validated ones, right at the point they're read in — this is your boundary.
  3. Update the processing functions so they work with validated records instead of raw dictionaries.
  4. Get the whole thing passing mypy --strict.

The actual calculations — how a trial end date gets worked out, how counts get totaled — shouldn't need to change. Only what those calculations trust should change.

A question worth asking before you look at the solution

We just said the actual calculations shouldn't change. So if you look at the solution and the code looks different from the starter, that's worth pausing on, not skipping past — same as in Module 1.

Here's the honest answer: the math itself is identical. Compare how trial_end gets computed in both versions — same formula, same logic. What changes is what that formula is allowed to operate on. The starter reaches into a raw dictionary and hopes the fields are right. The solution works with an already-validated object, where "wrong type" simply isn't possible anymore by the time the calculation runs.

One real difference you'll notice: the starter accesses fields with record["email"], and the solution accesses them with record.email. That's not a style choice — it's a direct result of moving from an untyped dictionary to a typed object, and it's the kind of change this module is actually teaching, not a side effect to explain away.

Full worked solution

The complete solution lives in course-materials/module-2/solution/:

solution/
├── pyproject.toml
├── sample_signups.json
├── run.py
└── src/
    └── signupflow/
        ├── __init__.py
        ├── models.py       # the boundary - what a valid record looks like
        ├── loader.py        # turns raw data into validated records
        └── processor.py     # same math as the starter, now fully typed

models.py — the pydantic model itself:

python
from datetime import date from typing import Literal from pydantic import BaseModel class SignupRecord(BaseModel): email: str plan: Literal["free", "pro", "enterprise"] signup_date: date trial_days: int = 14 referral_code: str | None = None

A few things worth noticing:

  • plan: Literal["free", "pro", "enterprise"] doesn't just say "this is a string" — it says "this must be exactly one of these three strings." A record with plan: "starter" gets rejected immediately, something a plain str type hint could never catch.
  • trial_days: int = 14 means: if this field is missing, default to 14. If it's present, it must genuinely be an integer.
  • referral_code: str | None = None means this field is genuinely allowed to be absent — not "I didn't want to deal with validating this," but an honest, deliberate "this is optional."

loader.py — where raw data gets checked, once:

python
import json from pathlib import Path from pydantic import ValidationError from signupflow.models import SignupRecord def load_signup_records(path: Path) -> list[SignupRecord]: with open(path) as f: raw_records = json.load(f) validated: list[SignupRecord] = [] for index, raw in enumerate(raw_records): try: validated.append(SignupRecord(**raw)) except ValidationError as e: raise ValueError( f"Record at index {index} is invalid: {e}\nRaw record: {raw}" ) from e return validated

Notice this function catches pydantic's error and re-raises it with the record's position included. Pydantic already tells you which field is wrong. This adds which record — so the final error tells you both.

processor.py — the same math as the starter, fully typed:

python
from datetime import date, timedelta from signupflow.models import SignupRecord def compute_trial_end_dates(records: list[SignupRecord]) -> list[dict[str, str]]: results = [] for record in records: trial_end = record.signup_date + timedelta(days=record.trial_days) results.append( { "email": record.email, "plan": record.plan, "trial_end": trial_end.isoformat(), } ) return results def count_by_plan(records: list[SignupRecord]) -> dict[str, int]: counts: dict[str, int] = {} for record in records: counts[record.plan] = counts.get(record.plan, 0) + 1 return counts

Verify it, step by step

  1. Create a virtual environment and install, same as Module 1:

    bash
    cd course-materials/module-2/solution python3 -m venv .venv source .venv/bin/activate pip install -e .

    If this fails with a long Rust/Cargo compilation error mentioning pyo3 or pydantic-core, don't worry — this isn't a mistake you made. It means your system's Python version is newer than the pinned version of pydantic has a pre-built wheel for, so pip falls back to compiling it from source, and that compilation itself fails on a very new Python release. The fix is the same idea as pinning in the first place: open pyproject.toml, and bump the pydantic version to the current latest (check with pip index versions pydantic), then re-run the install. This exact situation is worth recognizing — it's not unique to this module, and it can happen with any pinned package if your Python version is newer than the pin anticipated.

  2. Install mypy itself into this same environment — notice pip install -e . in step 1 only installed what's declared as a dependency in pyproject.toml (just pydantic). mypy is a separate development tool, not something your package depends on to run, so it needs its own install:

    bash
    pip install mypy
  3. Run mypy --strict against the package, using python -m mypy rather than bare mypy — this guarantees you're using this environment's mypy, not some other one that might exist elsewhere on your system:

    bash
    python -m mypy --strict src/signupflow/

    Expected: Success: no issues found in 4 source files

    If instead you see Cannot find implementation or library stub for module named "pydantic", along with a cascade of other confusing, seemingly unrelated errors — don't chase those other errors individually. They're downstream noise from the one real problem: mypy can't see pydantic, usually because a different mypy (not the one just installed into this venv) is being picked up from somewhere else on your system. Clear any stale cached results and force the use of this environment's mypy explicitly:

    bash
    rm -rf .mypy_cache python -m mypy --strict src/signupflow/
  4. Now run the same broken data that crashed the starter:

    bash
    python run.py

    You should not see a confusing TypeError about timedelta anymore. You should see a clear error that names the exact record index, the exact field (trial_days), and the exact reason — that it couldn't be read as an integer.

  5. Fix the bad value in sample_signups.json — replace the invalid trial_days value with a real number — and run python run.py again. This time it should run cleanly, printing the signup counts and every trial end date with no errors at all.

If step 4 gave you a specific, useful error, and step 5 ran clean, you've verified the actual thing this module set out to teach.


AI-assisted round

You already know the closed loop from Module 1 — propose, run, review, iterate — so this round moves a little faster. If you need a refresher on using your assistant, or on the Allow/Skip flow, AI-ASSISTANT-SETUP.md still applies exactly as before.

The task

Ask your assistant to extend the solution with a new field:

Output / Note

"Add a referral_bonus_percent field to SignupRecord. If referral_code is set, referral_bonus_percent must also be provided, and must be a number between 0 and 100. If referral_code is not set, referral_bonus_percent should not be provided either. Write a test that checks both the valid and invalid cases, and run it before telling me you're done."

The known failure pattern to watch for

This module's known failure pattern is one of the most common mistakes AI assistants make with typing tasks: reaching for a loose type instead of actually enforcing the rule you asked for.

The requirement above isn't just "add a field" — it's a rule that connects two fields together: if one is set, the other must be too. That's genuinely a little more work to implement correctly. A shortcut an assistant might take:

diff
class SignupRecord(BaseModel): email: str plan: Literal["free", "pro", "enterprise"] signup_date: date trial_days: int = 14 referral_code: str | None = None + referral_bonus_percent: float | None = None

Notice what this actually does: nothing. mypy is satisfied — the type is valid. Pydantic accepts any combination you throw at it: a record with referral_code set and no bonus percent, a record with a bonus percent of 150 (way outside 0-100), a record with a bonus percent but no referral code at all. The actual rule you asked for — the connection between the two fields — was never implemented. The assistant added a field that looks like it satisfies the request, and technically compiles clean, without actually enforcing anything.

This is exactly the trap named in this module's concept section: a type hint that makes a checker stop complaining is not the same as a type hint that's honest about what's actually required.

The guardrail

If you catch this — and you should go looking for it specifically, by testing a record that violates the cross-field rule and confirming it actually gets rejected — write down a line in your guardrail log. Something like:

Output / Note

Module 2 guardrail: When a requirement connects two fields together, check that the AI actually wrote validation logic for that connection — not just two independent optional fields that happen to both exist. Test the specific invalid combination directly, don't just trust that mypy passing means the rule is enforced.

If your assistant got the cross-field validation right on the first try — genuinely possible, especially with a very explicit prompt like the one above — that's still worth a line in the log. Note what you checked and confirmed clean.


Common mistakes

  • Using Any to make mypy stop complaining. Any doesn't mean "correct" — it means "unchecked." Reaching for Any because you're not sure what type something should be is giving up on the exact thing typing is supposed to do.
  • Optional used to avoid a decision, not to describe reality. A field should be Optional because it's genuinely allowed to be missing in the real world — not because handling the "what if it's missing" case felt like too much work right now.
  • Mixing pydantic v1 and v2 syntax in the same project. Pydantic v2 changed some core syntax from v1. Mixing old and new patterns in the same codebase is a common, confusing mistake — always check which version your project actually depends on.
  • Trusting a green mypy --strict as proof of correctness. It only proves your code is honest about its own types. If the types themselves describe the wrong thing — like a loose Optional standing in for a real business rule — mypy will happily pass code that's still broken.

Capstone tie-in

This is where typing and validation become a real, permanent standard in your capstone repo — not just something you practiced in a lab.

Step 1: Open your capstone repo and start the infrastructure

Confirm the title bar says pp4de [WSL: Ubuntu], not solution [WSL: Ubuntu] or course-materials.

You'll need the mock API running later in this walkthrough, so start it now rather than partway through. Open the integrated terminal (Ctrl+`) and run:

bash
docker compose start

(Use start, not up -d — the containers already exist from setup, start just resumes them. See SETUP.md Part E if this is unfamiliar.) Confirm both services come up healthy:

bash
docker compose ps

Step 2: Make mypy --strict a project-wide standard

In VS Code's Explorer, click pyproject.toml at the root of pp4de to open it — this is the same file you created in Module 1. Add these lines at the bottom:

toml
[tool.mypy] strict = true

What this does: from now on, running mypy against this project uses strict mode by default — nobody has to remember to add the --strict flag by hand every time.

Step 3: Add pydantic as a project dependency

Still inside pyproject.toml, find the dependencies = [...] list you created in Module 1, and add pydantic to it:

toml
dependencies = [ "click==8.1.7", "pydantic==2.13.4", ]

Step 4: Install what you just added

Two things need doing before any of this actually works, and it's easy to skip both since neither one produces an error immediately — the problem only shows up later, confusingly, when you try to use what you just declared.

Open the integrated terminal (**Ctrl+**), confirm your virtual environment is activated (source .venv/bin/activateif you don't see(.venv)` in your prompt), and run:

bash
pip install -e .

Why this is needed again: adding pydantic to the dependencies list in pyproject.toml in Step 3 only declared it — it didn't install it. Editing a config file never installs anything on its own; pip install -e . is what actually reads that file and fetches what it lists. If you skip this, pydantic genuinely isn't available in this environment yet, even though it looks like it should be.

Now install mypy itself, separately:

bash
pip install mypy

Why this is needed: Module 1 never used mypy, so it was never installed in this project's virtual environment. pip install -e . only installs what's declared as a dependency of your packagemypy is a development tool you use, not something pipeline depends on to run, so it always needs its own install, the same way it did back in Module 2's lab.

If you skip either of these and jump straight to Step 6's mypy check, you'll see something like Class cannot subclass "BaseModel" (has type "Any") — a confusing error that doesn't obviously point back to a missing install. If you ever see that error, this is the fix: confirm both installs above actually happened in this environment.

Step 5: Create your first real data model

This is the actual boundary for your capstone: the shape of a record coming from the mock API. You don't have real ingestion logic yet — that arrives in later modules — but you can model exactly what a valid record looks like right now.

Here's the honest problem, though: how do you model data you haven't actually looked at? Reading data_generator.py line by line is one way, but you're about to own a real pipeline pulling from a real API you won't always be able to read the source code of. The better habit is looking at what the API actually sends, the same way you'd have to on a real job.

First, look at the real data.

Make sure docker compose up -d is running, then create a small, reusable script — this isn't a throwaway command, it's something you'll extend and keep using through later modules too.

In VS Code's Explorer:

  1. Right-click the top-level pp4de folder → New Folder → name it scripts. This is a place for developer utility scripts — not part of the installable package in src/, not lab material in course-materials/, just tools you use to poke at things while you work.
  2. Right-click scriptsNew File → name it inspect_api_data.py.
  3. Type or paste this in:
python
"""A small, reusable tool for looking at real records from the mock API. Run this to see what the API actually sends, before writing or updating a model against it. Extend it (see Step 5) once you have a model to validate against, instead of writing a new one-off script each time. """ import urllib.request import json with urllib.request.urlopen( "http://localhost:8000/records?date=2026-07-01&page=1" ) as r: records = json.load(r)["records"] print(f"Got {len(records)} records. Here's the first one:\n") print(json.dumps(records[0], indent=2))

Open the integrated terminal (Ctrl+`) and run it:

bash
python3 scripts/inspect_api_data.py

Read the output carefully. You're looking for:

  • What fields exist, and what each one is called
  • Which fields look like plain values (a string, a number) versus a nested object (look closely at payload)
  • Whether any fields look like they only ever take one of a small, fixed set of values (run the script a few times, or change page=1 to page=2, page=3 — watch currency and status in particular)

Your task: write your own model.

Based only on what you actually observed, write a pydantic model for this record — call the class IngestionRecord. Use everything you learned in this module: real types, not Any; a nested model instead of a loose dict for anything that looks like a sub-object; Literal instead of str if you noticed a field only ever takes a small, fixed set of values; Optional only if you actually saw a field go missing (you likely won't, at first — the API mostly sends well-formed records).

Put this in src/pipeline/models.py, the same way you would for any real package code.

Compare against a reference solution.

Here's one honest version, based on the same data you just looked at:

python
"""The data boundary for records coming from the ingestion API. Every record from infra/mock-api gets checked against this model before anything downstream trusts it. This mirrors exactly what you built in Module 2's lab - a pydantic model at the point untrusted data enters the system - just applied to the capstone's real data source instead of the lab's practice one. """ from datetime import datetime from typing import Literal from pydantic import BaseModel class RecordPayload(BaseModel): channel: Literal["web", "mobile", "pos", "api"] retry_count: int class IngestionRecord(BaseModel): external_id: str customer_id: str amount: float currency: Literal["USD", "EUR", "GBP", "INR", "JPY"] status: Literal["completed", "pending", "refunded", "failed"] source_created_at: datetime source_updated_at: datetime payload: RecordPayload

Don't worry if yours doesn't match exactly — what matters is whether the decisions match. A few worth checking against your own:

  • RecordPayload is a nested model, not a plain dict. If you noticed payload was itself a small object and modeled it separately, that's the right instinct — channel and retry_count get checked with the same rigor as everything else, instead of sitting inside an unchecked dictionary.
  • currency, status, and channel all use Literal, not str. If you ran the script across a few pages and noticed these fields kept repeating from a small set, and modeled them as Literal accordingly — that's exactly the observation this was designed to prompt. A record with a status value the API never produces on purpose (which happens — it deliberately corrupts a small percentage of records) now gets rejected immediately, instead of quietly flowing through as a valid-looking but meaningless string.
  • Nothing here is Optional. If you never actually observed a field go missing, you shouldn't have marked anything optional "just in case" — that's exactly the mistake this module's common-mistakes section warns about.

If your version differs in a real way — not just naming — that's worth sitting with for a moment: did you observe something different, or did you make an assumption without checking? Either way, update models.py now if you need to, before moving on.

This file will grow significantly in later modules — for now, getting a first honest model in place, built from real observation, is the goal.

Step 6: Validate your model against real data — extend the same script

Don't just eyeball your model against a sample record and assume it's right. Prove it — by extending the same script you already have, not by typing a long one-off command into the terminal. You'll want this script again later, whenever this model changes.

Open scripts/inspect_api_data.py and replace its contents with:

python
"""A small, reusable tool for checking real records from the mock API against the current IngestionRecord model. Run this any time the model changes, or any time you want to see how much of a real page of data currently validates cleanly. """ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) import urllib.request import json from pipeline.models import IngestionRecord from pydantic import ValidationError with urllib.request.urlopen( "http://localhost:8000/records?date=2026-07-01&page=1" ) as r: records = json.load(r)["records"] valid = 0 for rec in records: try: IngestionRecord(**rec) valid += 1 except ValidationError as e: print("Rejected:", e) print() print(f"{valid} of {len(records)} records validated cleanly")

Notice the sys.path.insert line near the top. If that looks familiar, it should — it's the same kind of manual path patch you saw (and questioned) back in Module 1's AI-assisted round. Here it's doing a legitimate job: this script isn't installed as part of your package, so it needs a way to find pipeline.models. That's a fair use of the pattern — the difference from Module 1 is that here it's a deliberate, understood choice for a standalone script, not an unexplained patch hiding a deeper problem.

Open the integrated terminal (Ctrl+`), confirm your virtual environment is activated, and run:

bash
python3 -m mypy --strict src/pipeline/ python3 scripts/inspect_api_data.py

Confirm mypy passes cleanly first. Then read the script's output. You should see most records validate cleanly, and a handful rejected, each with a specific error naming exactly which field was wrong. That's not a bug — the mock API deliberately corrupts a small percentage of records, and this is your model doing its actual job.

One thing worth knowing, since it's easy to assume otherwise: this API also supports a chaos=false parameter, but that only turns off delivery flakiness — rate limiting, timeouts, duplicate or empty pages. It does not turn off malformed records. A small percentage of records come back corrupted no matter what chaos is set to, because that's meant to represent a real, unreliable upstream API — one you can't ask nicely to stop occasionally sending you garbage. Worth confirming assumptions like this empirically rather than trusting what a parameter name implies to mean.

Keep this script. Commit it along with everything else — you'll extend it again in later modules as your model grows.

Step 7: Commit and push through VS Code's Source Control panel

  1. Click the Source Control icon in the sidebar.
  2. Confirm .venv is not listed under Changes — if it is, stop and check .gitignore before continuing.
  3. You should see src/, pyproject.toml, and scripts/ listed under Changes — the model, the updated project config, and the reusable script you just built.
  4. Stage your changes (the + icon), write a commit message like Module 2: strict typing, pydantic model for API records, and click Commit.
  5. Click Sync Changes to push to your production-python repo on GitHub.
  6. Confirm the changes appear on GitHub.

Check row 2 off your SPEC.md checklist. Seven to go.

Before you close for the day, stop the infrastructure the same way you started it — don't leave it running unnecessarily in the background:

bash
docker compose stop

(Not docker compose down -v — that deletes your Postgres data along with stopping the containers. stop pauses everything cleanly, ready to docker compose start again next time, exactly as SETUP.md Part E describes.)


Interview drill

Every question below follows the same pattern. First, the question. Then, what the interviewer wants to hear. Then, a junior engineer's answer. Then, a strong senior answer.

Recall

Question: "What does mypy --strict actually check, that plain mypy doesn't? And why doesn't a passing mypy --strict run guarantee your code is correct?"

What the interviewer wants to hear: not just a list of flags. A real answer connects strict typing to what it can't catch — showing the candidate understands the limits of the tool, not just how to turn it on.

Junior answer: "Strict mode makes mypy check more things. It won't let you skip type hints, and it's stricter about Any. It's just a stronger version of the normal mode."

(This is true, but shallow. No sense of what the tool actually can't catch.)

Senior answer: "Plain mypy skips a lot by default — functions with no type hints at all just get ignored, silently. --strict closes that gap. Every function needs real type hints, and Any has to be used on purpose, not as a quiet escape hatch. But here's the part that matters more: passing mypy --strict only proves your code is consistent with its own types. It doesn't prove the types are correct. If I write a field as Optional[str] when it should really always be required, mypy won't catch that — because my code is being perfectly honest about a wrong assumption. I've seen this cause real bugs: a field marked optional that was actually always supposed to be present, and some code path quietly skipped handling the 'missing' case because nobody expected it to matter. Strict typing is a floor, not a ceiling."

Debugging

Question: shown as a real code snippet and a real incident story. The learner sees only this, with no explanation yet:

python
from pydantic import BaseModel from datetime import datetime class AppointmentUpdate(BaseModel): appointment_id: str new_time: Optional[datetime] = None provider_id: Optional[str] = None reason_for_change: Optional[str] = None def reschedule(update: AppointmentUpdate) -> None: appt = fetch_appointment(update.appointment_id) appt.time = update.new_time appt.provider_id = update.provider_id save_appointment(appt)

"This passes mypy --strict with no errors. In production, a small number of appointments have started silently losing their scheduled time and assigned provider — the fields just become empty in the database, with no error anywhere in the logs. mypy is green. What's wrong?"

What the interviewer wants to hear: recognizing that mypy --strict passing here is not proof of correctness — the types are consistent, but they're modeling the wrong thing. A strong answer names the real fix, not just "add a null check."

Junior answer: "Maybe add a check before saving, so it doesn't overwrite the time and provider if they're None."

(This treats the symptom directly in front of it, without asking why every field ended up Optional in the first place, or what that choice actually means for every caller of this function.)

Senior answer: "Every field on AppointmentUpdate is Optional, including ones that really should be required for a reschedule request — new_time and provider_id. When a partial update comes in — say, a client only sends reason_for_change — the missing fields deserialize as None. Then reschedule() blindly overwrites the appointment's real time and provider with those None values. mypy is completely satisfied here — assigning Optional[X] to Optional[X] is exactly correct by the type system. The bug isn't a type-checker miss. It's a modeling mistake — the types are honestly describing code that does the wrong thing. The real fix is modeling intent, not just shape: use a model where only the fields actually supplied are present at all — pydantic supports this with exclude_unset — so 'this field wasn't sent' and 'this field was explicitly cleared' are two different, distinguishable things, instead of both collapsing into the same None."

AI-review

Question: the learner is shown this diff, with no explanation yet, and asked what's wrong with it and what they'd tell the assistant to fix:

diff
class SignupRecord(BaseModel): email: str plan: Literal["free", "pro", "enterprise"] signup_date: date trial_days: int = 14 referral_code: str | None = None + referral_bonus_percent: float | None = None

The original request: "Add a referral_bonus_percent field. If referral_code is set, referral_bonus_percent must also be provided, and must be a number between 0 and 100. If referral_code is not set, referral_bonus_percent should not be provided either."

What the interviewer wants to hear: recognizing that this diff satisfies mypy and looks reasonable, while actually enforcing none of the real requirement. A strong answer explains exactly what's missing, not just "this seems incomplete."

Junior answer: "Looks okay — it added the field with the right type. Maybe just needs a comment explaining the rule."

(Accepts a change that adds a field but skips the actual behavior that was asked for, because the field itself looks correctly typed.)

Senior answer: "The field is added, and mypy is happy — but the actual rule was never implemented. This diff accepts a record with referral_code set and no bonus percent at all. It accepts a bonus percent of 150, way outside 0-100. It even accepts a bonus percent with no referral code, which the request explicitly said shouldn't happen. None of that gets rejected, because referral_bonus_percent is just an independent optional field — nothing connects it to referral_code at all. I'd tell the assistant directly: this needs real cross-field validation, not just a second optional field that happens to exist alongside the first one. In pydantic, that means a model-level validator that checks both fields together and raises a clear error if the rule is broken. And I'd ask for a specific test case that tries the invalid combination — referral_code set, no bonus percent — and confirms it's actually rejected, not just a test that the field accepts valid input."


Judgment

Interviewer: "You join a team syncing patient records between a hospital's internal system and a regional health exchange. The existing code uses raw dictionaries everywhere. No pydantic. mypy isn't run in CI at all. Your tech lead says adding strict typing and validation across the whole codebase would take about three weeks, and there's no room in the current sprint for that. What do you do?"

Candidate (asking first): "Before I answer — how big is the codebase, roughly? And is there any real evidence of bugs caused by bad data today, or is this more of a 'this feels risky' concern?"

Interviewer: "Medium-sized — maybe 40 files touch patient data directly. And yes, there's real evidence — two incidents in the last quarter where a malformed record from the health exchange caused a silent data mismatch. Neither was caught until a patient's record looked wrong days later."

Candidate (first answer): "Given real incidents already happened, I wouldn't try to sell a full three-week rewrite up front — that's a hard ask with no visible progress until the very end. I'd propose starting at the boundary: wherever data actually enters this system from the health exchange, add pydantic validation there first. That's a small, focused piece of work, and it directly addresses both incidents you just described — bad data getting in undetected."

Interviewer (pushback): "But the bugs didn't show up right at the boundary — they showed up days later, somewhere deep in the code. How does fixing the boundary help with bugs that happen far downstream?"

Candidate (round 1): "Good pushback — let me be precise about what boundary validation actually fixes. In both incidents, the root cause was bad data getting in. Where the bug became visible was somewhere else entirely, which is exactly what made it hard to trace. If the boundary had rejected that malformed record immediately, with a clear error, neither incident would have silently propagated in the first place. Boundary validation doesn't fix every possible bug in the codebase — but it fixes the specific failure mode you just described, which is the one with real incidents behind it."

Interviewer (second pushback): "Leadership also wants full mypy --strict compliance before a compliance audit in two months. Can your incremental approach actually get us there in time, or do we need the full three weeks starting now?"

Candidate (round 2): "I wouldn't do all-or-nothing here either. mypy supports enabling strict checking file by file, not just for the whole codebase at once. I'd start strict mode on the boundary code we're already touching, then expand it outward — every file anyone touches for any reason gets brought up to strict as part of that work, plus a standing goal of converting a few more files a week deliberately. I'd also add a CI check that stops the percentage of strict-compliant files from ever going backward, even before we hit 100%. That gives leadership a real, visible number they can track toward the audit — not a silent three-week black box with nothing to show until the end."

Interviewer (final challenge): "Convince me this isn't just a slower way of avoiding the real three-week investment."

Candidate (final defense): "The difference isn't slower versus faster — it's visible versus invisible. A three-week block with no interim progress is a bet that nothing goes wrong and nothing more urgent comes up in three weeks, which is a real risk on a live patient data system. My approach front-loads the highest-value fix — the boundary, tied to actual incidents — in the first few days, not the last. Full compliance still happens, on a tracked, ratcheting basis, with a number leadership can check in on anytime. That's not avoiding the work. That's sequencing it so the riskiest gap closes first, and progress is visible the whole way, not just at the end."


Model strong answer — a single answer, given upfront, that would satisfy most interviewers without needing every round of pushback above:

Output / Note

"I'd want to know two things first: roughly how large the codebase is, and whether there's real evidence of bugs from bad data, versus just a general sense of risk. Assuming there have been real incidents tied to malformed data from the health exchange, I wouldn't propose the full three-week rewrite up front — that's a big ask with no visible progress until the very end, and it's not actually where the highest-value fix is. I'd start at the boundary: wherever data enters this system from outside, add pydantic validation there first. That directly targets the actual failure mode behind the real incidents — bad data getting in undetected and causing confusing problems somewhere else entirely. For the compliance audit, I wouldn't treat strict typing as all-or- nothing either — mypy supports enabling strict mode file by file, so I'd bring the boundary code to strict immediately, expand outward as other files get touched, and add a CI check that stops the percentage of strict-compliant files from ever regressing. That gives leadership a real, trackable number heading toward full compliance, instead of a silent three-week commitment with nothing to show until the end. It's not slower than the full rewrite — it's sequenced so the riskiest, highest-evidence gap closes first, with visible progress the whole way."