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:
bashcd ~/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:
bashcd ~/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
- Open a terminal and go into the starter folder:
bash
cd course-materials/module-2/starter - Run it:
bash
python3 run.py - Watch it crash. You'll see the signup counts print successfully
first — then a
TypeErrorabouttimedelta, with no mention of which of the four records actually caused it. - Open
sample_signups.jsonand look at the four records. One of them has atrial_daysvalue 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. - 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:
- Create a pydantic model describing what a valid signup record looks like.
- Write a loading function that turns raw records into validated ones, right at the point they're read in — this is your boundary.
- Update the processing functions so they work with validated records instead of raw dictionaries.
- 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:
pythonfrom 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 withplan: "starter"gets rejected immediately, something a plainstrtype hint could never catch.trial_days: int = 14means: if this field is missing, default to 14. If it's present, it must genuinely be an integer.referral_code: str | None = Nonemeans 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:
pythonimport 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:
pythonfrom 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
-
Create a virtual environment and install, same as Module 1:
bashcd 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
pyo3orpydantic-core, don't worry — this isn't a mistake you made. It means your system's Python version is newer than the pinned version ofpydantichas a pre-built wheel for, sopipfalls 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: openpyproject.toml, and bump the pydantic version to the current latest (check withpip 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. -
Install
mypyitself into this same environment — noticepip install -e .in step 1 only installed what's declared as a dependency inpyproject.toml(justpydantic).mypyis a separate development tool, not something your package depends on to run, so it needs its own install:bashpip install mypy -
Run
mypy --strictagainst the package, usingpython -m mypyrather than baremypy— this guarantees you're using this environment's mypy, not some other one that might exist elsewhere on your system:bashpython -m mypy --strict src/signupflow/Expected:
Success: no issues found in 4 source filesIf 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 differentmypy(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:bashrm -rf .mypy_cache python -m mypy --strict src/signupflow/ -
Now run the same broken data that crashed the starter:
bashpython run.pyYou should not see a confusing
TypeErrorabouttimedeltaanymore. 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. -
Fix the bad value in
sample_signups.json— replace the invalidtrial_daysvalue with a real number — and runpython run.pyagain. 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_percentfield toSignupRecord. Ifreferral_codeis set,referral_bonus_percentmust also be provided, and must be a number between 0 and 100. Ifreferral_codeis not set,referral_bonus_percentshould 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:
diffclass 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 / NoteModule 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
mypypassing 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
Anyto makemypystop complaining.Anydoesn't mean "correct" — it means "unchecked." Reaching forAnybecause you're not sure what type something should be is giving up on the exact thing typing is supposed to do. Optionalused to avoid a decision, not to describe reality. A field should beOptionalbecause 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 --strictas proof of correctness. It only proves your code is honest about its own types. If the types themselves describe the wrong thing — like a looseOptionalstanding in for a real business rule —mypywill 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:
bashdocker 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:
bashdocker 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:
tomldependencies = [ "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:
bashpip 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:
bashpip 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 package —
mypy 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:
- Right-click the top-level
pp4defolder → New Folder → name itscripts. This is a place for developer utility scripts — not part of the installable package insrc/, not lab material incourse-materials/, just tools you use to poke at things while you work. - Right-click
scripts→ New File → name itinspect_api_data.py. - 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:
bashpython3 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=1topage=2,page=3— watchcurrencyandstatusin 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:
RecordPayloadis a nested model, not a plaindict. If you noticedpayloadwas itself a small object and modeled it separately, that's the right instinct —channelandretry_countget checked with the same rigor as everything else, instead of sitting inside an unchecked dictionary.currency,status, andchannelall useLiteral, notstr. If you ran the script across a few pages and noticed these fields kept repeating from a small set, and modeled them asLiteralaccordingly — 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:
bashpython3 -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
- Click the Source Control icon in the sidebar.
- Confirm
.venvis not listed under Changes — if it is, stop and check.gitignorebefore continuing. - You should see
src/,pyproject.toml, andscripts/listed under Changes — the model, the updated project config, and the reusable script you just built. - Stage your changes (the + icon), write a commit message like
Module 2: strict typing, pydantic model for API records, and click Commit. - Click Sync Changes to push to your
production-pythonrepo on GitHub. - 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:
bashdocker 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:
pythonfrom 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:
diffclass 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 —
mypysupports 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."