Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Python, From Zero, For AI

From your first line of code to your first API call.

Level
Nothing assumed
Lessons
89
Reading time
732 min
Price
Free, no sign-up to read

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.

Start the first lesson

Download the textbook (PDF) · free to print and teach from, with the exam paper and every answer at the back.

Module 1

10 lessons · 71 min

Getting Python running, and your first working code

Before anything else you need Python on a machine you actually own, and a clear picture of what a line of code does when it runs. This block installs Python on a laptop or a phone, teaches the two ways of running code, and covers the pieces every later lesson assumes: types, numbers, formatted output, truth, and branching.

By the end you can

Install 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 have9 minInstall the stable release minus one, tick Add to PATH on Windows, and remember that `python3` on macOS and Linux is not the same command as `python`.
  2. 2What a program is, and running your first line6 minA program does exactly what is written, in order, and shows you only what you ask it to show.
  3. 3The interactive shell and the saved file, and when to use each7 minThe REPL prints the value of every expression automatically and a script prints nothing you did not ask for, so the same lines behave differently in the two places.
  4. 4Variables, and the fact that everything has a type6 minA name is attached to a value at the moment the line runs, and the value's type decides what operators do.
  5. 5Numbers: whole, decimal, and the one that surprises everybody8 min`/` always returns a float and `//` rounds downwards, and 0.1 + 0.2 is not 0.3 because a float stores binary fractions with 53 bits of mantissa.
  6. 6Strings, and why input() always hands you text6 mininput() always returns text, so convert it the moment it arrives or you will compare numbers alphabetically.
  7. 7Building the output a person will read7 minAn f-string builds a string at the moment it runs, and the part after the colon controls only how the value is displayed, never the value itself.
  8. 8True, False, and what Python counts as nothing7 min`or` returns an operand rather than a boolean, so `x or default` quietly replaces a legitimate 0 or empty string, and `is` should be reserved for None.
  9. 9Making a decision: if, elif, else7 minIn an if/elif chain only the first true branch runs, so replacing elif with separate ifs lets a later assignment overwrite an earlier correct one with no error.
  10. 10What happens between your file and the answer8 minPython compiles the whole file to bytecode before running any of it, and its slowness comes from every value being a typed heap object, which is why moving a numeric loop into NumPy wins fifty-fold where rewriting it in Python wins thirty per cent.
Case studyFourteen machines, no admin password, and a term that has already startedA government higher secondary school in Bhopal, first week of the computer science termRead it

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.

What actually happened

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

  1. 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.

  2. 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.

  3. 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 modulePractice. Nothing is recorded and no score is kept.
  1. 1

    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?

  2. 2

    A program reads two marks with input() and prints the larger. Given 9 and 10 it prints 9. Nothing crashes. Why?

  3. 3

    Why is 0.1 + 0.2 == 0.3 False?

  4. 4

    What does -7 // 2 give, and why?

  5. 5

    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?

  6. 6

    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?

Module 2

10 lessons · 78 min

Collections, loops, and the shape of your data

Almost every program is a pile of values and something that walks over them. This block covers the four containers Python gives you, how to reach into them without copying or corrupting what you did not mean to touch, and the three ways of repeating work — the for loop, the while loop and the comprehension.

By the end you can

Choose 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

  1. 11Lists and dicts, and knowing which one you need7 minA list is positions in order; a dict is named slots, and one name means exactly one slot.
  2. 12Reaching into a sequence: indexes, slices and why the end is excluded8 minA slice stops before its second index, which makes its length exactly stop minus start, and slicing out of range returns a shorter result instead of raising.
  3. 13Changing a list, and the copy you thought you made9 minAssignment attaches a second name to the same list, `copy()` duplicates only the outer level, and a mutating method returns None rather than the list.
  4. 14Tuples and sets, and the cost of asking is this in there8 minA set answers membership in constant time where a list scans, so converting a large list to a set once before a loop can turn seconds into milliseconds.
  5. 15Dictionary patterns: defaults, counting and grouping8 min`get` with a default and `defaultdict` remove the missing-key branch, but both hide data you may have misread, so use plain `d[key]` when absence means the program is wrong.
  6. 16Loops, or doing the same thing to many things6 minIndentation decides what repeats, so where you start the running total decides whether the answer is a sum.
  7. 17While loops, break, and the loop that never ends7 minA `while` loop hangs when the body does not change the variable its condition tests, and a loop's `else` runs only when no `break` was hit.
  8. 18Comprehensions: building a list in one line, and when not to8 minA conditional before the `for` chooses a value and keeps the length, while an `if` after the `for` filters and shortens the result.
  9. 19Sorting by something other than the value itself8 min`key` takes a function applied once per element, tuples give multi-level ordering, and stable sorting lets you order by several fields by sorting repeatedly from the least important key upwards.
  10. 20Nested data: dictionaries inside lists inside dictionaries9 minNavigating nested data is one sentence read left to right, and the two index TypeErrors tell you whether you are one level too shallow or one level too deep.
