Build with it · Beginner
Python, from Zero, for AI
Draft, open for academic review
From your first line of code to your first API call.

9 modules. 89 lessons.
Getting Python running, and your first working code
10 lessons · 1 h 11
You will be able toInstall Python on a laptop, a phone or a free browser machine, run the same code both interactively and from a saved file, and predict what a short program prints by naming the type of every value in it and the branch a condition takes.
- 1.1Getting Python onto the machine you actually have
- 1.2What a program is, and running your first line
- 1.3The interactive shell and the saved file, and when to use each
- 1.4Variables, and the fact that everything has a type
- 1.5Numbers: whole, decimal, and the one that surprises everybody
- 1.6Strings, and why input() always hands you text
- 1.7Building the output a person will read
- 1.8True, False, and what Python counts as nothing
- 1.9Making a decision: if, elif, else
- 1.10What happens between your file and the answer
An illustrative case, built from documented patternsFourteen machines, no admin password, and a term that has already started
Kavita Salve teaches computer science to thirty-two students of class eleven in a government school in Bhopal. The lab has fourteen desktops that switch on, all running Windows 10, all locked down by the district’s IT contractor. The administrator password is with the contractor. He visits once a month and his next visit is in nineteen days.
She had two options and both cost something.
The first was to teach the whole term in a browser. A free notebook service starts in about thirty seconds, needs nothing installed, works on the four students’ phones as well as on the desktops, and cannot be broken by anything a student does. The cost is that the lab shares a four megabit connection with the office, and every morning at about twenty past eleven the block’s attendance upload saturates it for fifteen minutes. It is also, in her words, a place where code goes to be forgotten: the notebook hides where the file is, students never save anything they can find again, and a program becomes something that only exists while a tab is open.
The second was to get Python on the machines. The installer from python.org wants administrator rights. The store version does not, but the store requires a signed-in account and the machines have none. She could wait nineteen days for the contractor, or she could write to the district office and wait longer.
A colleague suggested a third way that she had not considered: a portable build of Python, copied from a USB stick into each machine’s own user folder, which needs no administrator and no store. It runs. It also cannot tick the Add Python to PATH box, because there is no installer doing the ticking, so the command is not python. It is a path eleven characters longer, typed at the start of every line, or a batch file she writes for them.
The decision was not really about Python. It was about what the first three lessons of the term were going to be. Three lessons on installation is three lessons of a fifteen-week term spent on something no exam asks about, with a real chance that on lesson three four machines still do not work and those students have done nothing. Three lessons on code means the browser, and the browser means that in February, when a student sits at a cousin’s laptop and wants to run what she wrote in September, none of it is there and none of the commands she knows apply.
The head teacher, who signs the internet bill, had a view of his own. He wanted the browser, because a lab of machines with software installed by a teacher is a lab he gets asked questions about when something stops working.
Kavita made her choice on the strength of one thing she had seen the previous year. In the board practical, students are given a printed program and asked what it prints. Her class had done badly on it, not because they could not code, but because they had only ever seen code in a cell that prints the value of the last line automatically. Asked what a saved file prints, half of them included values the file never printed. That is a gap the browser had created and she could see it in the marks.
How it played out
She split the term. Weeks one and two ran in the browser, so that every student wrote and ran code on day one, including the six who had only a phone. In week three she copied the portable Python onto all fourteen machines from a USB stick (one lesson, forty minutes, twelve machines working by the end) and wrote a one-line batch file called py.bat so the command was short enough to type.
From week three, one rule: everything is a saved file, run from the terminal, and the browser is only for trying a single line. She kept the notebook for exactly that, because the shell genuinely is the better place to ask what type() says about a value.
Two machines refused to run anything from a user folder; the policy blocked executables there. Those four students worked in pairs for the rest of the term, which she disliked and could not fix.
In the February practical, twenty-six of thirty-two correctly predicted the output of a printed program, against fourteen of thirty the previous year. The commonest remaining error was the one the module warns about: reporting a value that the file computed but never printed.
The contractor came on day nineteen and installed Python properly on all fourteen machines in about ten minutes, with the PATH box ticked. Kavita kept the batch file anyway. Two students had by then installed Python at home themselves, and both had hit the same thing: on a Mac borrowed from an uncle, python was not a command and python3 was.
Worth arguing about
The browser option was faster to start and worked on phones. What did it cost, in a way that showed up in marks rather than in the lesson?
One answer
A notebook prints the value of the last expression in a cell automatically. A saved file prints only what you tell it to print. Students who had only ever worked in a notebook formed a wrong model of what a program shows you, and it appeared in the practical, where they listed values the file computed but never printed. The gap was invisible while they were coding, because in the notebook their mental model and the tool agreed.
Kavita could not tick Add Python to PATH with the portable build. Why does that box matter, and what does its absence actually change?
One answer
Ticking it adds the Python folder to the list of places the shell searches for a command, so that typing python or py works from any folder. Without it, nothing is broken (Python runs perfectly well), but every command must name the full path to the interpreter, which is long, error-prone and different on every machine. Her batch file recreated the effect for one command in one place, which is the practical fix when you cannot change the system.
Two students learned at home that python and python3 are not the same command. Why is that difference worth teaching early rather than treating as a detail?
One answer
On macOS and most Linux systems python3 is the interpreter and python is either missing or, historically, a very old version, while on Windows the installer usually provides python and py. A learner who has memorised one command believes their code has stopped working when they move machines, and the error (command not found) says nothing about versions. Knowing that the command names the interpreter, and that a machine can have several, turns a mysterious failure into a one-line check with python3 --version.
Test yourself6 questions on this module
At the interactive prompt a student types total = 5 + 3 and then total, and sees 8. She puts exactly those two lines in a file and runs it. Nothing appears. What is going on?
A program reads two marks with input() and prints the larger. Given 9 and 10 it prints 9. Nothing crashes. Why?
Why is 0.1 + 0.2 == 0.3 False?
What does -7 // 2 give, and why?
A grading script uses four separate if statements (if marks >= 75: grade = “distinction”, if marks >= 40: grade = “pass”, and so on) instead of elif. A mark of 90 comes out as “pass”. Why?
A script does retries = typed_value or 3, where typed_value came from a form and is the integer 0 because the user genuinely wants no retries. What is retries?
Collections, loops, and the shape of your data
10 lessons · 1 h 18
You will be able toChoose between a list, a dict, a tuple and a set for a given piece of data and defend the choice on cost, then walk a nested JSON-shaped structure with loops or comprehensions without mutating something you did not intend to change.
- 2.1Lists and dicts, and knowing which one you need
- 2.2Reaching into a sequence: indexes, slices and why the end is excluded
- 2.3Changing a list, and the copy you thought you made
- 2.4Tuples and sets, and the cost of asking is this in there
- 2.5Dictionary patterns: defaults, counting and grouping
- 2.6Loops, or doing the same thing to many things
- 2.7While loops, break, and the loop that never ends
- 2.8Comprehensions: building a list in one line, and when not to
- 2.9Sorting by something other than the value itself
- 2.10Nested data: dictionaries inside lists inside dictionaries
An illustrative case, built from documented patternsNine hundred students, forty companies, and twenty-six minutes a run
The placement cell at a private engineering college in Coimbatore runs a matching exercise every December. Nine hundred and twelve final-year students, forty visiting companies, and for each company a list of conditions: a minimum aggregate, an allowed set of branches, sometimes a backlog rule, sometimes a bar on students already holding an offer above a certain package.
The script that does it was written three years ago by a student who has since graduated. It works. It is also seven nested loops over lists, and it takes twenty-six minutes for a full run on the cell’s one laptop.
Twenty-six minutes was tolerable when the rules were fixed. This year they are not. The companies send revisions (a branch added, a cut-off dropped from seventy to sixty-five) and each revision means another run. On the Tuesday, the coordinator, Divya, ran it nine times and lost most of the working day to waiting. The shortlists are due Friday morning and eleven companies have still to confirm their final conditions.
A colleague in the CS department looked at the script for twenty minutes and found the shape of the problem. Buried in the innermost loop was a line that asked, for every student and every company, whether that student’s roll number appeared in a list of students already placed. That list had grown to four hundred and ten entries, and Python was scanning it from the start, every time, four hundred thousand times over the run.
His proposal was small: build a set of placed roll numbers once, before the loops, and change one line. He estimated ten minutes of work and a run of under a minute afterwards.
Divya’s objection was not about the code. It was Wednesday. The script produced shortlists that the college had been sending to companies for three years, and the results were trusted. A change to a working program four days before a deadline, made by someone who had not written it, was a risk with a specific shape: if the new version quietly dropped or duplicated students, nobody would notice until a company asked why a shortlisted student had never been told, and that is a conversation the college has once and then loses the company.
Against that was the cost of not changing it. Eleven confirmations still to come meant at least eleven more runs, five hours of waiting spread across two days, and the real danger that the last revision would arrive at eight on Thursday evening with the printing to do.
The head of the cell asked one question that decided it: can you prove the new one gives the same answer as the old one?
There was a second thing in the file that the CS colleague noticed and did not mention on the Tuesday, because it was not the bottleneck. Every company’s allowed branches were stored as a list of strings, and every student’s branch was compared against it with the same in. Forty companies, nine hundred students, a list of six branches each: small enough to be invisible next to the four hundred thousand scans, and the same shape of mistake.
What made the twenty-six minutes hard to argue about was that nobody in the room could point at the slow line by reading the code. The nesting was seven deep and every loop looked reasonable on its own. The colleague had not found it by reading either; he had run the script with cProfile while making tea, and the top of the output said that ninety-four per cent of the time was inside one membership test.
Divya’s other worry was the input file. The cell receives student data as a spreadsheet export, and it had grown two columns since the script was written. A rewrite that touched how records were read might quietly change which column the aggregate came from, and an aggregate read from the wrong column produces shortlists that look completely normal.
How it played out
They kept both. The old script stayed exactly as it was. The new one was a copy with three lines changed: the placed roll numbers went into a set before the loops, the branch lists became sets as well, and a dictionary keyed by roll number replaced a scan that fetched a student’s record.
Then they ran both on Tuesday’s data and compared the output files. Not by reading them: by sorting each company’s shortlist and asking Python whether the two lists were equal, company by company. Thirty-nine matched. One did not, and the difference was two students.
Those two turned out to be a real bug in the old script, not the new one. A student who appeared twice in the input, because of a re-registration, had been counted twice in one company’s quota of fifteen, so that shortlist had only fourteen distinct names. The old script had been doing this for three years. Nobody had noticed, because a shortlist of fifteen with a repeated name looks like a shortlist of fifteen.
The new run took fifty-one seconds. Between Wednesday and Friday they ran it fourteen times, including twice on Thursday night after a company changed its cut-off at nine o’clock.
Divya’s note in the file, still there, reads: the sets are not for speed, they are so we can run this as often as the companies change their minds. The speed is what made the comparison possible in the first place: a check that takes half an hour to run is a check nobody runs twice.
Worth arguing about
The fix was one line. Why was the twenty-six minutes not a matter of the laptop being slow?
One answer
The innermost line asked whether a roll number was in a list of four hundred and ten entries. A list membership test compares against each element in turn until it finds a match, so its cost grows with the length of the list, and it was being run about four hundred thousand times. A set answers the same question by hashing the value once and looking in one bucket, at effectively constant cost. The machine was doing tens of millions of comparisons that were never necessary; a faster laptop would have shortened the wait without removing the work.
The duplicate student had been miscounted for three years without anyone noticing. What property of a list allowed that, and what would have prevented it?
One answer
A list keeps duplicates, and keeping them is usually the right behaviour, so nothing about a repeated roll number is an error to Python. The quota counted entries rather than distinct students. Building the shortlist as a set, or checking len(set(roll_numbers)) against len(roll_numbers) before writing the file, would have exposed it immediately, and the second is a one-line assertion worth adding to any list that is supposed to hold distinct keys.
Divya insisted on running both versions and comparing the outputs. Why was that a better answer than reading the new code carefully?
One answer
Reading tells you what you think the code does; comparing outputs tells you what it did on real data. The comparison was cheap to write (sort each company’s shortlist and test the lists for equality) and it was the only thing that could catch a difference the reader had not thought to look for. It also found a defect in the trusted version, which reading the new code could never have done, because nobody was reading the old one.
Test yourself6 questions on this module
After names = names.sort(), printing names gives None. What happened?
grid = [[0, 0], [0, 0]] and copy = grid.copy(). Then copy[0][1] = 9, and grid[0][1] is now 9 as well. Why?
A search returns three results and the code takes results[:10]. A colleague warns it will crash. Who is right?
A loop checks if roll in placed for 900 students, where placed is a list of 400 roll numbers. Converting placed to a set before the loop makes the script forty times faster. What changed?
From a list of 100 marks, [m if m >= 40 else 0 for m in marks] and [m for m in marks if m >= 40] give different results. How do they differ?
A loop written as for row in rows: if row.bad: rows.remove(row) leaves some bad rows behind. Why?
Functions, modules, and code you can read again in March
9 lessons · 1 h 11
You will be able toSplit a working script into functions and modules with explicit arguments, return values and docstrings, explain from Python’s scoping rules why a variable changed inside a function did or did not change outside it, and run the result from a terminal with named options.
- 3.1Functions, and the difference between printing and returning
- 3.2Arguments: positional, named, default, and the trap in the default
- 3.3Where a name lives, and why the function cannot see it
- 3.4Returning: one value, several values, or nothing at all
- 3.5Saying what a function expects, and what checks it
- 3.6Modules: splitting a script across files without breaking it
- 3.7Laying out a project so the imports keep working
- 3.8Side effects, and why some functions are easy to test
- 3.9Giving your script a proper command line
An illustrative case, built from documented patternsSeven hundred lines, eleven questions, and the volunteer who left
Sahyog runs forty learning centres across three districts and reports monthly to the funder: attendance per centre, per class, per gender, with a comparison to the previous month. Until last year this was four days of somebody’s time in a spreadsheet.
Then a volunteer, an engineering student named Ankit, wrote a script. It reads the attendance sheets that field staff send as CSVs, cleans the centre names, computes the tables and writes the report. It reduced four days to about forty minutes, most of which is Ankit’s script asking questions.
Eleven questions, in fact. When you run report.py it asks for the month, the year, the input folder, the output folder, whether to include the new centres, whether to use the corrected September figures, and five more. The answers go into input() calls. Ankit knew all eleven by heart.
Ankit finished his degree in June and took a job in Hyderabad. The person now running the script is Rekha, who manages the programme and does not write code.
In August the funder asked for two changes: split the attendance by class as well as centre, and email the report on the first of each month rather than whenever somebody remembers. Rekha found a volunteer, a second-year student called Farhan, and asked him to do it.
Farhan opened the file. Seven hundred and forty lines, no functions, one long run from top to bottom, with the cleaning, the arithmetic, the formatting and the printing interleaved. Variables called df2, df3 and temp. The correction for the September figures was a block in the middle guarded by an if that read the answer to question seven.
He gave Rekha two estimates. Patching it: about a day, maybe two, for the class split. He could find the place where the totals are computed and add another grouping next to it. The email, he said, was the problem: a script that stops to ask eleven questions cannot be run by a scheduler at two in the morning, so either somebody sits and answers them on the first of every month, or the questions have to go.
Restructuring it: four days. Split the file into functions with arguments and return values, move the eleven questions to command-line options with defaults, put the reading in one module and the arithmetic in another, and write the two or three checks that would tell them whether the new version agreed with the old.
Four days of a volunteer’s time, in the middle of the reporting cycle, produces nothing the funder can see. Rekha had been given the volunteer for six weeks. Spending most of the first week on a report that already worked was a hard thing to justify to her own director, who had asked for the class split and did not care how the file was arranged.
And there was a specific risk she could name. Ankit was gone. If Farhan restructured the script and then his exams started, Sahyog would be holding a half-rearranged program that neither of the two people who understood it was available to finish.
How it played out
Rekha asked for a middle course, and Farhan took it in a particular order that turned out to matter.
First he did nothing to the logic. He wrapped what was already there in functions, one per section, passing values in as arguments and returning results instead of leaving them in module-level variables. Nothing was rewritten; blocks were moved and indented. That took a day and a half, and after each function he ran the script on July’s data and compared the output file with the one that had been sent to the funder. Byte for byte, until it matched.
With the arithmetic sitting in a function called monthly_totals(rows, group_by), the class split was twenty minutes rather than a day.
The eleven questions became six command-line options with defaults and five that were simply deleted, because when Farhan traced them, three had the same answer every month and two controlled a correction for a September that was two years past. The script now runs as report.py --month 2026-08, and the scheduler on the office machine runs that line on the first of every month.
The part Rekha was most glad of came in November, when a field officer sent a file with an extra column and the script failed. The traceback named clean_centre_names, four lines long, and the fix took Farhan eleven minutes from his hostel. In the old file the same failure would have pointed into a seven-hundred-line stretch where every variable was called temp.
The cost was real: five and a half days of a six-week volunteer, and for two of those days the class split the director had asked for did not exist.
Worth arguing about
Farhan wrapped the existing code in functions before changing any of the logic. Why is that ordering worth copying?
One answer
It separates a change whose correctness can be verified mechanically from one that cannot. Moving code into a function with explicit arguments and a return value should not alter the output at all, so the old report and the new one can be compared byte for byte after every step; any difference is a mistake made in the last few minutes and is easy to find. Restructuring and adding a feature at the same time means a difference in the output could come from either, and you no longer have a trustworthy reference to compare against.
Why did eleven input() calls make the emailing requirement impossible, and what did moving them to the command line change?
One answer
input() reads from the terminal and blocks until a person types something. A scheduler starts a process with no terminal attached, so the script either hangs forever or fails at the first prompt. Command-line options carry the same information in the command itself, so the whole run is one line a scheduler can execute, the choices are recorded in the log rather than in somebody’s memory, and defaults mean the common case needs no options at all.
Five of the eleven questions were deleted rather than converted. What does that say about where the questions came from?
One answer
They were decisions frozen into prompts by the person who happened to know the answers. Three had the same answer every month, which means they were not decisions at all but constants written in the wrong place, and two related to a correction that had expired two years earlier. A prompt hides that: as long as somebody types the answer, nobody asks whether the question is still live. Making them explicit options with defaults forced the question, and the answer for five of them was that they should never have been asked.
Test yourself6 questions on this module
def word_count(t): print(len(t.split())). A caller writes n = word_count(text) and then n + 1, which raises TypeError on NoneType. What is the fix?
def add(item, basket=[]) accumulates items across separate calls that pass no basket. Why?
A function prints a module-level count on its first line and assigns count = count + 1 on its last. It raises UnboundLocalError on the print. Why does the assignment on the last line affect the first?
def f(rows): rows.append(1); rows = []: the caller’s list gains the 1 but is not emptied. What single rule explains both halves?
A learner saves a script as json.py in the folder they are working in. Now import json in that folder gives their own file, and code that used the real library breaks. Why?
A monthly report script asks eleven questions with input(). It has to run at two in the morning from a scheduler. What is the problem, and the fix?
When it goes wrong: exceptions, debugging and tests
9 lessons · 1 h 15
You will be able toTake a program that crashes or quietly gives a wrong answer and find the cause from a traceback, a log line or a debugger session, then write a test that fails before the fix and passes after it, including for code that calls a paid API.
- 4.1Reading an error message properly
- 4.2Catching an error, and deciding whether you should
- 4.3Raising your own errors, and choosing the right one
- 4.4Logging: the print statements you can turn off
- 4.5Assertions: stating what must be true, and where they vanish
- 4.6The debugger: stopping the program and looking around
- 4.7Your first test, and what to test first
- 4.8Testing code that calls an API you pay for
- 4.9Debugging with an AI assistant, and what it gets wrong
An illustrative case, built from documented patternsNine days of reminders that nobody received
The clinic sends a reminder message the evening before an appointment. Around ninety a night. The script was written by the receptionist’s nephew, Sameer, who is in his final year of a BCA and comes in on Sundays.
On a Tuesday in March the clinic’s manager, Dr Phadke, noticed that the no-show rate for the week was thirty-one per cent. It had been running at eleven. She checked the log file. Every night for nine days it said the same thing: Reminder job finished. 0 errors.
Sameer found the cause in about an hour. Nine days earlier the message provider had changed the name of one field in its reply, from message_id to id. The script read the old name, which raised a KeyError. And the whole send loop was inside this:
try: ... except: pass
So every message failed, silently, and the loop went on to the next patient. The counter that printed 0 errors was incremented in the except branch of a different try, further down, that had never been reached.
The fix itself was five minutes: read the new field name. Sameer pushed it on the Tuesday evening and the reminders went out that night.
The decision came afterwards, and it was Dr Phadke’s, not his. She wanted to know what would stop the next one. Sameer offered three things and estimated them honestly.
Removing the bare except and catching only what he had a plan for: half an hour. Cheap, and it would have turned the nine silent days into a crash on night one.
Logging properly, with levels, so that a failed send wrote an ERROR line with a traceback rather than nothing: two hours.
Tests: a day, and this was the one he was reluctant about. The part he most wanted to test was the sending, and sending is where the money and the patient data are. A test that really sends is a test that texts ninety people every time somebody runs it.
Against all three sat the clinic’s actual constraint. Sameer is there on Sundays. A day of his time is a month of the clinic’s access to him, and in that month Dr Phadke had also asked for the reminders to go out in Marathi for the patients who prefer it.
She asked him what a day of tests would have caught here. He said, if he were honest: nothing. A test of the message formatting would have passed the whole nine days, because the formatting was fine. What broke was a field name in somebody else’s reply, and the only test that would have seen it is one that calls the real service.
There was one more thing in the log that Dr Phadke wanted explained. The nightly line said Reminder job finished, and it had said that on every one of the nine nights. It was a print statement at the end of the file, and it printed whether or not anything had been sent, because nothing between it and the loop could stop the program from reaching it. She had been reading it as confirmation for two months.
Sameer’s own view of the bare except was worth recording, because it is the reason the pattern exists at all. He had put it there deliberately, in the first week, after a single bad phone number in the patient list stopped the whole run at patient nineteen and seventy people got no reminder. Catching everything and continuing had genuinely fixed that problem. It had also, in fixing it, thrown away the difference between one bad number and every message failing.
What he wanted, and did not know how to ask for at the time, was to continue past the failures he expected and stop on the ones he did not.
How it played out
They did the half hour first, that Sunday. The bare except became except MessagingError as err, and everything else was allowed to reach the top and stop the program. The next Sunday he added logging: log.exception on a failed send, log.info per patient with a message id and no phone number, and a final line with counts of sent and failed.
The day of tests was not spent. What Sameer wrote instead, in about ninety minutes, was two things.
The first was a test of the parsing, with a saved copy of a real reply from the provider stored in the repository as a file. It does not touch the network. It asserts that the function returns a string id and that it raises a named error when the field is missing. That test would have failed the moment the provider changed the field, if anyone had run it, which is why the second thing mattered more.
The second was a check at the end of the nightly run: if the number of successful sends is less than eighty per cent of the number of appointments, exit with a non-zero status and write a line at CRITICAL. The scheduler was already set up to email Dr Phadke when a job exits non-zero, because that was how the backup script had always worked.
In July the provider changed something else: a rate limit, applied without notice, that started rejecting the last thirty messages of each night. The clinic knew the same evening. Not from the test, which passed, but from the count.
The no-show rate for the nine days cost the clinic about forty appointments. Dr Phadke’s summary, written in the file above the send loop and still there, is: an error we cannot see is more expensive than one that stops the program.
Worth arguing about
The log said 0 errors for nine nights. What made that worse than a program that crashed on the first night?
One answer
A crash announces itself and costs one night. A silent failure looks like success, so the clinic kept trusting a job that had stopped working and only found out through the no-show rate, nine days and about forty appointments later. The bare except caught every exception, including the KeyError that meant the send had failed, and pass discarded it; the counter it printed came from a branch that never ran. Catch only the exceptions you have a plan for, and let the rest stop the program.
Sameer admitted that a day of unit tests would not have caught this bug. Why, and what did catch it in July?
One answer
The failure was not in the clinic’s logic but in the shape of another service’s reply, and a unit test runs against the clinic’s own code with data the author wrote, so it would have kept passing. What caught the July failure was a check on the outcome of the real run: comparing successful sends against appointments and exiting non-zero when the ratio dropped. Tests protect you against your own changes; a monitored invariant protects you against everyone else’s.
The test of the parsing does not call the provider. What does it assert, and why is that still worth ninety minutes?
One answer
It runs against a saved copy of a real reply and asserts the contract the code depends on: that the id field exists, that it is a string, and that a missing field raises a specific named error rather than a KeyError from deep inside a loop. That is cheap, fast and free to run, and it means a future change to the parsing cannot quietly reintroduce the same failure. It does not tell you the provider has changed its reply (nothing offline can) which is exactly why the runtime count exists as well.
Test yourself6 questions on this module
A traceback ends with KeyError: ‘rent’ and has four blocks above it. Where do you look first?
A nightly job wraps its send loop in try / except: pass. Messages have failed for nine days and the log still says zero errors. Beyond hiding this bug, what else does a bare except: break?
A web handler protects an admin action with assert user.is_admin. Why is that a security hole rather than a style problem?
Why is log.debug(“row %s of %s”, i, total) preferred over log.debug(f”row {i} of {total}”)?
You are testing a function that asks a model to summarise a paragraph. Which assertion is worth writing?
assert len(rows) == expected, “row count changed” was written instead as assert (len(rows) == expected, “row count changed”). What does the second one do?
Data in and out: files, formats and the environment
10 lessons · 1 h 20
You will be able toRead and write CSV, JSON and text files using paths that work regardless of where the program was started, handle non-English text without corrupting it, process a file larger than memory, and keep an API key out of your source and out of version control.
- 5.1Files, and keeping data after the program ends
- 5.2Paths: why your program cannot find a file that is right there
- 5.3Text that survives: encodings, and why the accents turned into question marks
- 5.4CSV: the format everybody sends you and nobody agrees on
- 5.5JSON: the format every API speaks
- 5.6Dates and times, and the hour that does not exist
- 5.7Files larger than memory
- 5.8Installing packages, and why virtual environments exist
- 5.9Pinning dependencies, so it still runs next year
- 5.10API keys: out of the code, out of the repository
An illustrative case, built from documented patternsThe names that arrived as question marks
Every month the block education office collects a return from ninety-one schools: enrolment, meals served, days the kitchen ran, and the names of the cook-cum-helpers who are to be paid. The schools send spreadsheets. The office combines them into one file and uploads it to the state portal.
Biraj, a data entry operator who taught himself Python from videos, wrote the combining script in January and it saved the office three days a month. In May the portal started rejecting the file.
The error from the portal said only: invalid characters in row 214. Biraj opened the combined CSV in Excel and the names in Assamese script were question marks. Not all of them. The first forty-odd rows were fine. Some of the rest were question marks and some were sequences like রাজ.
He spent a day and a half on it, and what he eventually found was that the office was handling three different things at once, all called text.
One group of schools sends .xlsx files. Those are read by a library and arrive as proper Unicode; they were the rows that were fine.
A second group exports CSV from a much older machine, where the school clerk saves from a version of Excel that writes in the local Windows code page. Those files could not represent the Assamese characters at all, so Excel had substituted question marks before the file ever left the school. Those bytes were gone. Nothing in Biraj’s script could recover them.
A third group sends CSVs that are genuinely UTF-8. Biraj’s script opened every CSV with a plain open() and no encoding argument, and on the office’s Windows machine that meant the code page again. Those names were being read wrongly on his side. The bytes were intact; only the label was wrong.
So he had two problems that looked identical on screen and were not the same at all: one where the data was destroyed at the source, and one where he was mislabelling data that was fine.
The decision was what to do about the first group, and it had a cost either way.
He could write the combined file as UTF-8 with a byte order mark, which is what makes Excel on Windows open it correctly by double-clicking. That would help the schools, who check the combined file and complain when names look wrong. But the state portal is a program, not Excel, and Biraj did not know whether it would treat those three extra bytes at the start as part of the first column name.
Or he could write plain UTF-8, which the portal would certainly accept, and accept that every school that opens the file to check it sees mangled names and telephones the office about it. The office has two phone lines and a fortnight of the month when nobody can use them anyway.
The deeper choice was whether to fix the twenty-two schools at the source, which meant a circular to clerks with the words save as CSV UTF-8 in it, and a follow-up call to each, and a month in which some of them get it wrong.
How it played out
Biraj did three things and told the block officer that only the third was a fix.
He added encoding=”utf-8” to every open() in the script, which corrected the third group immediately: those names had been intact all along.
He wrote two output files rather than one. The upload file is plain UTF-8, with no byte order mark, and goes to the portal. A second file, with utf-8-sig, goes to the schools as the copy to check, and opens correctly in Excel with a double click. Two lines of code, and the phone calls stopped.
For the twenty-two schools whose names had already been destroyed, nothing in code could help. He added a check instead: any name containing a question mark is written to a separate list with the school code, and that list goes back to the school with the return. Sixty-one names in the first month. Nineteen in the third.
The circular went out with a screenshot of the Save as dialog and the exact item to choose. Fourteen of the twenty-two changed after the first month; four needed a phone call; two are on machines where the option does not exist, and those two schools now send .xlsx, which the script already handles.
One thing surprised him. After the change, one school’s returns started failing a name comparison against the previous month’s file, for names that looked identical on screen. They were composed differently: the same Assamese text with a vowel sign stored as one code point in one file and as two in the other. He normalised both sides on the way in, which is one line, and it has not recurred.
Worth arguing about
Two groups of files looked equally broken on screen. What was the real difference between them, and how would you tell them apart in Python?
One answer
One group had been damaged at the source: the characters were replaced by question marks when the file was written in an encoding that could not represent them, so the original bytes no longer exist and no code can recover them. The other group was intact UTF-8 being read with the wrong encoding, which produces mojibake and is fully reversible. The test is to try text.encode(“latin-1”).decode(“utf-8”) on a mangled string: if sense comes out, the bytes are fine and only the label was wrong; a literal question mark character tells you the loss already happened upstream.
Why did adding encoding=”utf-8” fix one group and change nothing for another?
One answer
Without the argument, Python uses the platform’s default encoding, which on that Windows machine was a local code page rather than UTF-8, so genuinely UTF-8 files were decoded wrongly. Naming the encoding removed that whole class of failure for files that were correct on disk. It could do nothing for the files whose characters had already been replaced by question marks before they reached the office, because there is nothing left in those bytes to decode.
He wrote two output files instead of choosing between UTF-8 and utf-8-sig. What is the trade-off he was avoiding, and what does the byte order mark actually do?
One answer
utf-8-sig writes three extra bytes at the start of the file, which Excel on Windows uses as a signal to open it as UTF-8 rather than as the local code page; without them the schools see mangled names when they double-click the file. But those same bytes can appear glued to the first column name when another program reads the file expecting plain UTF-8, which risks the portal rejecting it or silently misreading a header. The two audiences want different files, so writing both is cheaper and safer than picking one and hoping.
Test yourself6 questions on this module
A logging script opens its file with mode “w” each time it runs. After a week the file holds only the last run’s lines. Why?
python3 tools/clean.py works from the project root and raises FileNotFoundError when run from inside tools/. The data file has not moved. What is the reliable fix?
A colleague’s file shows names as नमस on your machine. Another shows them as ???. Which is recoverable and why?
Reading a CSV with csv.DictReader, float(row[“amount”]) raises ValueError on some rows. The file looks fine in a spreadsheet. What is the likely cause?
json.dumps raises TypeError: Object of type datetime is not JSON serializable. What does that tell you about JSON?
pip install requests reported success, and the script still fails with ModuleNotFoundError: No module named ‘requests’. What is the first thing to check?
Talking to a service: HTTP from Python, done properly
11 lessons · 1 h 35
You will be able toWrite a client for a paid model API that reuses connections, times out, retries only what is safe to retry, walks pagination with a generator, consumes a streamed response as it arrives, runs many requests concurrently under a rate limit, validates the JSON the model returns before trusting it, and reports the cost of every call from the usage figures in the response.
- 6.1Calling an API for the first time
- 6.2What a request is made of, and how to see the one you actually sent
- 6.3Sessions: paying for the handshake once instead of every call
- 6.4Timeouts and retries: the code that decides whether you pay twice
- 6.5Pagination: walking a result that arrives in pages
- 6.6A provider’s SDK: what it does for you, what it hides, and how to read it
- 6.7Consuming a streamed reply: server-sent events, line by line
- 6.8asyncio: twenty calls in the time of one
- 6.9Threads, processes, and the lock that decides which one you need
- 6.10Validating what the model returns: from a string that looks like JSON to an object you can trust
- 6.11Knowing what each call cost: usage figures, token counting, and a budget that stops the program
An illustrative case, built from documented patternsThe retry that billed twice
Padhaai Labs sells a writing-feedback tool to twelve schools. Every night it sends the day’s essays to a model API, which returns a set of tags and a short comment for the teacher. About forty thousand essays a month, at roughly two paise each in model cost, which is a bill the company can carry.
The job used to fail about once a week. A connection would drop somewhere between Indore and the provider’s servers, one call would raise, and the whole run would stop with two-thirds of the essays untagged. The next morning somebody would restart it and the teachers’ comments arrived at lunchtime instead of before first period.
So Anuj, who wrote it, added retries. He used the Retry object that comes with urllib3, set five attempts with exponential backoff, and (because the calls that were failing were POSTs, and retrying only GETs would have fixed nothing) added POST to allowed_methods. The weekly failure stopped. Nobody thought about it again for four months.
The bill for March was 2.7 times February’s. The volume of essays had gone up by about eight per cent.
It took Anuj most of a day to find it, because the tagged output looked correct. Every essay had exactly one set of tags in the database, because the database write was keyed on the essay id and the second write replaced the first. The evidence was only in the provider’s usage export: on some nights, several thousand calls more than there were essays.
The mechanism turned out to be the read timeout. He had set timeout=20 for the whole call. Most replies came back in three or four seconds, but a long essay with a slow model could take twenty-five. When that happened, the request had been received, the model had generated the tags, the provider had billed for the tokens, and the reply was still on its way when Python gave up waiting. The retry then sent the same essay again. Sometimes twice. Once, on a bad night in the second week of March, five times for the same essay.
He now had a decision, and both sides of it cost money.
He could stop retrying POSTs. That is the correct default and it is why urllib3 excludes them. The cost is the thing he had fixed four months earlier: the job goes back to dying on a dropped connection, at two in the morning, and the teachers get their comments at lunchtime, which is the complaint that started all of this.
He could keep retrying and raise the read timeout to ninety seconds, so that a slow reply is waited for rather than abandoned. That removes most of the double billing but not the case where the connection genuinely drops after the server has done the work, and it makes a truly hung call cost ninety seconds of the run rather than twenty.
The third option was the one the provider’s documentation had been suggesting all along and which he had not read: an idempotency key. You generate an identifier for each logical request, send it on every attempt, and the server returns the first result rather than doing the work again. The cost there is that not every endpoint he uses supports it, and he would have to check.
How it played out
He did all three, in an order chosen by what each cost to write.
The timeouts were separated the same afternoon: timeout=(5, 90). A connection that does not open in five seconds is not going to, and a model given a two-thousand-word essay is allowed ninety.
POST came out of allowed_methods. In its place he wrote about fifteen lines of his own retry loop that distinguishes the two failures the module distinguishes: a connect failure or a 429 or a 503 is retried with backoff and jitter, and a read timeout on a POST is not retried at all. It is logged at ERROR with the essay id and left for the morning.
The number of essays that reach the morning list is between two and nine a night out of about thirteen hundred. Those are re-run by a second job at six, before school.
The idempotency key went in six weeks later, when he had read the provider’s page properly. The endpoint they use does support it. With it in place he was able to put POST back into the retried set for that endpoint, and the morning list mostly emptied.
April’s bill was eleven per cent below February’s, on nine per cent more essays, because the retry storms had been quietly present at a smaller scale for the whole four months.
One further change came out of the day he spent looking: the job now logs the usage figures from every response and keeps a running total, and it stops with a non-zero exit if the night’s spend passes a ceiling. That ceiling has been hit once, during an unrelated bug that sent the same batch twice, and it stopped the run after about seventy rupees rather than after all of them.
Worth arguing about
Why did a read timeout on a POST cost money in a way a connect timeout never does?
One answer
A connect timeout means the connection was never established, so the request did not reach the server and nothing was done or billed; retrying it is free and safe. A read timeout means the request was delivered and the client gave up waiting for the reply, so the server may have completed the work (generating the tokens and billing for them) and only the response was lost. Retrying that sends the same work again, and the client cannot tell the difference from the outside.
The tagged output in the database was correct throughout. Why was that a problem rather than a comfort?
One answer
Writes were keyed on the essay id, so the duplicate result simply replaced the first and the visible output stayed consistent. The failure was therefore invisible in every place the team looked (the data, the logs, the teachers’ reports) and only appeared in the provider’s usage export at the end of the month. A failure that cannot be seen from the outputs needs an independent measurement, which is why he ended up logging the usage figures from every response and totalling them per run.
An idempotency key let Anuj put POST back in the retried set. What does the key actually do, and why is it not simply a better retry?
One answer
The client generates one identifier per logical request and sends it with every attempt; the server recognises a repeat and returns the stored result of the first attempt instead of doing the work again. That moves the decision to the only place that can know whether the work happened. It is not a better retry because it depends entirely on the server supporting it for that endpoint: without server-side support the header is ignored, the work is repeated, and the retry is exactly as unsafe as before.
Test yourself6 questions on this module
A model API call returns status 200. What has that established?
Moving a loop of two hundred calls from requests.get() to one Session cuts the run from ninety seconds to under forty. What did the Session remove?
A POST to a paid model API times out while waiting for the reply. Why is retrying it different from retrying a failed connection?
A nightly export walks a list endpoint with page=1, page=2 and so on, and occasionally misses records. New records arrive while it runs. Why does a cursor fix this?
Twenty API calls are gathered with asyncio, and inside each coroutine there is a requests.get(). The run still takes twenty seconds. Why?
A team estimates cost with the rule of about four characters per token. Their bills for Hindi prompts come in far higher than the estimate. What is the mechanism?
Objects, generators and the shape a larger program takes
10 lessons · 1 h 28
You will be able toRestructure a growing script into classes and dataclasses with a swappable provider behind one interface, implement the dunder methods that let your objects work with for, len, in and with, write a decorator that retries or caches, run a type checker and act on its output, make the project installable as a command with pyproject.toml, and profile it to say from measurement rather than guesswork where the time goes.
- 7.1Classes: when a dict stops being enough
- 7.2Dataclasses: the class that is mostly data, written in four lines
- 7.3Inheritance and composition: swapping one model provider for another
- 7.4Dunder methods: making your own objects work with len, for, in and ==
- 7.5The iterator protocol: what for actually does, and why a generator is empty the second time
- 7.6Context managers: the cleanup that runs even when the block raises
- 7.7Decorators: wrapping a function with retry, timing or a cache
- 7.8Running a type checker: the bugs it finds that tests do not
- 7.9pyproject.toml: making your project installable, and a command somebody can type
- 7.10Profiling: finding where the time actually goes before you optimise anything
An illustrative case, built from documented patternsTwo days of structure, or one afternoon of find and replace
Khula Khata publishes a weekly digest of tenders issued by four municipal corporations. A scraper collects the PDFs, a model summarises each one in about eighty words, and a volunteer editor checks the summaries before they go out. Roughly six hundred documents a week.
In February their model provider changed its pricing, and the summarising step went from about nine hundred rupees a week to about three thousand two hundred. For a group funded by two small grants, that was the difference between publishing weekly and publishing when they could.
The obvious response was to move to a cheaper model. There were three candidates: a smaller model from the same provider, a model from a different provider with a different reply format, and an open model they could run themselves on a rented machine for a fixed monthly cost.
The problem was the code. The provider’s client object appeared in eleven places across four files. Two of those places unpacked the reply structure by hand. One built a request with a parameter that only that provider accepts. The team’s developer, Meghna, described it accurately: the program did not call a model, it called that company.
She put two options to the other two.
The first was an afternoon. Find and replace the client, fix whatever breaks, run the pipeline on last week’s documents and read twenty summaries. If the new provider works out, they have saved two thousand rupees a week by Friday. If it does not, they do the afternoon again for the next candidate.
The second was two days. Define one small class with one method (take the messages and the system prompt, return a string) and write a subclass for each provider that absorbs the differences: the argument shapes, the reply structures, the error types. Then the rest of the program holds a provider object and never knows which one it is. Two days in which nothing is published and no money is saved, in exchange for being able to try the third candidate in twenty minutes rather than an afternoon.
The argument against the two days was not only time. The editor, Farid, had watched a previous volunteer spend a fortnight building an abstraction over three databases they turned out never to need. His question was fair: are we writing a class because we will change providers again, or because writing classes feels like progress?
Meghna’s answer was that they were going to change providers at least twice this month, because they had three candidates and no way to compare them except by running the real pipeline on real documents.
There was one more thing pushing at the decision. Nobody in the group could say what the pipeline actually cost per document, because the summarising and the PDF text extraction and the scraping all ran in one script and the whole thing took about forty minutes. Meghna assumed the extraction was the slow part, because PDFs are famously slow.
There was also a smaller mess she had inherited and never mentioned. The reply from the provider was unpacked by hand in two of the eleven places, with different assumptions in each: one took the first element of the content list, the other joined every element. For most tenders those give the same string. For the four or five a week where the model returns a second block, they had been giving different summaries for months, and nobody had matched them up because the two paths ran on different days of the week.
Meghna’s own instinct, before the stopwatch, had been to leave the structure alone and cache the summaries instead. A tender document does not change once issued, so a re-run should not pay twice. That instinct was right and stayed on the list; it simply was not the thing standing between them and a cheaper provider this week.
How it played out
She spent the first two hours not writing the class. She put a stopwatch around the three stages and ran the pipeline once. Extraction was four minutes. Scraping was three. The model calls were thirty-one, and cProfile put ninety-two per cent of the total inside the HTTP library, which is to say: waiting.
That changed the shape of the work. Whatever provider they chose, the pipeline was going to be dominated by network waiting, so the calls had to be overlapped. That is a change to how the model is called, and making it in eleven places was not sensible.
So the class was written, and it took a day and a half rather than two days: a base with one method, three subclasses, and a fake one that returns a canned string for the tests. The eleven call sites became one. The overlap (twenty at a time, capped by a semaphore) was written once, inside the object that holds the provider.
They then tried all three candidates in one afternoon, on the same forty documents, and read the summaries side by side. The cheapest model was noticeably worse at Marathi place names and dropped the tender value from about one summary in six, which no price makes acceptable in a document about public money. The mid-priced model from the other provider was as good as the original at about a third of the cost. The self-hosted open model was close behind and cheaper still at their volume, but the machine needed watching and none of the three of them wanted to be the person watching it.
They took the mid-priced one. The pipeline now runs in nine minutes rather than forty, which was an accident of the concurrency work rather than the point.
Farid’s objection was recorded in the repository’s README and has been useful since: one interface, because we change providers; no interface for anything we have changed once.
Worth arguing about
Meghna spent the first two hours measuring rather than writing code. What did the measurement change?
One answer
It contradicted the assumption that PDF extraction was the bottleneck: extraction was four minutes and the model calls were thirty-one, with over ninety per cent of the run inside the HTTP library, waiting. That turned the job from an optimisation of the extraction step into a concurrency change around the model calls, and a change to how the model is called was a much stronger argument for putting the call in one place than the pricing question had been on its own.
Farid’s objection was that abstractions get written because they feel like progress. What made this one different from the abstraction over three databases?
One answer
The test is whether the second implementation exists and is needed now. They had three candidate providers and no way to choose between them except by running the real pipeline on real documents, so the interface was going to be exercised twice within the month, not hypothetically some day. Their rule afterwards states it: build an interface where you already change things, and not where you have changed something once.
The base class had exactly one method. Why is a one-method interface a strength here rather than a sign of a half-finished design?
One answer
It names the only thing the rest of the program actually needs (hand over messages and a system prompt, get back a string) so every difference between providers is absorbed inside a subclass and nothing leaks out. A wider interface would have forced each subclass to implement things some providers do differently or not at all, and it would have made the fake provider used in tests harder to write. The fake being trivial is the practical proof that the interface is the right size.
Test yourself6 questions on this module
A class writes messages = [] in the class body rather than self.messages = [] in __init__. Two instances then share one list. Why?
A dataclass field written as items: list = [] raises ValueError at class definition. What is Python protecting you from, and what is the fix?
rows = (line for line in f if line.strip()). A function counts them, then a second loop over rows prints nothing and raises no error. Why?
After adding __eq__ to a small class, putting instances in a set raises TypeError: unhashable type. What happened?
@timed and @retry(3) are stacked on one function, with @timed written above. What does the log measure?
In a context manager written with @contextlib.contextmanager, where does the cleanup go?
Numbers in bulk: NumPy, pandas and the vector under every model
10 lessons · 1 h 27
You will be able toLoad a messy CSV into pandas, clean its missing values and types, group and join it into the table a question needs, convert it to a NumPy array of the right shape and dtype, compute cosine similarity between one vector and a hundred thousand in a single vectorised expression, plot the result to a file, and explain from memory layout why the array version runs a hundred times faster than the loop it replaced.
- 8.1NumPy arrays: why one line beats a loop by a hundred times
- 8.2Shapes, axes and broadcasting: the wrong-shape bug that runs without complaint
- 8.3dtypes: what float16 costs, why int8 wraps round, and how to compare floats
- 8.4Masks, where, and the slice that is not a copy
- 8.5Dot products and cosine similarity: one vector against a hundred thousand in a single line
- 8.6pandas: a table with named columns, and the four things to look at first
- 8.7Cleaning a table: missing values, the string that is not missing, and duplicates that are not identical
- 8.8groupby, merge and pivot: getting from rows to the table a question needs
- 8.9matplotlib: a plot you can read, saved to a file, from a script or a server
An illustrative case, built from documented patternsFour thousand two hundred farmers, and a join that grew the table
The cooperative collects milk twice a day from 4,217 members across thirty-one villages. Payment is monthly and works from two files: the collection records, one row per member per session, and a member master with the bank account, the rate category and any deductions for cattle feed taken on credit.
For eleven years this was done in a spreadsheet by a clerk named Sunil, who is retiring. His successor, Pooja, has a BSc in statistics and had spent three weeks moving it into a pandas script that reads both files, groups the collections by member and month, joins the member master, applies the rate and the deductions, and writes the file the bank uploads.
On the morning of the run she did the check she had been taught to do: the number of rows before a join and after it. The grouped collections had 4,217 rows, one per member. After merging the member master it had 4,261.
Forty-four extra rows. The total to be paid had gone up by about ninety-one thousand rupees.
The cause took her an hour. Forty-one members appeared twice in the member master and one appeared four times, because when a member changes bank account the cooperative’s old software adds a row rather than replacing one. The merge matched each collection row against every matching master row, which is what a join on a repeated key does, and produced a payment line for each. Two of the duplicates had different rate categories, because a member had moved from one category to another in 2023 and both rows survived.
She could see three ways forward and the deadline was one o’clock, when the file has to reach the bank for same-day credit.
The first was to deduplicate the master by member id, keeping the most recent row, and run. Fifteen minutes. The risk is the account number: if the most recent row is the stale one, and she did not know how the old software ordered its rows, forty-one members are paid into closed accounts, which the bank returns three days later and which each require a phone call and a visit.
The second was to fall back to the spreadsheet for this month, which Sunil could still do, and fix the script properly in June. The cost is a day of a retired man’s time, an admission on her third week that the new system is not ready, and the same forty-four rows waiting in June.
The third was to hold the whole run until the duplicates were checked against the branch, which would be tomorrow at the earliest. Four thousand two hundred families paid a day late in a month when the school fees fall due.
The check that caught it was not something Pooja invented on the day. Sunil had done the same thing in the spreadsheet for eleven years, in his own way: the total on the payment sheet had to match a figure he wrote in pencil at the bottom of the collection register before he started. He could not have said which pandas function corresponds to that, and it is the same invariant.
What had made her nervous all week was a different number. The collections file for the month had 253,014 rows, and pandas had read the member id column as an integer in one file and as a string with a leading zero in the other, so an earlier draft of the script had merged on nothing at all and produced a payment file with 4,217 rows and every amount zero. That failure was loud. It was the reason she had started printing row counts and sums between every step, which is why she was in the habit that morning.
How it played out
She took a fourth option that took forty minutes and was uglier than any of the three.
She split the master into the forty-two members with duplicate rows and the 4,175 without. The clean ones went through the script as written. For the forty-two she printed the rows to a sheet of paper and walked it to the branch secretary, who knew twenty-nine of them by name and confirmed the current account for each from the passbook copies in the file cabinet. The remaining thirteen were paid on the account the bank had accepted last month, which she got from March’s uploaded file rather than from the master.
The file went at 12:40. Nothing bounced.
What she wrote afterwards is the part the cooperative kept. The script now asserts, immediately after every merge, that the row count has not changed, and stops with a message naming the duplicated ids if it has. It also asserts that the sum of the payment column matches the sum computed from the grouped collections before the join, to the paisa. Both are one line each and neither has fired since, except once, in September, when a village’s collections had been entered twice on a Sunday and the row count check caught it before anything reached the bank.
The duplicate rows in the master were not fixed in code. Pooja wrote a monthly report of members with more than one active row and gave it to the branch secretary, who now closes them at source. It was down to nine by August.
Her note in the file: a join is the only line in this script that can invent money, and it does it without an error message.
Worth arguing about
Why did the merge produce more rows than it started with, and why is that not a bug in pandas?
One answer
A merge matches every row on the left against every row on the right that shares the key, so a key appearing twice on the right produces two output rows for one input row. That is the defined and usually desired behaviour of a join. It becomes a defect only when the right-hand table is assumed to hold one row per key and does not, which is exactly why comparing the row count before and after the join is the check worth writing every time.
Deduplicating the master by keeping the most recent row would have taken fifteen minutes. What was the risk Pooja could not eliminate?
One answer
She did not know that the newest row held the current account. The old software appended a row whenever anything changed, so the order in the file reflected when a row was written and not which account was in use, and for two members the duplicate rows also disagreed about the rate category. Choosing the most recent would have been a guess dressed as a rule, and being wrong meant forty-one payments into closed accounts, three days of bank returns and a phone call each.
The two assertions she added are one line each. Why is a row-count check more valuable here than reading the output file carefully?
One answer
The wrong output was entirely plausible: 4,261 payment lines with correct-looking amounts, forty-four of which were duplicates spread through four thousand rows. No amount of reading finds that reliably under deadline. The invariant states a relationship the data must satisfy (one payment line per member, and a total equal to the total computed before the join) and it is checked mechanically on every run, including the runs nobody is watching. It caught a completely different fault in September for the same reason.
Test yourself6 questions on this module
Squaring a million numbers takes about 600 ms as a Python list comprehension and about 5 ms as arr * arr. What is the mechanism?
scores has shape (1000,) and weights has shape (1000, 1). scores * weights produces an array of a million elements instead of a thousand, and raises nothing. Why?
m has shape (500, 8) and you want the mean of each column. Which call gives eight numbers?
top = scores[:100] then top *= 2, and the first hundred entries of scores have doubled as well. Why does the same code on a Python list behave differently?
A tensor of logits stored as float16 produces inf after np.exp, and then nan. What is the mechanism?
A payments table of 4,217 rows becomes 4,261 rows after merging a member master. What has happened, and what is the one-line guard?
Building the thing: a small AI program, end to end
10 lessons · 1 h 28
You will be able toAssemble a complete tool from the course’s parts (a chat loop with bounded history, injection-resistant prompt templates, a sqlite response cache, a document chunker, a local embedding search over your own notes, a free local model as the fallback provider) expose it through a Gradio interface and a FastAPI endpoint, run it on a schedule without double-processing, and gate every change behind a GitHub Actions job that runs the tests.
- 9.1A chat loop with memory: keeping history, trimming it, and stopping cleanly
- 9.2Prompt templates: building a prompt from parts without letting the parts rewrite it
- 9.3A response cache on sqlite: the re-run that costs nothing
- 9.4Chunking: splitting a document that does not fit, without cutting a sentence in half
- 9.5Search over your own notes: chunks, embeddings, cosine, and sixty lines
- 9.6A free model on your own machine, called from Python
- 9.7A web interface in twenty lines: Gradio, and what launch() actually starts
- 9.8An HTTP endpoint with FastAPI: your function, callable by any program
- 9.9Running it every morning: cron, a lock file, and a job that is safe to run twice
- 9.10GitHub Actions: the tests run on a machine that is not yours, before anything ships
An illustrative case, built from documented patternsThree thousand pages of circulars, and where the embeddings go
The helpdesk sees about sixty people a week: pension arrears, land mutation, ration card exclusions, school admission under the reserved quota. Two paralegals answer most of it from experience. The experience is the problem: one of them, Shalini, is leaving in October, and what she knows sits in eleven years of handwritten case notes, typed up by a volunteer into about three thousand pages of documents, and in a folder of state circulars nobody has indexed.
A volunteer developer, Arif, offered to build a search. Not a chatbot for the public (the helpdesk’s lawyer was firm about that) but a tool the paralegals use, which finds the three or four passages most likely to answer a question and shows them with the file and the page they came from.
He had the pieces. Chunk the documents, embed each chunk, save the matrix, embed the question, take the top few by cosine similarity, show them. Sixty lines, most of which he had written before.
The decision was which embedding model, and it was not a technical preference.
A hosted embedding model would cost about four hundred rupees to index everything once and almost nothing per query. It is measurably better on Hindi, and about a third of the notes are in Hindi and a good many are in both languages in the same paragraph. It also means every case note leaves the building: names, addresses, the details of a woman’s dispute with her brother-in-law over a plot of land, sent to a company in another country under terms nobody at the helpdesk has read.
A free model running on the office laptop costs nothing and nothing leaves the room. It is slower to index (Arif estimated two hours for three thousand pages on that machine), and the small English-first models are noticeably weaker on Hindi. There are multilingual open models that do better, at three times the memory, on a laptop with eight gigabytes that also runs the office’s accounting software.
The lawyer, Mr Verma, asked the question that framed it: if a client asked us where their case note is stored, what is the true answer?
Against that, Shalini’s point was equally concrete. A search that misses the Hindi notes is a search that misses her Hindi notes, which are most of the ones about pension arrears, which is the largest single thing the helpdesk does. A tool that works well in English and poorly in Hindi will be used for a month and abandoned, and she leaves in October.
Arif had one more constraint that shaped the build more than either model did. The office laptop is shared: the accounting software runs on it every afternoon, the machine is switched off at night by whoever leaves last, and there is no server anywhere in the building. Anything that had to stay running was not going to survive contact with that room.
So the tool is a script the paralegals start from a shortcut, which loads the matrix and the notes into memory in about four seconds and opens a small local web page. Nothing runs when nobody is using it. The monthly re-index is a scheduled job that checks for a lock file first, because the first week Arif ran it, two copies started at once (the scheduler and a paralegal who had double-clicked the shortcut) and the two of them wrote a half-finished matrix over a good one.
He restored it from the previous month’s copy, which existed only because he had been lazy and never deleted it, and then wrote the four lines that make the job refuse to run alongside itself.
How it played out
They used the local multilingual model, and the deciding factor was not the argument about privacy in principle. It was that the notes contain names and Aadhaar numbers that nobody had time to redact, and redacting three thousand pages to make a hosted service acceptable was more work than tolerating a slower index.
The indexing ran overnight rather than in two hours, because the laptop is not fast, and it is re-run monthly by a scheduled job that only embeds files whose content hash has changed. Arif cached the embeddings in a sqlite file keyed on that hash, which is the same cache pattern he had used for model replies, and the monthly re-index now takes about four minutes.
They measured it rather than arguing about it. Shalini wrote thirty questions she knew the answers to (twelve in Hindi, eight in English, ten mixed) and wrote down which document should come back for each. The multilingual model returned the right document in the top three for twenty-four of the thirty. The small English-first model, tried on the same thirty, managed eleven, and failed almost entirely on the Hindi questions. That measurement took an afternoon and settled a question two people had been having opinions about for a fortnight.
The six failures were instructive. Four were chunking: a circular’s operative sentence sat at a page boundary and had been split down the middle. Overlapping the chunks by fifty tokens fixed three of them.
The helpdesk added a rule of its own, which Arif built in an hour: every result shows the file name and page, and the tool never paraphrases. A paralegal reads the passage. Mr Verma’s position was that advice given to a client has to be traceable to a document somebody can put in front of a magistrate, and a summary that dissolves the source is worse than useless in that room.
Shalini left in October. The helpdesk answered nine pension arrears questions in November from circulars she had never mentioned to anyone.
Worth arguing about
The hosted model was better at Hindi and cheap. What made the local one the right choice here, and what would have had to be true for the answer to flip?
One answer
The documents contain client names and identity numbers, and using a hosted service would have meant sending them to a third party the helpdesk had no agreement with. The decisive point was cost of remedy rather than principle: redacting three thousand pages was more work than tolerating a slower local index. The answer would flip if the corpus were already public (the circulars alone, for instance) or if redaction were cheap, because then the quality advantage would cost nothing that mattered.
Shalini’s thirty questions took an afternoon. Why was that a better use of the time than continuing the discussion about which model was better at Hindi?
One answer
It replaced two opinions with a number on the corpus that actually matters. Twenty-four of thirty against eleven of thirty is not a close call, and no amount of general knowledge about multilingual models would have produced it for these documents, in this mixture of languages, with these questions. It also produced the six failures, which turned out to be mostly a chunking problem rather than a model problem: something no amount of model comparison would have revealed.
The index is re-run monthly, but the query model is never changed. Why does that rule matter more than it looks?
One answer
The stored matrix and the query vector have to come from the same model, because two models place their vectors in unrelated spaces even at the same number of dimensions. If the query model changed, the dot products would still compute, the scores would still look like plausible numbers between zero and one, and the results would be nonsense with no error anywhere. Any change of embedding model means re-indexing everything, which is why it is a rule and not a preference.
Test yourself6 questions on this module
In a chat loop, the input tokens billed on turn forty are far higher than on turn one, with the same length of question. Why?
A disk cache keys entries on json.dumps of the request. Identical requests keep missing the cache between runs. Which detail is most likely wrong?
Why do adjacent chunks of a document overlap by a sentence or two before being embedded?
A notes search is re-indexed with a newer embedding model, but the query is still embedded with the old one. What happens?
A Gradio chat function is turned into a generator to stream the reply. What should each yield produce?
A job runs perfectly by hand and fails under cron with a missing API key, although the key is exported in the shell profile. Why?
What this course is
Takes someone who has never written a line of code to the point of calling an AI API from their own Python script. Every lesson has code you can paste into a terminal and run, and every lesson ends with a question about what the code actually does. It covers what a program is, variables and types, strings and input, lists and dicts, loops, functions, reading error messages properly, files, packages and virtual environments, and a first real HTTP request. No prior programming, no maths beyond addition, no assumptions about which country you learned in.
Who wrote it
Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.
More in Build with it
- Building with AIFrom your first API call to a feature you can trust
- Agents and AutomationWhat an agent actually is, and when you should build a script instead.
- Shipping ItGet what you built in front of real people, and keep it alive.
- Building Apps with AIBuild working software before you can write it, without fooling yourself.
- Data, SQL and Getting to the AnswerMost AI problems turn out to be data problems. This is where you learn to ask a database a question and defend the answer.
- Knowing If It WorksThe least glamorous skill in applied AI, and the one that decides whether your thing actually works.
- Context EngineeringThe window is a budget. Learn to spend it.