Python for AI Engineers

◆ Chapter 03

The core language: values, types, and control flow

Values, types, truthiness and control flow — the everyday syntax you read in every codebase, with the JS gotchas called out.

~2,409 words · chapter 3 of 15

You already think in values and expressions. Python's atoms are close to JavaScript's, but the rules underneath differ in ways that bite. This chapter builds the foundation you need to read GenAI code — the parsing, prompt-building, and response-guarding that fills every agent codebase — and judge it.

3.1 Names, not variables

Python has no let, const, or var. You just assign:

model = "claude-opus-4"   # a name bound to a string object
temperature = 0.7
model = "claude-haiku"    # rebinding the same name — totally fine

A name is a label pointing at an object. Assignment never copies; it points. This is exactly JS reference semantics, but applied uniformly — there is no primitive/object split.

a = [1, 2, 3]
b = a            # b points at the SAME list
b.append(4)
print(a)         # => [1, 2, 3, 4]   ← a changed too

Multiple and tuple assignment is idiomatic and everywhere in real code:

x, y = 10, 20
x, y = y, x                      # swap, no temp var  => x=20, y=10
role, content = ("user", "Hi")   # unpacking a tuple
first, *rest = [1, 2, 3, 4]      # => first=1, rest=[2, 3, 4]

Everything is an object — ints, functions, classes, modules, None. Every value has a type and methods. (42).bit_length() works; functions can be passed around and stored in dicts (the backbone of tool-dispatch tables in agents).