Case studyNine hundred students, forty companies, and twenty-six minutes a runThe placement cell of an engineering college in Coimbatore, four days before the shortlists are dueRead it

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.

What actually happened

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

  1. 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.

  2. 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.

  3. 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 modulePractice. Nothing is recorded and no score is kept.
  1. 1

    After names = names.sort(), printing names gives None. What happened?

  2. 2

    grid = [[0, 0], [0, 0]] and copy = grid.copy(). Then copy[0][1] = 9, and grid[0][1] is now 9 as well. Why?

  3. 3

    A search returns three results and the code takes results[:10]. A colleague warns it will crash. Who is right?

  4. 4

    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?

  5. 5

    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?

  6. 6

    A loop written as for row in rows: if row.bad: rows.remove(row) leaves some bad rows behind. Why?

Module 3

9 lessons · 71 min

Functions, modules, and code you can read again in March

A working script of forty lines becomes an unreadable script of four hundred unless you break it up. This block covers how arguments really work, why a variable changed inside a function sometimes escapes and sometimes does not, how imports find your files, and how to give a script a proper command line.

By the end you can

Split 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

  1. 21Functions, and the difference between printing and returning7 minprint shows a value to a person; return hands it to the program, and a function without return gives back None.
  2. 22Arguments: positional, named, default, and the trap in the default9 minDefault values are created once when the `def` line runs, so a mutable default is shared by every call, and a function can mutate the object you passed but cannot rebind your name.
  3. 23Where a name lives, and why the function cannot see it8 minA name assigned anywhere in a function is local for the whole function, which is why reading a global before assigning it raises UnboundLocalError, while mutating a global object needs no declaration at all.
  4. 24Returning: one value, several values, or nothing at all7 minReturn values instead of printing them, because a function that prints can only ever feed a terminal, while a function that returns feeds a file, a test, a request and a terminal.
  5. 25Saying what a function expects, and what checks it8 minType hints are metadata that Python never enforces, so their value comes from a checker like mypy, from editor support, and from documenting units and edge cases the code cannot show.
  6. 26Modules: splitting a script across files without breaking it8 minAn import executes the module once and caches it, and Python searches the script's own folder first, which is why naming a file `json.py` breaks the real one.
  7. 27Laying out a project so the imports keep working8 minRun a module inside a package with `python3 -m package.module` rather than by path, and build file paths from `__file__` rather than the current working directory.
  8. 28Side effects, and why some functions are easy to test8 minFunctions that read the clock, the network or a model are impure, so pass those dependencies in as arguments and keep the parsing, calculation and formatting pure enough to test for free.
  9. 29Giving your script a proper command line8 minArguments belong on the command line rather than in `input()` prompts, and `argparse` gives you conversion, validation, generated help and correct exit codes for about fifteen lines.
Case studySeven hundred lines, eleven questions, and the volunteer who leftA small NGO in Ranchi that files monthly attendance reports for forty rural learning centresRead it

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.

What actually happened

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

  1. 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.

  2. 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.

  3. 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 modulePractice. Nothing is recorded and no score is kept.
  1. 1

    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?

  2. 2

    def add(item, basket=[]) accumulates items across separate calls that pass no basket. Why?

  3. 3

    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?

  4. 4

    def f(rows): rows.append(1); rows = [] — the caller's list gains the 1 but is not emptied. What single rule explains both halves?

  5. 5

    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?

  6. 6

    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?

Module 4

9 lessons · 75 min

When it goes wrong: exceptions, debugging and tests

