Python
The language every AI job is conducted in.
A programming language is the set of written instructions a computer will follow, and Python is the one this field settled on. It reads close to English: a line that filters a list of records looks roughly like a sentence describing that filter. Every model provider ships a Python library, and nearly every machine learning tool is written in it.
The choice is not that the language is better than the alternatives; it is that the libraries are here. Working in another language means writing yourself the things everyone else imports, and being unable to read the code your team ships. In an interview, hesitating over ordinary Python reads as inexperience whatever else you know.
It is the floor under nearly everything else in this atlas. Async and concurrency, typing and validation, NumPy and pandas and PyTorch are all Python-specific skills, and every code example elsewhere assumes you can read one.
Learn the handful of idioms AI codebases actually lean on rather than the whole language; the standard library carries most of the work.
In more detailEvery ML library, every inference SDK and every coding interview in this field is Python. What matters is not syntax but the idioms AI codebases lean on: async/await for concurrent API calls, type hints and pydantic for validating model output, generators for streaming, context managers for resources. Comfort with the standard library beats framework knowledge.
Variables, strings and numbers
By the end: You can read and write basic Python without looking up syntax.
- Variables and the basic types
- Strings: what you can do with them
- f-strings for building text
- Numbers, and the two kinds of division
- None, and what counts as true or false
This is the beginning: how to store a value under a name, and the four kinds of value everything else is built out of — text, whole numbers, decimals, and true or false. Most of what you do in Python is done to text, so a good part of this section is what you can do to a string and how to build one out of other values. It assumes you have never written a line of code.
Variables#
A variable is a name for a value. You do not declare a type. Python works it out from what you assign.
name = "Jack"
age = 29
price = 19.99
is_active = TrueThose are the four basic types: str for text, int for whole numbers, float for decimals, bool for true or false. You can check any value's type with type().
type(name) # <class 'str'>
type(age) # <class 'int'>Strings#
A string is text. Single or double quotes both work. Use triple quotes when you need several lines.
a = 'hello'
b = "hello"
c = """line one
line two"""Here are the string operations you will use most often.
s = "Hello World"
len(s) # 11
s.upper() # 'HELLO WORLD'
s.lower() # 'hello world'
s.replace("World", "Python") # 'Hello Python'
s.split(" ") # ['Hello', 'World']
s.strip() # removes spaces at both ends
s.startswith("He") # True
"World" in s # TrueYou can pick out parts of a string by position. Counting starts at 0. This is called slicing.
s = "Python"
s[0] # 'P' first character
s[-1] # 'n' last character
s[0:3] # 'Pyt' characters 0, 1, 2
s[2:] # 'thon' from position 2 to the end
s[:2] # 'Py' up to but not including position 2
s[::-1] # 'nohtyP' reversedStrings cannot be changed
s[0] = 'J' raises an error. Every string method returns a new string instead of changing the old one. So s.upper() on its own does nothing useful. You have to assign it: s = s.upper().
Joining strings together#
You can add strings with +, but join is better when you have many pieces.
first = "Hello"
second = "World"
first + " " + second # 'Hello World'
words = ["Hello", "World", "again"]
" ".join(words) # 'Hello World again'
", ".join(words) # 'Hello, World, again'Why join is better: each + creates a brand new string and copies everything so far. With 10 pieces you will not notice. With 10,000 pieces in a loop, the program crawls. join builds the result in one pass.
# Slow with many lines
out = ""
for line in lines:
out = out + line
# Fast
out = "".join(lines)f-strings#
An f-string lets you put variables directly inside text. Put an f before the quote and wrap the variable in curly braces. This is the normal way to build text in modern Python.
name = "Jack"
tokens = 1250
f"Hello {name}" # 'Hello Jack'
f"Used {tokens} tokens" # 'Used 1250 tokens'
f"Double: {tokens * 2}" # 'Double: 2500'You can control how numbers look by adding a colon and a format code.
cost = 0.0374125
f"{cost:.2f}" # '0.04' two decimal places
f"{cost:.4f}" # '0.0374' four decimal places
f"{1250:,}" # '1,250' thousands separator
f"{0.37:.0%}" # '37%' as a percentageA debugging shortcut
Add an equals sign inside the braces and Python prints the name as well as the value. f"{tokens=}" gives 'tokens=1250'. It saves typing the label yourself every time you debug.
Numbers#
Two kinds of division catch people out, so learn them together.
7 / 2 # 3.5 always gives a float
7 // 2 # 3 floor division, drops the remainder
7 % 2 # 1 the remainder
2 ** 10 # 1024 power
round(3.7) # 4
int("42") # 42 text to number
str(42) # '42' number to textNone, True and False#
None means no value. It is what a function returns when it does not return anything. It is not zero and not an empty string.
Python also treats some values as false when you use them in an if statement. These are all falsy: False, None, 0, empty string, empty list, empty dict.
items = []
if items:
print("has items")
else:
print("empty") # this runsThe trap in that shortcut
if not items is true for an empty list AND for None. Those are different situations: 'the user sent nothing' versus 'the user sent an empty list'. When the difference matters, check explicitly with if items is None.
Where people go wrong
Forgetting that string methods return a new string. Writing s.upper() on its own line changes nothing. You have to assign the result back with s = s.upper().
Build this: Write a program that takes a sentence, prints how many words it has, prints it reversed, and prints each word on its own line with its length next to it.
Open this section on its own pageLists, dictionaries and sets
By the end: You can choose the right container for a job and use it fluently.
- Lists: creating, adding, removing, sorting
- Dictionaries: keys and values
- Sets: unique items and fast membership checks
- Loops over each of them
- Comprehensions
One value under one name gets you a short way. Real programs hold groups of things: a list of results, a record with named fields, a set of items already seen. This section covers Python's three main containers, how to loop over each, and how to choose between them, which usually comes down to whether order matters and whether you look things up by name or by position.
Lists#
A list holds items in order. It can hold duplicates. Square brackets.
topics = ["python", "sql", "git"]
topics[0] # 'python'
topics[-1] # 'git'
len(topics) # 3
topics.append("rag") # add to the end
topics.insert(0, "linux") # add at a position
topics.remove("sql") # remove by value
topics.pop() # remove and return the last item
"git" in topics # TrueSorting is worth knowing properly, because it comes up constantly.
nums = [5, 2, 9, 1]
sorted(nums) # [1, 2, 5, 9] returns a NEW list
nums.sort() # sorts nums in place, returns None
sorted(nums, reverse=True) # [9, 5, 2, 1]
# Sorting by something other than the value itself
people = [{"name": "A", "age": 30}, {"name": "B", "age": 25}]
sorted(people, key=lambda p: p["age"])A very common bug
nums = nums.sort() sets nums to None, because .sort() returns nothing. Either call nums.sort() on its own line, or use nums = sorted(nums).
Dictionaries#
A dictionary maps a key to a value. Think of a real dictionary: you look up a word and get its meaning. Curly braces.
user = {"name": "Jack", "role": "engineer", "years": 5}
user["name"] # 'Jack'
user["city"] = "Berlin" # add a new key
del user["years"] # remove a key
"role" in user # True
len(user) # number of keysLooking up a key that does not exist raises an error. get() returns None instead, or a default you choose. Use get whenever the key might be missing.
user["city"] # KeyError if missing
user.get("city") # None if missing
user.get("city", "n/a") # 'n/a' if missingLooping over a dictionary gives you the keys unless you ask for more.
for key in user:
print(key)
for key, value in user.items():
print(key, value)
user.keys() # all keys
user.values() # all valuesSets#
A set holds unique items with no order. Adding something twice changes nothing. Its main use is removing duplicates and checking membership quickly.
tags = {"python", "ai", "python"} # {'python', 'ai'}
tags.add("sql")
tags.discard("ai")
a = {1, 2, 3}
b = {2, 3, 4}
a & b # {2, 3} in both
a | b # {1, 2, 3, 4} in either
a - b # {1} in a only
# Removing duplicates from a list
unique = list(set([1, 2, 2, 3, 3, 3]))Sets matter for speed. Checking whether something is in a list means looking at every item one by one. Checking a set is instant, no matter how big it is. If you are checking membership inside a loop, use a set.
seen = ["a1", "b2", "c3"] # slow to search when large
seen = {"a1", "b2", "c3"} # fast, always
if "b2" in seen:
...Which one should you use?#
- Order matters and duplicates are fine, use a list.
- You look things up by a name or id, use a dictionary.
- You need uniqueness, or you check membership a lot, use a set.
- The values are fixed and must never change, use a tuple.
Comprehensions#
A comprehension builds a list in one line. It does the same job as a loop with append, and Python programmers expect to see it.
# The long way
squares = []
for n in range(5):
squares.append(n * n)
# The same thing
squares = [n * n for n in range(5)] # [0, 1, 4, 9, 16]You can filter at the same time by adding an if at the end.
nums = [1, 2, 3, 4, 5, 6]
evens = [n for n in nums if n % 2 == 0] # [2, 4, 6]
names = [u["name"] for u in users]
adults = [u for u in users if u["age"] >= 18]
# Dictionary and set comprehensions work the same way
by_id = {u["id"]: u for u in users}
roles = {u["role"] for u in users}Know when to stop
Comprehensions are good for one loop and one condition. Once you need nested loops and several conditions, a normal for loop is easier to read and nobody will criticise you for it.
Where people go wrong
Modifying a list while looping over it. Removing items mid-loop makes Python skip elements, because the positions shift underneath the loop. Build a new list instead, usually with a comprehension.
Build this: Given a list of dictionaries representing people with a name, age and city, write code that returns the names of everyone over 25, grouped into a dictionary by city.
Open this section on its own pageLoops, conditions and functions
By the end: You can write functions with clear inputs and outputs, and control the flow of a program.
- if, elif, else
- for and while loops, break and continue
- Defining functions and returning values
- Default arguments and keyword arguments
- Scope: what a function can see
So far a program runs from top to bottom and does the same thing every time. This section is about the three ways that changes: doing something only when a condition holds, doing something repeatedly, and packaging a piece of work under a name so it can be called from anywhere with different inputs. Functions are the larger half — nearly all the code you will read is organised into them.
Conditions#
Python uses indentation instead of braces. Everything indented under the if belongs to it. Four spaces is the convention.
score = 72
if score >= 90:
grade = "A"
elif score >= 70:
grade = "B"
else:
grade = "C"# Comparisons
== != < > <= >=
# Combining them
if age > 18 and has_id:
...
if role == "admin" or role == "owner":
...
if not is_blocked:
...
# Python lets you chain comparisons
if 0 < score < 100:
...Loops#
A for loop walks through anything you can iterate: a list, a string, a dictionary, a range.
for topic in topics:
print(topic)
for i in range(5): # 0, 1, 2, 3, 4
print(i)
for i in range(1, 6): # 1 to 5
print(i)
# When you need the position as well as the item
for index, topic in enumerate(topics):
print(index, topic)
# When you need to walk two lists together
for name, score in zip(names, scores):
print(name, score)break leaves the loop entirely. continue skips to the next iteration.
for n in numbers:
if n < 0:
continue # skip negatives
if n > 100:
break # stop completely
process(n)A while loop runs until its condition becomes false. Use it when you do not know how many times you will loop, such as retrying until something succeeds.
attempts = 0
while attempts < 3:
if try_once():
break
attempts += 1Functions#
A function is a named block of code that takes inputs and gives back a result. def defines it, return sends a value back.
def greet(name):
return f"Hello {name}"
message = greet("Jack") # 'Hello Jack'A function without a return statement gives back None. That is fine for functions that only print or save something.
Arguments can have defaults, which makes them optional when calling.
def call_model(prompt, model="fast", temperature=0.0):
...
call_model("hello")
call_model("hello", model="strong")
call_model("hello", temperature=0.7)Never use a list or dict as a default
def add(item, items=[]) creates that list once, when the function is defined, and every call shares it. Items pile up across calls and it looks like data leaking between requests. Use None as the default and create the list inside the function.
# Wrong
def add(item, items=[]):
items.append(item)
return items
# Right
def add(item, items=None):
if items is None:
items = []
items.append(item)
return itemsScope#
A variable created inside a function only exists inside it. The function can read variables from outside, but assigning to one creates a new local variable rather than changing the outer one.
count = 0
def increment():
count = count + 1 # UnboundLocalError
def increment():
global count # works, but avoid this
count = count + 1
def increment(count): # better: pass it in, return it out
return count + 1Prefer the last version. Functions that take what they need and return a result are easier to test and easier to reason about than ones that reach out and change things elsewhere.
Lambda#
A lambda is a small function written in one line, with no name. You will mostly see it as the key argument to sorted.
sorted(people, key=lambda p: p["age"])
# The same thing written normally
def get_age(p):
return p["age"]
sorted(people, key=get_age)Where people go wrong
Mixing tabs and spaces for indentation. Python treats them differently and the error message is confusing. Configure your editor to insert four spaces when you press Tab and never think about it again.
Build this: Write a function that takes a list of numbers and returns a dictionary with the minimum, maximum, average and count. Handle the empty list case sensibly rather than crashing.
Open this section on its own pageErrors, files and modules
By the end: You can handle failures properly, read and write files, and split code across modules.
- try and except, and catching the right thing
- Raising your own errors
- Reading and writing files with with
- Working with JSON
- Imports and modules
Three things a program needs the moment it leaves your own machine. What to do when something fails instead of stopping dead. How to read and write files, including JSON, which is the format almost every API speaks. And how to split code across several files and use one from another. Handling failure properly is what decides whether a bad response costs you one request or the whole run.
Handling errors#
When something goes wrong Python raises an exception and stops. try and except let you catch it and decide what to do.
try:
age = int(user_input)
except ValueError:
print("That is not a number")The common built-in errors are worth recognising on sight.
- ValueError: right type, wrong value. int("abc") raises this.
- TypeError: wrong type entirely. "2" + 2 raises this.
- KeyError: that key is not in the dictionary.
- IndexError: that position is past the end of the list.
- FileNotFoundError: no file at that path.
- AttributeError: that object has no such method or attribute. Usually means the value is None.
Always name the exception you are catching. A bare except catches everything, including your own typos and the Ctrl-C that stops the program.
# Bad: hides real bugs
try:
result = process(data)
except:
result = None
# Good: catches one thing, on purpose
try:
result = json.loads(raw)
except json.JSONDecodeError:
result = Noneelse runs if nothing went wrong. finally always runs, whether or not it did.
try:
data = fetch()
except TimeoutError:
log("timed out")
else:
save(data) # only if fetch succeeded
finally:
close_connection() # alwaysRaising your own#
Raise an error when your function is given something it cannot work with. Failing loudly and early beats returning something wrong.
def set_temperature(value):
if not 0 <= value <= 2:
raise ValueError(f"temperature must be 0 to 2, got {value}")
...
# Your own error type, for when callers need to tell yours apart
class ModelOutputError(Exception):
pass
raise ModelOutputError("model returned invalid JSON")Files#
Always open files with a with block. It closes the file for you, including when something raises partway through.
# Reading
with open("notes.txt") as f:
content = f.read() # whole file as one string
with open("notes.txt") as f:
for line in f: # one line at a time, memory friendly
print(line.strip())
# Writing
with open("out.txt", "w") as f: # "w" overwrites
f.write("hello\n")
with open("out.txt", "a") as f: # "a" appends
f.write("another line\n")w destroys the file
Opening with mode "w" empties the file immediately, before you write anything. If you meant to add to it, use "a".
JSON#
JSON is how data moves between systems, including every API you will call. Python converts between JSON text and dictionaries with two pairs of functions.
import json
# Text to Python
data = json.loads('{"name": "Jack"}') # from a string
with open("config.json") as f:
data = json.load(f) # from a file
# Python to text
text = json.dumps(data) # to a string
text = json.dumps(data, indent=2) # readable
with open("out.json", "w") as f:
json.dump(data, f, indent=2) # to a fileModules#
Any Python file is a module. Importing one makes its functions available. This is how you split a program into files instead of one enormous script.
import json # whole module
from pathlib import Path # one thing from a module
from collections import Counter, defaultdict
import numpy as np # with a shorter name
# Your own file, helpers.py, in the same folder
from helpers import clean_textThree standard library tools are worth knowing now, because they save you writing code by hand.
from collections import Counter, defaultdict
from pathlib import Path
Counter(["a", "b", "a"]).most_common(1) # [('a', 2)]
groups = defaultdict(list)
groups["fruit"].append("apple") # no need to create the list first
Path("data/out.json").parent.mkdir(parents=True, exist_ok=True)Where people go wrong
Catching an exception and then continuing with partial data. Half-processed data flowing onward is worse than a clear failure, because the damage appears somewhere unrelated and much later.
Build this: Write a program that reads a JSON file, handles the file being missing and the JSON being malformed with different messages, and writes a cleaned version to a new file.
Open this section on its own pageType hints and pydantic
By the end: You can annotate code so the editor helps you, and validate untrusted data at the boundary.
- Basic annotations and why they help
- Optional values, and the checker following your if statements
- Lists, dicts and unions
- pydantic for validating real data
- Generating a JSON schema from a model
A type hint is a note in the code saying what kind of value belongs where. Python ignores hints while running, but your editor and a type checker read them, so a mistake surfaces as you type rather than in production. This section covers the annotations worth writing, then pydantic, which uses the same syntax to check data arriving from outside your program — an API response, a form, a model's output — and to reject it clearly when it is wrong.
What a type hint is#
A type hint says what kind of value something should be. Python ignores hints while running. Your editor and a type checker read them and warn you before you run anything.
def greet(name: str) -> str:
return f"Hello {name}"
count: int = 0
prices: list[float] = []
user: dict[str, str] = {}The practical benefit is autocomplete. Once your editor knows a variable is a str, it can offer you every string method. Without hints it can only guess.
Values that might be missing#
This is the most useful thing type hints do. If a function might return nothing, say so, and the checker will insist that callers handle it.
def find_user(user_id: str) -> dict | None:
...
user = find_user("abc")
print(user["name"]) # warning: user might be None
if user is not None:
print(user["name"]) # fine nowThe common shapes#
from typing import Literal
names: list[str]
scores: dict[str, int]
pair: tuple[int, int]
value: int | str # either one
role: Literal["user", "assistant"] # only these exact stringsWhere hints stop being enough#
Nothing checks a hint at runtime. If an API sends you a string where you declared an int, Python will not complain and your code will break later with a confusing error. For data arriving from outside your program, you need real validation.
pydantic#
pydantic takes a class definition and turns it into a validator. You describe the shape once, and it checks incoming data against it and converts types where it safely can.
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
email: str | None = None # optional, defaults to None
user = User(name="Jack", age=29)
user.name # 'Jack'
User(name="Jack", age="29") # works: "29" converts to 29
User(name="Jack", age="abc") # raises ValidationErrorField adds rules the type alone cannot express.
from pydantic import BaseModel, Field
class Invoice(BaseModel):
number: str
amount: float = Field(gt=0) # must be positive
currency: str = Field(min_length=3, max_length=3)
overdue: bool = FalseWhen validation fails you get a list of every problem, not just the first, which makes it possible to tell a user everything that is wrong at once.
from pydantic import ValidationError
try:
invoice = Invoice.model_validate_json(raw_text)
except ValidationError as e:
for err in e.errors():
field = ".".join(str(p) for p in err["loc"])
print(f"{field}: {err['msg']}")Why this matters for AI work#
A model returns text. That text is untrusted, exactly like input from a stranger. A pydantic model is where it stops being a guess and becomes something you can rely on. pydantic can also produce a JSON schema from your class, and that schema is exactly what tool definitions and structured output requests need.
Invoice.model_json_schema()
# Send this to the API as the required output shape,
# then validate the response with the same class.Validate once, at the edge
Parse incoming data into a model as soon as it arrives, then pass the typed object through your program. Validating deeper down means checking the same dictionary in several places, and those checks slowly disagree with each other.
Where people go wrong
Assuming a type hint protects you. It documents intent, it does not check anything. The moment data comes from a file, an API or a model, you need a real validator.
Build this: Define a pydantic model for a blog post with a title, an optional subtitle, a word count that must be positive, and a status limited to draft or published. Feed it three bad payloads and print a clear message for each.
Open this section on its own pageAsync and concurrency
By the end: You can run many slow API calls at once without exceeding a rate limit.
- Why async exists and when it helps
- async, await and asyncio.run
- Running many things at once with gather
- Limiting how many run at once
- Timeouts, and the mistake that silently undoes all of it
Calling a model API is mostly waiting. Make a hundred calls one after another and your program spends nearly its whole life doing nothing, so the fix is to have many in flight at once. This section covers how Python expresses that, how to start many and collect the results, how to cap the number running so you do not trip a rate limit, and the one mistake that quietly turns concurrent code back into sequential code.
The problem async solves#
A call to a model API takes a few seconds, and your program spends nearly all of that time doing nothing but waiting for the reply. If you make fifty calls one after another, you wait fifty times.
# 50 calls, 2 seconds each, about 100 seconds total
for doc in docs:
summarise(doc)Async lets you start all fifty and wait for them together. The total becomes roughly the time of one call. This is the single biggest speed improvement available in most code that talks to an API.
The syntax#
Mark a function async def, and use await when calling something that takes time. A function marked async does not run on its own, it has to be started by asyncio.run or awaited from another async function.
import asyncio
async def summarise(doc: str) -> str:
return await call_model(doc)
async def main():
result = await summarise("some text")
print(result)
asyncio.run(main()) # starts everythingRunning many at once#
gather starts everything together and waits for all of it. Results come back in the order you passed them in.
async def main(docs):
results = await asyncio.gather(*(summarise(d) for d in docs))
return resultsBy default, if one of them raises, gather cancels the rest and raises. In a batch job that is usually wrong: one bad document should not lose the other forty-nine. Pass return_exceptions=True and the failures come back as values you can inspect.
results = await asyncio.gather(*tasks, return_exceptions=True)
for doc, result in zip(docs, results):
if isinstance(result, Exception):
log(f"failed: {doc}", result)
else:
save(result)Not too many at once#
Firing five hundred requests simultaneously will get you rate limited. A semaphore is a counter that only lets a fixed number through at a time, and queues the rest.
async def run_all(docs, limit=8):
sem = asyncio.Semaphore(limit)
async def one(doc):
async with sem: # waits for a free slot
async with asyncio.timeout(60): # give up after 60s
return await summarise(doc)
return await asyncio.gather(*(one(d) for d in docs), return_exceptions=True)Put a timeout on anything that touches the network. Without one, a single stalled connection holds a slot forever and the job never finishes.
The mistake that undoes everything#
One blocking call freezes all of it
Async runs on a single thread. If you call something that blocks, such as an ordinary requests.get, time.sleep, or a heavy file read, everything else stops until it finishes. Your concurrent code then runs at exactly sequential speed, and people usually blame the API. Use an async library, or hand the blocking work to asyncio.to_thread.
import requests, httpx, asyncio
# Blocks the whole event loop
async def bad(url):
return requests.get(url).text
# Correct: an async client
async def good(url):
async with httpx.AsyncClient() as client:
r = await client.get(url)
return r.text
# When you have no async option
async def fallback(path):
return await asyncio.to_thread(heavy_sync_read, path)When async does not help#
Async only helps when you are waiting. If the work is calculation rather than waiting, there is still only one thread doing all of it, so async adds complexity and speeds up nothing. For heavy computation you want multiprocessing instead.
Where people go wrong
Forgetting await. Calling an async function without it returns a coroutine object rather than running anything, and Python only warns you that a coroutine was never awaited. Nothing happens and nothing obviously fails.
Build this: Fetch fifty URLs with at most five running at once, a per-request timeout, and a summary at the end of which succeeded and which failed. Then swap in a blocking call and watch the speed collapse back to sequential.
Open this section on its own pageGenerators and working with large data
By the end: You can process files larger than memory and consume a streaming response.
- What an iterator is
- Generators and yield
- Why they save memory
- Batching
- Async generators for streaming
A list holds everything at once, which is fine until the everything is a ten-gigabyte file. A generator hands you one item at a time and remembers where it got to, so you can work through data far larger than memory, and start handling a model's reply before it has finished arriving. This section covers what an iterator is, how to write a generator, how to batch, and the async version used for streaming responses.
The idea#
A normal function computes everything and returns it all at once. A generator produces one value at a time and pauses in between, remembering where it was. Nothing is computed until you ask for it.
# Returns a list of 1,000,000 numbers. All in memory at once.
def squares_list(n):
return [i * i for i in range(n)]
# Produces them one at a time. Almost no memory.
def squares_gen(n):
for i in range(n):
yield i * i
for value in squares_gen(1_000_000):
print(value)Why this matters#
Reading a very large file with read() loads all of it into memory and may fail. Looping over the file object reads one line at a time, so the size of the file stops mattering.
# Loads the whole file
with open("huge.jsonl") as f:
lines = f.readlines()
# Reads one line at a time
def records(path):
with open(path) as f:
for line in f:
yield json.loads(line)
for record in records("huge.jsonl"):
process(record)Generator expressions#
A comprehension with round brackets instead of square ones is a generator. Use it when you are going to loop over the result once and do not need to keep it.
squares = [n * n for n in range(1000)] # a list, held in memory
squares = (n * n for n in range(1000)) # a generator, produced on demand
total = sum(n * n for n in range(1000)) # no list is ever builtBatching#
Most embedding and inference APIs take batches, so you will regularly need to turn a long stream into fixed-size groups.
from itertools import batched
for chunk in batched(records("huge.jsonl"), 64):
embed(chunk) # a tuple of up to 64 items
# Before Python 3.12, written by hand
def batched_manual(items, size):
batch = []
for item in items:
batch.append(item)
if len(batch) == size:
yield batch
batch = []
if batch:
yield batch # do not lose the last partial batchA generator can only be used once
After you loop over it, it is empty. Looping again gives you nothing and raises no error. In a retrieval pipeline this looks exactly like your documents disappearing. If you need the data twice, convert it to a list and accept the memory cost knowingly.
Streaming responses#
When an API streams a reply, it arrives as an async generator. You loop with async for, and each pass waits for the next piece.
async def collect(stream):
parts = []
async for chunk in stream:
parts.append(chunk.text)
print(chunk.text, end="", flush=True) # show it as it arrives
return "".join(parts)One consequence worth remembering: you cannot check the shape of a streamed response until it has finished. Any JSON parsing or schema validation has to happen after the loop, not inside it.
Where people go wrong
Building a list when a generator would do. It works fine on your test file and runs out of memory on the real one, usually in production and usually at the worst moment.
Build this: Write a generator that yields fixed-size batches from any iterable without loading everything, then use it to process a file much larger than your RAM and report how many batches it produced.
Open this section on its own pageTesting
By the end: You can write tests that catch real regressions and survive refactoring.
- pytest basics
- Testing the same logic against many inputs
- Fixtures for shared setup
- Fakes at the boundary
- What you can and cannot test in an AI system
A test is a small piece of code that runs your code and checks the answer. The reason to write them is not diligence: it is that they let you change something six months later and find out in seconds whether you broke anything. This section covers pytest, running the same logic against many inputs, sharing setup between tests, and standing in fakes for slow or paid dependencies — which matters in AI work, where a real model call costs money and answers differently each time.
Your first test#
A test is a function whose name starts with test_ containing a plain assert. pytest finds them and runs them. There is no base class and no special assertion methods.
# add.py
def add(a, b):
return a + b
# test_add.py
from add import add
def test_adds_two_numbers():
assert add(2, 3) == 5
def test_handles_negatives():
assert add(-1, 1) == 0pytest # run everything
pytest test_add.py # one file
pytest -k "negative" # only tests matching a name
pytest -v # show each test nameMany inputs, one test#
parametrize runs the same test once per set of inputs, and reports each one separately when it fails. This replaces copying a test five times with different values.
import pytest
@pytest.mark.parametrize(
"raw, expected",
[
("42", 42),
(" 42 ", 42),
("0", 0),
],
)
def test_parses_int(raw, expected):
assert parse_int(raw) == expectedTesting that something raises#
def test_rejects_bad_temperature():
with pytest.raises(ValueError):
set_temperature(5)
def test_error_message_is_useful():
with pytest.raises(ValueError, match="must be 0 to 2"):
set_temperature(5)Fixtures#
A fixture is setup shared between tests. Declare it once, then take it as an argument in any test that needs it.
@pytest.fixture
def sample_records():
return [
{"id": "1", "score": 0.9},
{"id": "2", "score": 0.4},
]
def test_filters_by_score(sample_records):
result = above(sample_records, 0.5)
assert len(result) == 1Fakes, not mocks everywhere#
Replace the thing at the edge of your system, such as the model client, with a simple object that returns fixed answers. Do not replace your own functions with mocks. A test that only checks a mock was called proves nothing and keeps passing after you break the behaviour.
class FakeModel:
def __init__(self, replies):
self.replies = list(replies)
self.calls = 0
def call(self, prompt):
self.calls += 1
return self.replies.pop(0)
def test_agent_stops_at_turn_limit():
model = FakeModel(["think", "think", "think"])
agent = Agent(model=model, max_turns=2)
agent.run("anything")
assert model.calls == 2What you can test in an AI system#
More than people expect. The model's output is not predictable, but everything around it is, and that is where most bugs live.
- Testable normally: parsing responses, retry logic, budget and turn limits, tool dispatch, building the message list, cost calculation.
- Not unit testable: whether the model's answer is good. That needs evals, which are a different tool for a different job.
Keep the two apart
Separating deterministic logic from model-dependent behaviour is what makes an AI system debuggable. If the parsing and the judgement are tangled in one function, you cannot test either of them.
Where people go wrong
Writing tests that assert on exact model output. Model output varies between runs even at temperature zero, so the test flakes and eventually gets deleted. Assert on structure and properties instead.
Build this: Write tests for a retry function: it succeeds first time, it succeeds after two failures, and it gives up after the limit. Assert on the number of attempts made, not on a mock.
Open this section on its own pageEnvironments, packaging and tooling
By the end: You can set up a project somebody else can clone and run correctly first time.
- Virtual environments, and what they actually do
- Installing and pinning dependencies
- Project layout
- Linting and formatting
- Reading a traceback
Code that runs on your machine and nowhere else is not finished. This section covers what makes a project portable: a virtual environment so each project keeps its own packages and versions, pinned dependencies so a colleague installs exactly what you did, a layout people expect to find, and tools that format and check the code for you. It ends on reading a traceback, which is the single most useful debugging skill in the language.
Virtual environments#
A virtual environment is a folder holding its own copy of Python and its own packages. Each project gets one, so two projects can use different versions of the same library without fighting.
python3 -m venv .venv # create it
source .venv/bin/activate # turn it on (Mac and Linux)
.venv\Scripts\activate # Windows
which python # .../project/.venv/bin/python
deactivate # turn it offActivating simply puts that folder first in your PATH, which is the list of places your shell searches for commands. So typing python finds the project copy instead of the system one. That is the whole mechanism.
Why source and not ./
Running a script normally starts a new shell, which would change its own PATH and then exit, losing the change. source runs the commands in your current shell, so the change stays.
Dependencies#
pip install requests
pip install "pydantic>=2.0"
pip freeze > requirements.txt # record exact versions
pip install -r requirements.txt # install them elsewhereRecording exact versions matters more than it seems. Without it, a project that builds today can break in three weeks because a library you never named published an update. The failure arrives in CI on an unrelated change and costs an afternoon.
Project layout#
myproject/
.venv/ never committed
.env secrets, never committed
.gitignore
pyproject.toml project and dependency declaration
README.md
src/
myproject/
__init__.py
main.py
tests/
test_main.pySecrets belong in a .env file that is listed in .gitignore, and are read at runtime. An API key committed to a public repository is scanned and abused within minutes.
import os
from dotenv import load_dotenv
load_dotenv()
api_key = os.environ["GEMINI_API_KEY"]Tooling#
- ruff: linting and formatting in one tool, fast enough to run every time you save.
- uv: a much faster replacement for pip and venv, worth adopting on new projects.
- A type checker in your editor, so annotations help you while you type rather than in CI.
ruff check . # find problems
ruff check --fix . # fix what it can
ruff format . # format everythingReading a traceback#
Read a traceback from the bottom up. The last line is the actual error. The lines above it are the path of calls that led there, oldest at the top.
Traceback (most recent call last):
File "main.py", line 12, in <module>
result = process(data)
File "process.py", line 8, in process
return item["name"]
KeyError: 'name'When the trace runs through library code, find the last line that belongs to you. That is almost always where the problem is, because the library is usually doing exactly what it was told.
Where people go wrong
Installing packages globally with sudo pip install. It mixes project dependencies into the system Python, and eventually two projects need incompatible versions and something you depend on stops working.
Build this: Set up a project with a virtual environment, a pinned requirements file, a gitignored .env holding a fake API key, and a script that reads that key. Then clone it into a fresh folder and confirm it runs first time.
Open this section on its own pageWhere you use this
A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.
- Stage 1, Turn text into numbers: Building the character-to-index mapping in both directions.
- Stage 3, Graders that need no model: Structuring this as tests with fixtures, so it runs the way the rest of the repo runs.
Now actually do it
Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.
1Rebuild the standard library patterns from memory
6hWithout looking anything up, write: a function that counts word frequencies, one that groups records by a key, one that batches an iterable, and one that retries a callable with backoff. Then compare against Counter, defaultdict, itertools.batched and your own retry decorator.
Done when: All four work first try, and you can name the standard library tool that replaces each.
2A concurrent fetcher with every failure handled
8hFetch 200 URLs with a concurrency cap, per-request timeout, retry with jitter on 429 and 5xx, results written as they complete so it resumes after a crash. Kill it halfway and restart it.
Done when: Restarting skips completed work, one bad URL does not lose the rest, and the run reports which failed and why.
3Validate a real API response
5hTake a public API, define pydantic models for its responses, and handle three malformed payloads with distinct actionable messages. Then generate the JSON schema from the same model.
Done when: One class definition produces both the validation and the schema, and each failure message names the field and what was expected.
4Package and hand it to someone
6hTurn the above into an installable project: pyproject.toml, pinned dependencies, gitignored .env, ruff clean, a README. Clone it into a fresh directory and run it.
Done when: It runs first time from a clean clone, and someone else confirms it.
25 hours of practice
Interview questions
36 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.
Variables, strings and numbers
What is the difference between a list and a tuple?
A list can be changed after creation, a tuple cannot. Because a tuple cannot change, it can be used as a dictionary key and a list cannot. Use a tuple when the group of values is fixed, like a coordinate pair.
Why use join instead of adding strings in a loop?
Strings are immutable, so each + builds a whole new string and copies everything so far. Over many iterations that repeated copying makes the loop much slower. join does it in one pass.
What is the difference between == and is?
== compares values. is compares whether two names point to the same object in memory. Use is only for None, True and False. Using it on numbers or strings seems to work for small values because Python reuses those objects, then fails on larger ones.
What does s[::-1] do?
It reverses the string. The three parts of a slice are start, stop and step, so a step of -1 walks backwards through the whole thing.
Lists, dictionaries and sets
How would you remove duplicates from a list?
list(set(items)) is the quickest way, but it loses the original order. If order matters, use list(dict.fromkeys(items)), because dictionaries keep insertion order in modern Python.
Why is checking membership in a set faster than in a list?
A list has to compare against each item in turn, so the time grows with its length. A set uses a hash table, so it jumps straight to where the item would be. That takes the same time regardless of size.
What happens if you use a list as a dictionary key?
It raises a TypeError. Keys must be hashable, which means they cannot change. Lists can change, so they are not hashable. Use a tuple instead.
What is the difference between sorted(x) and x.sort()?
sorted returns a new sorted list and leaves the original alone. .sort() sorts the list in place and returns None. Assigning the result of .sort() to a variable is a common bug, because you get None.
Loops, conditions and functions
What is wrong with def f(items=[])?
The default list is created once when the function is defined, not on each call, so every call shares the same list. Values accumulate across calls. Use None as the default and create the list inside the function.
What is the difference between break and continue?
break exits the loop completely. continue skips the rest of the current iteration and moves to the next one.
What does a function return if there is no return statement?
None. This is why assigning the result of a method like list.sort() gives you None, because it changes the list in place and returns nothing.
What is the difference between arguments and parameters?
Parameters are the names in the function definition. Arguments are the actual values you pass when calling it. Interviewers ask this to check you can be precise about terminology.
Errors, files and modules
Why should you avoid a bare except?
It catches everything, including KeyboardInterrupt and SystemExit, so the program cannot be stopped normally. It also hides real bugs like typos, turning a crash you would have noticed into silently wrong output.
What is the difference between json.load and json.loads?
load reads from a file object. loads reads from a string. The same distinction applies to dump and dumps.
Why use with when opening a file?
It closes the file automatically when the block ends, even if an exception is raised inside it. Without it, a failure partway through can leave the file open.
When would you create a custom exception class?
When callers need to handle your failure differently from built-in ones. A ModelOutputError can be caught and retried, whereas catching ValueError would also catch unrelated problems.
Type hints and pydantic
Are Python type hints enforced at runtime?
No. Python ignores them while running. They exist for editors and type checkers, which is why data from an API still needs real validation with something like pydantic.
What does dict | None mean in a return type?
The function returns either a dictionary or None. A type checker will then warn if you use the result without checking for None first. Older syntax writes it as Optional[dict].
Why use pydantic rather than checking fields by hand?
It gives you validation, type conversion, clear error messages listing every problem, and a JSON schema, all from one class definition. Hand-written checks drift apart from the shape they are meant to enforce.
Where should validation happen in an application?
At the boundary, as soon as untrusted data arrives. After that point everything downstream can assume the data is the right shape, which keeps the checks in one place.
Async and concurrency
What is the difference between concurrency and parallelism?
Concurrency means several tasks are in progress and taking turns. Parallelism means they genuinely run at the same instant on different cores. Python's asyncio gives you concurrency on one thread, which is enough when the bottleneck is waiting for the network.
When does async not help?
When the work is CPU-bound. Async overlaps waiting, not computing, and there is still one thread. For heavy computation you need multiprocessing.
What happens if you call a blocking function inside a coroutine?
It stalls the entire event loop, so every other coroutine stops until it returns. The symptom is that concurrent code runs at sequential speed. The fix is an async library or asyncio.to_thread.
How would you call an API 1000 times without being rate limited?
Use asyncio.gather with a Semaphore capping how many are in flight, a timeout on each request, return_exceptions=True so one failure does not lose the rest, and retry with exponential backoff on 429 responses.
Generators and working with large data
What is the difference between a list and a generator?
A list computes and stores every item immediately. A generator produces items one at a time on demand and stores only its current position, so memory stays flat no matter how many items there are.
How would you process a file larger than your available RAM?
Loop over the file object rather than calling read or readlines, so it yields one line at a time. Wrap it in a generator if you need to transform the records, and batch it if the next step wants groups.
What happens when you iterate a generator a second time?
You get nothing, and no error. It has been consumed. If you need the values again you must recreate the generator or materialise it into a list.
Can you call len() on a generator?
No. A generator does not know how many items it will produce and cannot look ahead. If you need a count, either convert it to a list or count as you iterate.
Testing
What is the difference between a unit test and an integration test?
A unit test checks one piece in isolation with fast, predictable inputs. An integration test checks that several pieces work together, usually including something real such as a database. You want many of the first and a few of the second.
Why is mocking heavily a problem?
Tests full of mocks assert that your code called your code, which stays true even after you break the behaviour. They also have to be rewritten on every refactor. Fake at the system boundary and let the real logic run.
Is 100% test coverage a good goal?
No. Coverage tells you which lines ran, not whether anything was verified. A suite at 90% that asserts almost nothing is worse than an honest 50%, because it creates confidence that has not been earned.
How do you test code that calls an LLM?
Separate the deterministic parts and test those normally with a fake client returning fixed responses. The quality of the model's output is not a unit test; it needs an eval suite measured over a fixed task set.
Environments, packaging and tooling
Why use a virtual environment?
It isolates a project's dependencies. Without one, two projects needing different versions of the same library conflict, and installing globally can break system tools that rely on Python.
What is the difference between requirements.txt and pyproject.toml?
requirements.txt is a flat list of packages to install. pyproject.toml declares the project itself, including metadata, dependencies and tool configuration, and is the modern standard.
How do you handle secrets such as API keys?
Keep them in environment variables, loaded from a .env file that is gitignored, and read at runtime. Never commit them. In production they come from the platform's secret store rather than a file.
How do you read a Python traceback?
Bottom up. The last line names the exception. The frames above show the call path. Find the last frame in your own code, because that is usually where the mistake is.
Resources
Free to watch and read, picked for this topic.
Videos
For variables, strings and numbers
For lists, dictionaries and sets
For loops, conditions and functions
For errors, files and modules
For type hints and pydantic
For async and concurrency
For generators and working with large data
For environments, packaging and tooling
Full courses: the whole topic, start to finish
Harvard CS50’s Introduction to Programming with Python – Full University Course
freeCodeCamp.org
Harvard's complete Python course: lectures, problems and all. The most thorough free start.
Python for Everybody - Full University Python Course
freeCodeCamp.org
A full university Python course, gentle and thorough. Pairs well with the one above.
Python Full Course for Beginners
Programming with Mosh
Read and explore
Stop 1 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.