JS → Python: No variable keyword — bare name = value. There is no block scoping ({} blocks don't scope; functions do). No hoisting; a name exists only after it's assigned. const has no equivalent — immutability is a property of the object (see §3.4), not the binding.

Reading & judging: The classic bug is a shared mutable default (def f(items=[])). Since the list is created once and shared across calls, it accumulates state. Good code uses def f(items=None): items = items or []. Flag any mutable default argument on sight.

3.2 The scalar types

Type Example Notes
int 42, 10**100 arbitrary precision — never overflows
float 0.7, 1e-4 64-bit IEEE 754, same as JS numbers
bool True, False Capitalized. A subclass of int
str "hi" Immutable Unicode text
NoneType None The one-and-only "nothing" value
print(2 ** 200)          # => 1606938044258990275541962092341162602522202993782792835301376
print(type(True))        # => <class 'bool'>
print(True + True)       # => 2   ← bool IS an int
print(int("128007"))     # => 128007  (token counts arrive as strings from APIs)

None is Python's only null. There is no undefined. A missing dict key, an unset attribute, a function with no return — all yield None. This single-null design removes an entire class of JS confusion.

def maybe_tool():
    pass                 # no return statement

print(maybe_tool())      # => None

JS → Python: JS has both null and undefined; Python has only None. typeof x === "undefined" checks vanish. bool being an int subclass means sum([True, False, True]) counts truthy items — a real idiom for counting matches over a list.

3.3 Numbers & arithmetic

7 / 2      # => 3.5    true division — ALWAYS returns float
7 // 2     # => 3      floor division (rounds toward -inf)
-7 // 2    # => -4     (not -3!)
7 % 3      # => 1      modulo
2 ** 10    # => 1024   power (JS uses ** too, but has no // or true-division split)

The / vs // split is a top JS-dev trap: in JS 7/2 is 3.5 and you reach for Math.floor. In Python / is always float division and // is the floor operator. Int/float mixing auto-coerces to float: 3 + 0.5 => 3.5.

The sharpest contrast: Python does not coerce strings and numbers.

"tokens: " + 128        # => TypeError: can only concatenate str (not "int") to str
"tokens: " + str(128)   # => 'tokens: 128'   ← convert explicitly
f"tokens: {128}"        # => 'tokens: 128'   ← f-string is cleaner

In JS, "tokens: " + 128 silently gives "tokens: 128". Python raises. This is a feature — it catches the "I meant to format that" bug at runtime instead of shipping garbage into a prompt.

Reading & judging: Seeing str(x) sprinkled everywhere is fine and correct. A red flag is int(user_value) with no surrounding error handling — a non-numeric string raises ValueError. Boundary-parsing of API/user data should be guarded (Chapter on error handling).

3.4 Strings in depth

Strings are immutable — every "modification" returns a new string.

name = "claude"
name.upper()       # => 'CLAUDE'  (new string)
print(name)        # => 'claude'  (original untouched)

Quotes: single and double are identical ('x' == "x"). Triple quotes span lines — the workhorse for system prompts:

SYSTEM_PROMPT = """You are a careful assistant.
Answer only from the provided context.
If unsure, say "I don't know"."""

f-strings are the single most important string feature you'll read. Prefix f, embed expressions in {}:

model, temp, cost = "opus", 0.7, 1234.5
f"Using {model} at temp={temp}"        # => 'Using opus at temp=0.7'
f"{temp * 2}"                          # => '1.4'   (any expression)
f"{cost:.2f}"                          # => '1234.50'   (2 decimal places)
f"{cost:,.2f}"                         # => '1,234.50'  (thousands separator)
f"{0.8734:.1%}"                        # => '87.3%'     (percent)
f"{temp=}"                             # => 'temp=0.7'  (self-documenting — debug gold)

The = specifier (f"{temp=}") prints both the expression and its value — invaluable when reading logging code. Format specs after : control width, precision, and grouping.

Essential methods (all return new values):

"  hi  ".strip()                 # => 'hi'          (trim whitespace)
"a,b,c".split(",")               # => ['a', 'b', 'c']
"-".join(["a", "b", "c"])        # => 'a-b-c'       (note: SEPARATOR.join(list))
"gpt-4".replace("gpt", "claude") # => 'claude-4'
"Bearer sk-123".startswith("Bearer")  # => True
"CLAUDE".lower()                 # => 'claude'
"a,b,,c".split(",")              # => ['a', 'b', '', 'c']  (empty fields kept)

Note .join is called on the separator, not the list — a frequent JS-dev stumble (JS is arr.join("-")).

Raw strings (r"...") disable backslash escapes — vital for regex, Windows paths, and prompts containing literal backslashes:

r"\d+\.\d+"           # => '\\d+\\.\\d+'  — regex stays literal, no escaping doubled
"C:\new"              # => 'C:' + newline!  \n interpreted
r"C:\new"             # => 'C:\\new'        safe

Reading & judging: Good code uses f-strings for interpolation. Seeing "text " + str(x) + " more" is sloppy but works. A genuine red flag: %-formatting or .format() for new code (legacy, error-prone). Another: building prompts with naive + concatenation across many lines instead of a triple-quoted template — harder to audit for injection.

3.5 Booleans and truthiness

Any value can be tested for truth. The falsy values are a fixed, small set:

False, None, 0, 0.0, "", [], {}, set(), ()   ← all falsy

Everything else is truthy. This drives the ubiquitous LLM guard pattern:

if not response:          # empty string, None, or empty list all caught
    raise ValueError("empty model response")

and / or / not are the operators (no && / || / !). Crucially, and/or return an operand, not a bool, and short-circuit:

"" or "default"           # => 'default'   (first truthy, or the last)
"gpt" or "default"        # => 'gpt'
temperature or 0.7        # => 0.7 if temperature is 0/None/""  ← common default idiom
0 and 1/0                 # => 0   (short-circuits, never divides)

The x = a or default idiom is everywhere in config code. But beware: it treats any falsy value as "missing". If temperature = 0.0 is a valid setting, temperature or 0.7 wrongly replaces it with 0.7. The correct guard for "was this actually provided?" is x if x is not None else default.

JS → Python: Truthiness diverges sharply. In JS, [] and {} are truthy; in Python they are falsy. In JS, "0" and " " are truthy; in Python they're truthy too (non-empty strings) — but 0 the number is falsy in both. NaN is falsy in JS; Python's float('nan') is truthy. Don't port truthiness intuition blindly.

3.6 is vs ==

== tests equality of value. is tests identity — same object in memory.

a = [1, 2]
b = [1, 2]
a == b        # => True   (same contents)
a is b        # => False  (different objects)

The one place is is mandatory: checking for None. None is a singleton, so identity is the correct, fast, unambiguous test:

if config is None:        # ✅ correct
    config = {}

if config == None:        # ⚠️ works but wrong style; can be fooled by weird __eq__
    ...

The gotcha: small integers (−5 to 256) and short strings are interned (reused), so is may accidentally work:

x = 256; y = 256
x is y        # => True   (interned)
x = 257; y = 257
x is y        # => False  (not interned — DON'T rely on this)

Reading & judging: x is None / x is not None = correct and idiomatic. x == None = code smell. Using is to compare integers, strings, or any value (count is 0, name is "user") is a bug waiting to happen — it works by luck of interning and fails silently for larger values. Flag every value-comparison done with is.

3.7 Comparisons and chaining

Python lets you chain comparisons the way math does:

0 <= temperature <= 1        # => reads naturally; each term evaluated once
0 <= x < 10                  # => True if x in [0, 10)
low < value < high           # bounds check in one expression

This is a real readability win over JS's 0 <= x && x < 10. Equality (==) works across types safely — 1 == 1.0 is True, 1 == "1" is False (no coercion, unlike JS ==; Python has no === because it never needed one).

3.8 Control flow

Blocks are defined by indentation and a colon, not braces. Four spaces per level, consistently — mixing tabs and spaces is a syntax error.

score = 0.82
if score > 0.9:
    tier = "high"
elif score > 0.5:            # "else if" is spelled elif
    tier = "medium"
else:
    tier = "low"

There is no ?: ternary. Instead, a value-returning conditional expression reads left-to-right:

label = "pass" if score >= 0.5 else "fail"

Loops iterate values, not indices — the biggest JS-dev adjustment:

messages = ["hi", "how are you", "bye"]

for msg in messages:         # msg IS the value, not an index
    print(msg)

for i, msg in enumerate(messages):     # need the index too? use enumerate
    print(i, msg)            # => 0 hi / 1 how are you / 2 bye

for i in range(3):           # range(3) => 0,1,2  (stop-exclusive)
    print(i)

for a, b in zip(["x", "y"], [1, 2]):   # pair up two lists
    print(a, b)              # => x 1 / y 2

range(start, stop, step) is stop-exclusive (range(1, 4) is 1,2,3). while, break, and continue behave as in JS. Python adds a for/while ... else — the else runs only if the loop finished without break (useful for search loops):

for tool in tools:
    if tool.name == wanted:
        break
else:
    raise LookupError(f"no tool named {wanted}")   # runs only if never broke

pass is the explicit no-op — a placeholder where a statement is syntactically required:

try:
    risky()
except TimeoutError:
    pass          # deliberately ignore (be sure that's intended!)

Structural pattern matching (match/case, Python 3.10+) is modern and increasingly common for dispatching on message roles or tool payloads:

def handle(event):
    match event:
        case {"type": "text", "text": body}:        # dict pattern, binds body
            return body
        case {"type": "tool_use", "name": name}:
            return f"calling {name}"
        case {"type": "error", "code": int() as code} if code >= 500:
            return "server error, retry"            # guard with if
        case _:                                     # _ is the wildcard default
            return "unknown"

print(handle({"type": "text", "text": "hi"}))       # => 'hi'

case _ is the catch-all. Patterns can destructure dicts, lists, and classes, and bind names as they match — far more powerful than a switch.

JS → Python: switchmatch, but with destructuring. for (const x of arr)for x in arr. for (let i=0; i<n; i++)for i in range(n). arr.forEach((v,i)=>…)for i, v in enumerate(arr). There is no C-style for(;;) — you always iterate over something.

JS → Python operator & keyword cheat sheet

Concept JavaScript Python
Logical and/or/not && || ! and or not
Strict equality === == (no coercion; no === needed)
Identity (none) is / is not
Ternary c ? a : b a if c else b
Null check x == null x is None
Default value x ?? d / x || d x if x is not None else d / x or d
Floor divide Math.floor(a/b) a // b
Power a ** b a ** b
Type check typeof x type(x) / isinstance(x, T)
String→number Number(s) int(s) / float(s)
Loop over values for..of for x in seq
Loop with index .forEach((v,i)=>) enumerate(seq)
Switch switch match/case
No-op ; pass

Reading & judging (control flow): Four recurring bugs to hunt for. (1) Index-iteration hangoverfor i in range(len(items)): items[i]… when a plain for item in items would do; verbose and off-by-one-prone. (2) Mutable/immutable confusion — code that "modifies" a string in place (s.replace(...) on its own line, result discarded) is a no-op, because strings are immutable and .replace returns a new string. (3) == vs isif status is "done" is a latent bug. (4) Truthiness on containersif len(items) > 0 is fine but noisy; if items is idiomatic. The real danger is if value or default swallowing a legitimate 0 or "" — verify the author didn't mean if value is None.

Try it: Run x = 0; print(x or "fallback") then print(x if x is not None else "fallback"). Watch the first print fallback and the second print 0 — that gap is a genuine production bug in config-loading code, and now you can spot it in a review.