Most of the time you spend programming is spent on code that does not work yet. This block turns that time into a method: read the failure, decide whether to catch it or let it stop the program, get the state in front of you with a logger or a debugger, and then write the test that stops the same bug coming back.

By the end you can

Take 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

  1. 30Reading an error message properly9 minRead a traceback bottom line first for what broke, then the lowest block of your own code for where.
  2. 31Catching an error, and deciding whether you should9 minCatch only the exceptions you have a plan for, keep the `try` block down to the line that can actually raise, and chain with `from err` so the original cause survives in the traceback.
  3. 32Raising your own errors, and choosing the right one8 minRaise the most specific built-in that already means what went wrong, always put the offending value in the message, and give a package one base exception class so callers can catch its failures without catching everything.
  4. 33Logging: the print statements you can turn off8 minLogging separates commentary from output and lets you switch detail on by level rather than by editing code, and `%s` arguments avoid building a message that will be discarded.
  5. 34Assertions: stating what must be true, and where they vanish7 minAn assert states a claim about your own code's correctness and is removed entirely under `python -O`, so validation of input, permissions or anything a user can cause must use an `if` and a `raise`.
  6. 35The debugger: stopping the program and looking around8 min`breakpoint()` and `python -m pdb -c continue` put you inside the running program at the moment of failure, where you can ask new questions of the live state instead of rerunning with more prints.
  7. 36Your first test, and what to test first9 minpytest turns plain assert statements into detailed failure reports, and the highest-value test you will ever write is the one that reproduces a bug you just fixed.
  8. 37Testing code that calls an API you pay for9 minNo test should make a real network call, and for a model API you assert the contract — shape, types, required fields, length bounds — because the exact words change between runs.
  9. 38Debugging with an AI assistant, and what it gets wrong8 minAn assistant answers from the text you paste, so give it the whole traceback and your versions, and verify any suggested method with `dir()` and `help()` before believing it exists.
Case studyNine days of reminders that nobody receivedA four-doctor clinic in Nashik that sends appointment reminders the evening beforeRead it

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.

What actually happened

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

  1. 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.

  2. 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.

  3. 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 modulePractice. Nothing is recorded and no score is kept.
  1. 1

    A traceback ends with KeyError: 'rent' and has four blocks above it. Where do you look first?

  2. 2

    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?

  3. 3

    A web handler protects an admin action with assert user.is_admin. Why is that a security hole rather than a style problem?

  4. 4

    Why is log.debug("row %s of %s", i, total) preferred over log.debug(f"row {i} of {total}")?

  5. 5

    You are testing a function that asks a model to summarise a paragraph. Which assertion is worth writing?

  6. 6

    assert len(rows) == expected, "row count changed" was written instead as assert (len(rows) == expected, "row count changed"). What does the second one do?

Module 5

10 lessons · 79 min

Data in and out: files, formats and the environment

Real work arrives as a file somebody sent you, in an encoding nobody recorded, with dates in three formats. This block covers paths that work on any machine, text that survives a round trip in any language, the two formats you will meet constantly, and the two things every AI script needs from its environment: pinned dependencies and a key that is not in the code.

By the end you can

Read 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

  1. 39Files, and keeping data after the program ends6 minOpening a file with mode w empties it first, so use a when you mean to add.
  2. 40Paths: why your program cannot find a file that is right there8 minA relative path resolves against the working directory the program was started in, so anchor every path to `Path(__file__).resolve().parent` and join with the `/` operator.
  3. 41Text that survives: encodings, and why the accents turned into question marks9 minPass `encoding="utf-8"` to every open, because the platform default is not UTF-8 everywhere, and remember that `len` counts code points rather than the characters a reader sees.
  4. 42CSV: the format everybody sends you and nobody agrees on8 minUse the csv module rather than splitting on commas, always pass `newline=""`, and remember every value arrives as a string so conversion and its failures belong at the boundary.
  5. 43JSON: the format every API speaks8 minJSON's type set is smaller than Python's, so tuples, sets, datetimes and Decimals need a `default` hook, and integer dictionary keys come back as strings.
  6. 44Dates and times, and the hour that does not exist9 minStore instants in UTC as aware datetimes and convert with a named zone only for display, and use `perf_counter` rather than clock arithmetic to measure how long something took.
  7. 45Files larger than memory8 minLoop over the file object rather than reading it, chain generators for each processing step, and move to SQLite the moment you need to sort or join rather than filter.
  8. 46Installing packages, and why virtual environments exist7 minA virtual environment is a per-project folder of packages, and activation lasts only for one terminal session.
  9. 47Pinning dependencies, so it still runs next year8 minApplications pin exact versions and libraries give ranges, and a `pip freeze` snapshot is not the same as a record of what you actually asked for.
  10. 48API keys: out of the code, out of the repository8 minKeys live in the environment, never in the source or a notebook, and when one leaks the fix is revocation first — cleaning git history afterwards does not undo the exposure.
Case studyThe names that arrived as question marksA block education office in Guwahati uploading the monthly midday meal return for ninety-one schoolsRead it

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.

What actually happened

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

  1. 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.

  2. 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.

  3. 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 modulePractice. Nothing is recorded and no score is kept.
  1. 1

    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?

  2. 2

    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?

  3. 3

    A colleague's file shows names as नमस on your machine. Another shows them as ???. Which is recoverable and why?

  4. 4

    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?

  5. 5

    json.dumps raises TypeError: Object of type datetime is not JSON serializable. What does that tell you about JSON?

  6. 6

    pip install requests reported success, and the script still fails with ModuleNotFoundError: No module named 'requests'. What is the first thing to check?

Module 6

11 lessons · 95 min

Talking to a service: HTTP from Python, done properly

The first API call worked. This module is everything between that and code you would let run unattended against a service that charges by the token: what a request is made of, why connections should be reused, how to time out and back off without double-spending, how to walk a paginated result, what a provider's SDK does for you, how to consume a streamed reply, how to make twenty calls at once, and how to know what each call cost.

By the end you can

Write 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

  1. 49Calling an API for the first time9 minThe status code tells you the request and reply worked; only the body tells you what the answer says.
  2. 50What a request is made of, and how to see the one you actually sent8 minA request is a method, a URL with a query string, headers and an optional body; when a call fails, print the request Python built rather than the one you think you wrote.
  3. 51Sessions: paying for the handshake once instead of every call7 minA bare requests.get() opens and closes a TCP and TLS connection every time; a Session keeps it open, which is why a loop of two hundred calls drops from minutes to seconds and why every serious client is a Session.
  4. 52Timeouts and retries: the code that decides whether you pay twice9 minRetry a request only when you can prove it did not take effect — a connect failure, a 429, a 503 — and never blindly retry a POST that timed out while reading, because the server may have finished the work and billed you before the reply was lost.
  5. 53Pagination: walking a result that arrives in pages8 minAn API never hands you all of anything; it hands you a page and a way to ask for the next, and a generator that yields items and hides the page boundary is the cleanest way to consume it.
  6. 54A provider's SDK: what it does for you, what it hides, and how to read it8 minAn SDK is a Session with the auth header, retries, typed responses and streaming already written; the same OpenAI-shaped client talks to a free local model by changing base_url, and when it misbehaves the source is sitting in your virtual environment to read.
  7. 55Consuming a streamed reply: server-sent events, line by line9 minA streamed model reply is one long HTTP response made of data: lines, each carrying a JSON fragment; read it with iter_lines, parse each fragment, print with flush=True, and accumulate the text yourself because nothing else will.
  8. 56asyncio: twenty calls in the time of one10 minasync lets one thread overlap the waiting of many network calls, so twenty one-second requests finish in about a second; gather them, cap them with a Semaphore so the rate limit holds, and remember that any blocking call inside a coroutine stalls all of them.
  9. 57Threads, processes, and the lock that decides which one you need9 minPython threads overlap waiting but not computing, because one interpreter lock lets only one thread run Python at a time; use a ThreadPoolExecutor for network calls in synchronous code, and processes when the bottleneck is your own arithmetic.
  10. 58Validating what the model returns: from a string that looks like JSON to an object you can trust9 minA model's reply is text until you have parsed it and checked every field against a schema; pydantic turns that check into one line, and feeding its error message back to the model is the cheapest repair there is.
  11. 59Knowing what each call cost: usage figures, token counting, and a budget that stops the program9 minEvery response carries its own token counts; multiply them by the price table, keep a running total, and raise before the cap is crossed — and count Hindi or code prompts before sending, because the four-characters-per-token rule of thumb is off by two to three times for them.
Case studyThe retry that billed twiceA four-person edtech company in Indore, tagging forty thousand student essays overnightRead it

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.

What actually happened

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

  1. 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.

  2. 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.

  3. 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 modulePractice. Nothing is recorded and no score is kept.
  1. 1

    A model API call returns status 200. What has that established?

  2. 2

    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?

  3. 3

    A POST to a paid model API times out while waiting for the reply. Why is retrying it different from retrying a failed connection?

  4. 4

    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?

  5. 5

    Twenty API calls are gathered with asyncio, and inside each coroutine there is a requests.get(). The run still takes twenty seconds. Why?

  6. 6

    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?

Module 7

10 lessons · 88 min

Objects, generators and the shape a larger program takes

Scripts grow. This module is the Python you need once a program is bigger than one file: classes and dataclasses for things that belong together, inheritance and composition for swapping one model provider for another, the dunder methods that make your own objects work with for and len and with, the iterator protocol under every loop, context managers, decorators, a type checker that catches what tests miss, making a project installable as a command, and finding out where the time actually goes.

By the end you can

Restructure 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

  1. 60Classes: when a dict stops being enough9 minA class bundles data with the functions that act on it and gives the bundle a name; self is simply the object the method was called on, and a mutable value placed on the class rather than in __init__ is shared by every instance.
  2. 61Dataclasses: the class that is mostly data, written in four lines8 minA dataclass writes __init__, __repr__ and __eq__ from the field list; a mutable default must go through field(default_factory=...), frozen=True makes instances hashable and immutable, and asdict is the road to JSON.
  3. 62Inheritance and composition: swapping one model provider for another9 minPut the one thing that differs between providers behind a small base class with a single method, and give the rest of the program an object that has a provider rather than is one — inheritance for the interface, composition for everything else.
  4. 63Dunder methods: making your own objects work with len, for, in and ==9 minBuilt-in syntax is dispatched to double-underscore methods — len() calls __len__, for calls __iter__, == calls __eq__ — so implementing them makes a class feel native, and defining __eq__ without __hash__ silently makes instances unhashable.
  5. 64The iterator protocol: what for actually does, and why a generator is empty the second time9 minfor calls iter() once and next() repeatedly until StopIteration; a list gives a fresh iterator each time but a generator is its own iterator and is used up after one pass, which is why iterating it twice yields nothing the second time.
  6. 65Context managers: the cleanup that runs even when the block raises8 minwith guarantees that __exit__ runs however the block ends, which is why files, sessions, locks and timers belong in one; contextlib.contextmanager writes one from a generator with a single yield, and the code after the yield is the cleanup.
  7. 66Decorators: wrapping a function with retry, timing or a cache9 minA decorator is a function that takes a function and returns a replacement; @wraps keeps the original's name, and lru_cache on a model call returns the same answer forever for the same arguments, which is a saving or a bug depending on whether you wanted fresh output.
  8. 67Running a type checker: the bugs it finds that tests do not9 minA type checker follows every possible path through the code without running it, so it catches the None that only appears on the branch you never tested; Optional forces you to handle the missing case, and Protocol lets you describe an interface without inheritance.
  9. 68pyproject.toml: making your project installable, and a command somebody can type9 minA pyproject.toml with a name, version, dependencies and a [project.scripts] entry turns a folder into something pip can install and a command a colleague can run; pip install -e . links the source so edits take effect without reinstalling.
  10. 69Profiling: finding where the time actually goes before you optimise anything9 minMeasure before you change: perf_counter around suspects, cProfile sorted by cumulative time for the whole program, tracemalloc for memory — and in an AI program the answer is almost always the network call, not the loop you were about to rewrite.
Case studyTwo days of structure, or one afternoon of find and replaceA three-person civic technology group in Pune that summarises municipal tender documents for journalistsRead it

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.

What actually happened

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

  1. 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.

  2. 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.

  3. 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 modulePractice. Nothing is recorded and no score is kept.
  1. 1

    A class writes messages = [] in the class body rather than self.messages = [] in __init__. Two instances then share one list. Why?

  2. 2

    A dataclass field written as items: list = [] raises ValueError at class definition. What is Python protecting you from, and what is the fix?

  3. 3

    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?

  4. 4

    After adding __eq__ to a small class, putting instances in a set raises TypeError: unhashable type. What happened?

  5. 5

    @timed and @retry(3) are stacked on one function, with @timed written above. What does the log measure?

  6. 6

    In a context manager written with @contextlib.contextmanager, where does the cleanup go?

Module 8

10 lessons · 87 min

Numbers in bulk: NumPy, pandas and the vector under every model

A model is arithmetic on arrays, and the data that feeds it arrives as tables. This module is the Python for both: NumPy arrays and why they are a hundred times faster than a loop, shapes and broadcasting and the wrong-shape bug that runs without complaint, dtypes and what float16 costs in precision, masks and views, the dot product and cosine similarity that sit under embedding search, then pandas for loading, cleaning, grouping and joining real tables, plotting what you found, and working in a notebook without the hidden-state bugs that come with one.

By the end you can

Load 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

  1. 70NumPy arrays: why one line beats a loop by a hundred times9 minA NumPy array is one block of memory holding values of a single type, so an operation on it is a compiled loop over raw numbers; a Python list is a row of pointers to separate objects, and the loop over it pays for type checks and allocation on every element.
  2. 71Shapes, axes and broadcasting: the wrong-shape bug that runs without complaint9 minBroadcasting stretches a size-1 dimension to match, so (3,) and (3,1) combine into (3,3) silently; axis=0 collapses rows to give one value per column, and printing .shape before and after an operation is the check that catches the bug that raises nothing.
  3. 72dtypes: what float16 costs, why int8 wraps round, and how to compare floats9 minThe dtype fixes bytes per element and therefore memory, range and precision; float16 holds three significant figures and overflows at 65,504, integers wrap silently at their limit, and two floats should be compared with isclose rather than ==.
  4. 73Masks, where, and the slice that is not a copy8 minA boolean mask selects elements in one expression and replaces most loops; a basic slice of an array is a view onto the same memory so writing to it writes to the original, while a mask or fancy index returns a copy.
  5. 74Dot products and cosine similarity: one vector against a hundred thousand in a single line9 minCosine similarity is the dot product of two unit-length vectors, so normalise the matrix once and every query becomes one matrix-vector product; at a hundred thousand rows that is milliseconds, and the point at which it stops being milliseconds is the point at which an index becomes worth its complexity.
  6. 75pandas: a table with named columns, and the four things to look at first9 minA DataFrame is a dict of typed columns sharing an index; read_csv guesses the types and gets dates and IDs wrong, loc selects by label and iloc by position, and a column of object dtype is a column pandas could not understand.
  7. 76Cleaning a table: missing values, the string that is not missing, and duplicates that are not identical9 minisna finds only real NaN, so the strings 'Unknown', 'N/A' and empty must be converted first; fill or drop by column with a reason each time, and deduplicate on the key that defines a row rather than on every column.
  8. 77groupby, merge and pivot: getting from rows to the table a question needs9 mingroupby splits by key and aggregates per group in one expression; merge joins two tables on a key and multiplies rows when the key repeats on both sides, so check the row count before and after every join.
  9. 78matplotlib: a plot you can read, saved to a file, from a script or a server8 minMake a figure and axes with subplots, draw on the axes, label them, and savefig — plt.show() is for a notebook, a log-scaled axis is what makes a loss curve or a token histogram legible, and a plot with no axis labels is a plot nobody can check.
  10. 79Notebooks without hidden state: execution order, restart-and-run-all, and when to leave for a .py file8 minA notebook's kernel remembers every cell ever run in whatever order you ran them, so a result can depend on a cell you deleted; Restart and Run All is the only test that the notebook says what it does, and code that has stopped changing belongs in a module the notebook imports.
Case studyFour thousand two hundred farmers, and a join that grew the tableA dairy cooperative in Kolhapur district, on the day the monthly payment file goes to the bankRead it

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.

What actually happened

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

  1. 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.

  2. 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.

  3. 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 modulePractice. Nothing is recorded and no score is kept.
  1. 1

    Squaring a million numbers takes about 600 ms as a Python list comprehension and about 5 ms as arr * arr. What is the mechanism?

  2. 2

    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?

  3. 3

    m has shape (500, 8) and you want the mean of each column. Which call gives eight numbers?

  4. 4

    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?

  5. 5

    A tensor of logits stored as float16 produces inf after np.exp, and then nan. What is the mechanism?

  6. 6

    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?

Module 9

10 lessons · 88 min

Building the thing: a small AI program, end to end

Everything so far was a part. This module assembles them into a working tool one piece at a time: a chat loop that keeps and trims its own history, prompt templates that cannot be broken by what a user types, a disk cache so a re-run costs nothing, a chunker for documents longer than a context window, a search over your own notes built on the similarity arithmetic of module 8, a free model running on your own machine, a web interface, an HTTP endpoint, a schedule that runs it every morning, and a continuous-integration job that runs the tests before anything ships.

By the end you can

Assemble 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

  1. 80A chat loop with memory: keeping history, trimming it, and stopping cleanly9 minA conversation is a list you send back in full on every turn, so its cost grows with its length; trim from the oldest user turn while keeping the system prompt, catch KeyboardInterrupt so the last exchange is saved, and never let the loop run without a way out.
  2. 81Prompt templates: building a prompt from parts without letting the parts rewrite it8 minA prompt is a string assembled from a template and user data; keep the template in a file, fill it with format or Template rather than an f-string at the call site, fence the user's text with clear delimiters, and accept that no delimiter makes injection impossible — only your handling of the output does.
  3. 82A response cache on sqlite: the re-run that costs nothing9 minKey the cache on a hash of everything that changes the answer — model, messages, parameters — serialised with sort_keys so the same request always hashes the same; sqlite3 is in the standard library, survives restarts, and makes a twenty-times-rerun script free after the first.
  4. 83Chunking: splitting a document that does not fit, without cutting a sentence in half8 minSplit on paragraph boundaries first, then sentences, then characters as a last resort; measure chunks in tokens not characters, overlap adjacent chunks so a fact on the boundary survives, and keep each chunk's origin so a search result can point back to the page.
  5. 84Search over your own notes: chunks, embeddings, cosine, and sixty lines10 minEmbed every chunk once into a normalised matrix saved with np.save, embed the query with the same model, take the top-k dot products, and hand the matching chunks with their sources to the model — the whole thing is the course's earlier parts assembled, and the model used to embed must never change between indexing and querying.
  6. 85A free model on your own machine, called from Python9 minOllama, llama.cpp and transformers each run an open model on a CPU with no account; memory is parameters times bytes per weight, tokens per second is what a CPU gives you, and the same provider class from module 7 makes the local model a drop-in for the paid one.
  7. 86A web interface in twenty lines: Gradio, and what launch() actually starts8 mingr.ChatInterface wraps a function that takes a message and history and returns text — or yields it for streaming — and launch() starts a local web server on port 7860; share=True opens a temporary public tunnel, and Hugging Face Spaces hosts the same file for free.
  8. 87An HTTP endpoint with FastAPI: your function, callable by any program9 minA FastAPI route is a function with a pydantic model as its argument; the framework parses and validates the request body, returns 422 with the field named when it fails, and generates the documentation page at /docs from the same declarations.
  9. 88Running it every morning: cron, a lock file, and a job that is safe to run twice9 minA scheduler only starts the process; the job must find its own environment, refuse to run alongside itself, record what it has already processed so a re-run does no harm, and write logs somewhere a person will read them.
  10. 89GitHub Actions: the tests run on a machine that is not yours, before anything ships9 minA workflow file runs your tests on a clean machine on every push, which catches the dependency you forgot to pin and the file whose case only Windows and Linux care about; secrets go in the repository settings and the paid-API tests stay behind a flag so a pull request from a stranger cannot spend your money.
Case studyThree thousand pages of circulars, and where the embeddings goA district legal aid helpdesk in Lucknow, building a search over its own case notes and government circularsRead it

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.

What actually happened

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

  1. 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.

  2. 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.

  3. 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 modulePractice. Nothing is recorded and no score is kept.
  1. 1

    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?

  2. 2

    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?

  3. 3

    Why do adjacent chunks of a document overlap by a sentence or two before being embedded?

  4. 4

    A notes search is re-indexed with a newer embedding model, but the query is still embedded with the old one. What happens?

  5. 5

    A Gradio chat function is turned into a generator to stream the reply. What should each yield produce?

  6. 6

    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?

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly