Contents
Chapter 2

Tour

You know a language, and Python differs from it in a few places that matter more than the syntax.

This chapter and the ones that follow give a programmer’s tour of Python: syntax and the built-in numbers, strings, and None here, then containers, control flow, functions, modules, classes, static typing, class attributes, and object cleanup. The tour assumes you have programming experience. Find supplementary information in the official language documentation.

Scripting vs. Programming

Python exists to improve your productivity. The language aims to aid you as much as possible. It tries to hinder you as little as possible. It does not impose arbitrary rules or force a particular set of features.

People often call Python a scripting language, but scripting languages tend to limit you, especially in the range of problems they can solve. Python is a programming language that also supports scripting. It is marvelous for scripting, and you may replace all your batch files, shell scripts, and simple programs with Python scripts.

How to Read the Examples

The # denotes a comment that goes until the end of the line, just like C++ and Java // comments. The Examples explains the filename comment on a listing’s first line and the #: output markers.

Indentation and Blocks

Python is clean to write. Your own code stays easy to read long after you’ve written it. Indentation determines how statements group into blocks. This script runs with python if.py:

# if.py

response = "yes"
if response == "yes":
    print("affirmative")
    val = 1  # [1]
#: affirmative
print("continuing...")  # [2]
#: continuing...
print(val)
#: 1

A C/C++ if requires parentheses around the conditional. Python makes them optional.

The conditional clause ends with a colon. A group of indented statements follows: the “then” part of the if statement. The print() function sends its arguments to standard output, separated by spaces and followed by a newline. The line at [1] assigns to a variable named val. The statement at [2] returns to the left margin, and that return ends the if.

An indented block groups statements without creating a scope, so val, assigned inside the if, stays visible afterward, unlike a variable declared inside braces in C++ or Java. New scopes come from functions, classes, modules, and comprehensions, not from an if or a for block.

Python binds a name only when the assignment runs. With any answer other than "yes", the if skips val = 1, and print(val) raises a NameError.

# unbound_val.py

response = "no"
if response == "yes":
    val = 1
try:
    print(val)  # type: ignore
except NameError as e:
    print(e)
#: name 'val' is not defined

Python skips the body of the if, so val stays unbound. Indentation shows where the assignment sits, not whether it runs.

The type checker sees that val = 1 cannot run and reports an error on print(val), so # type: ignore tells it the mistake is deliberate. The try and except catch the exception so the script can print the exception’s message and finish.

Indenting can nest as deeply as you like. Four spaces per level is the convention, and mixing tabs and spaces inconsistently inside one block raises a TabError.

C++ and Java programmers debate where braces go. In Python the indentation is the structure, so the language settles the question and taste plays no part. Python code from any two authors therefore lines up the same way, and that sameness is one of the main reasons for Python’s consistent readability.

A statement ends with its line, so it needs no terminating semicolon. A semicolon’s one job is to separate two statements that share a line. An open parenthesis, bracket, or brace keeps the statement going across lines until it closes, and the indentation of those continued lines is yours to choose. The longer calls in tstrings.py wrap that way.

Variables and References

A variable in Python is a name bound to an object, not a box that holds a value. Assignment binds a name. It does not copy. Python does not require you to declare a variable’s type, and one name can bind to objects of different types over its life. That freedom is dynamic typing.

# references.py

x = 10  # x names an int
x = "ten"  # The same name now binds to a str
a = [1, 2, 3]
b = a  # b binds to the same list, not a copy
b.append(4)
print(a)  # The same object: a and b
#: [1, 2, 3, 4]
print(a is b)  # Identical objects
#: True
c = a[:]  # Copies the list, not its contents
print(a is c, a == c)  # Different object, equal value
#: False True
nested = [[1], [2, 3]]
shallow = nested[:]
shallow[1].append(99)
print(nested)  # The inner list is shared
#: [[1], [2, 3, 99]]

Use == to ask whether two objects have equal values. Use is to ask whether two names refer to the same object. Reserve is for None and other singletons.

a[:] is a shallow copy: it duplicates the outer list but not the objects inside it. nested and shallow still share the same inner list, so shallow[1].append(99) changes what nested sees too. The standard library’s copy.deepcopy() duplicates the inner objects as well.

Numbers, strings, and tuples are immutable: operations produce new objects rather than changing the original. Lists, dictionaries, and sets are mutable. Mutability decides whether another name sees a change, as a sees the 4 appended through b in references.py.

You can assign several names at once, so a swap needs no temporary:

# multiple_assignment.py

a, b = 1, 2
a, b = b, a  # Swap without a temporary
print(a, b)
#: 2 1
first, *rest = [10, 20, 30, 40]
print(first, rest)
#: 10 [20, 30, 40]

*rest collects whatever remains. Containers covers the general form.

Numbers and Arithmetic

Integers have unlimited precision, so they cannot overflow. Underscores group digits for readability, so 10_000_000 is the same literal as 10000000. Floating point is the usual IEEE double.

The operators are what you expect, with two worth noting: / on integers always produces a float, and // is floor division (divide, then round down to a whole number). The result’s type follows the operands, so 7.0 // 2 is 3.0. Floor division rounds toward negative infinity, not toward zero, so -7 // 2 is -4 where C and Java give -3. The remainder follows from that, so -7 % 2 is 1 in Python and -1 in C. The sign of % matches the divisor.

# arithmetic.py

print(7 / 2)  # True division, always a float
#: 3.5
print(7 // 2)  # Floor division
#: 3
print(7 % 2)  # Remainder
#: 1
print(-7 // 2, -7 % 2)  # Floors, not truncates toward zero
#: -4 1
print(2 ** 10)  # Exponentiation
#: 1024
print(10 ** 30)  # A 31-digit int, no overflow
#: 1000000000000000000000000000000
print(abs(-5), round(3.14159, 2))
#: 5 3.14
print(round(0.5), round(1.5), round(2.5))  # Ties go to even
#: 0 2 2
scores = [90, 0, 71, 0, 55]
print(sum(s > 60 for s in scores))  # True counts as 1
#: 2

round() breaks a tie to the nearest even value, so round(0.5) is 0 and both round(1.5) and round(2.5) are 2, rather than rounding half away from zero as C’s round() does. An f-string’s format spec rounds the same way.

A bool is a subtype of int, so True equals 1 and False equals 0. Summing a sequence of comparisons therefore counts how many are true.

The argument to sum() is a generator expression, which hands over one value at a time instead of building a list first.

Python has no ++ or --. Use += 1 and -= 1. Each arithmetic operator has an augmented-assignment form: +=, -=, *=, /=, //=, %=, and **=.

# augmented.py

total = 0
total += 5  # Augmented assignment, like other languages
print(total)
#: 5
items = [1, 2]
alias = items
items += [3]  # In place, so alias sees it
print(alias)
#: [1, 2, 3]
items = items + [4]  # A new list, alias keeps the old one
print(alias, items)
#: [1, 2, 3] [1, 2, 3, 4]

Augmented assignment on a mutable object changes it in place, so every other name for it sees the change. For a list, += does what extend() does. items = items + [4] instead builds a new list and rebinds items to it, which leaves alias bound to the old one. For an int, both forms rebind the name, so total += 5 behaves the way += does in any other language.

Integers also support the bitwise and shift operators, each with a matching augmented form (&=, |=, ^=, <<=, >>=). Binary literals, which start with 0b, make the bit patterns readable:

# bitwise.py

print(bin(0b1100 & 0b1010))  # AND, bits set in both
#: 0b1000
print(bin(0b1100 | 0b1010))  # OR, bits set in either
#: 0b1110
print(bin(0b1100 ^ 0b1010))  # XOR, bits set in exactly one
#: 0b110
print(bin(~0b1100))  # NOT, inverts every bit
#: -0b1101
print(bin(1 << 4))  # Left shift, same as 1 * 2 ** 4
#: 0b10000
# Right shift, same as 48 // 2 ** 2
print(bin(0b110000 >> 2))
#: 0b1100

flags = 0
flags |= 0b0010  # Set bits with the augmented form
flags |= 0b1000
print(bin(flags))
#: 0b1010

The bin() function converts an integer to a binary string for display. Because a Python integer grows as wide as its value needs, ~ has no set number of bits to flip. ~x produces -x - 1, the value that flipping every bit gives in two’s complement. bin() prints that as a sign followed by a magnitude, so ~0b1100 reads as -0b1101 rather than a row of ones.

Python reserves one further operator, @ (with @= to match), for matrix multiplication. The built-in numeric types do not implement it, but array libraries such as NumPy do. The same character in front of a def or a class means something else. That is decorator syntax.

Booleans, None, and Truthiness

None is Python’s single “no value” object, like null elsewhere. It is the default return value of a function that returns nothing.

You can test any object in a boolean context. Numbers are false when zero, containers are false when empty, and None is always false. Everything else is true, unless an object’s type says otherwise. That rule is truthiness, and it lets you write if items: instead of if len(items) != 0:. A type says otherwise by defining __bool__(). Without one, Python falls back to __len__(), and that fallback is why an empty container is false.

# truthiness.py

for value in [0, 1, "", "hi", [], [1], None]:
    print(repr(value), "->", bool(value))
#: 0 -> False
#: 1 -> True
#: '' -> False
#: 'hi' -> True
#: [] -> False
#: [1] -> True
#: None -> False

class Bucket:
    def __init__(self, count: int) -> None:
        self.count = count

    def __len__(self) -> int:
        return self.count

print(bool(Bucket(0)), bool(Bucket(3)))
#: False True

if not []:
    print("empty")  # An empty list is falsy
#: empty

repr() returns a value’s unambiguous representation, so the empty string shows as '' and not as blank. Bucket defines no __bool__(), so bool() falls back to its __len__(). Bucket(0) is false and Bucket(3) is true.

Bucket is a small class. Its count: int and -> int are type hints. They state the types for the reader and the type checker and change nothing about how the code runs.

and and or short-circuit and return one of their operands, not a coerced boolean:

# or_fallback.py

# 'or' returns the first truthy operand
name = "" or "default"
print(name)
#: default
items = []
print(items and items[0])  # Stops at the falsy operand
#: []
count = 0
print(count or 10)  # 0 is falsy, so the fallback wins
#: 10
print(10 if count is None else count)  # Keeps the 0
#: 0

and stops at its first falsy operand and returns it, so Python skips items[0] on the empty list. x or default is a common way to supply a fallback, and it replaces every falsy x. A legitimate 0 or "" gets the fallback the same as a missing value does. When zero or an empty string is a legal value, test for None instead: default if x is None else x. That is a conditional expression.

Strings

String Literals

Single or double quotes create strings. If you surround a string with double quotes, you can embed single quotes and vice versa:

# strings.py

print("That isn't a horse")
#: That isn't a horse
print('You are not a "Viking"')
#: You are not a "Viking"
print("""
You're just pounding two
coconut halves together.
""")
#:
#: You're just pounding two
#: coconut halves together.
#:
print('''
"Oh no!" He exclaimed.
"It's the blancmange!"
''')
#:
#: "Oh no!" He exclaimed.
#: "It's the blancmange!"
#:
print(r'c:\python\lib\utils')
#: c:\python\lib\utils

Python’s name comes not from the snake but from the Monty Python comedy troupe, of which the language creator Guido van Rossum is a fan. Examples often include Python-esque references.

The triple-quote syntax quotes everything, including newlines. That suits any block of literal text, such as an embedded template, a SQL query, or a chunk of HTML. You can write it out in full without escaping line breaks.

In an ordinary string, a backslash starts an escape sequence, as in C and Java. \n is a newline and \t is a tab. The r right before a string means “raw”: Python takes each backslash literally, as a single character. Without the r, the last literal in strings.py is a syntax error. \u starts a Unicode escape, and tils is not the four hex digits it requires. One limit remains. A raw string cannot end with a single backslash, because a raw string’s backslash still escapes the closing quote.

Common String Operations

Strings are immutable sequences with a large set of methods. Slicing selects a range of characters, and in tests for a substring:

# string_methods.py

s = "  Hello, World  "
print(s.strip())
#: Hello, World
print(s.strip().lower())
#: hello, world
print("World" in s)
#: True
print("a,b,c".split(","))
#: ['a', 'b', 'c']
print("-".join(["2024", "06", "15"]))
#: 2024-06-15
print("ababab".replace("a", "X"))
#: XbXbXb
print(s.strip()[0:5])
#: Hello

String methods return new values rather than changing the original. replace() replaces every occurrence, as XbXbXb shows; an optional third argument caps how many it replaces.

strip() and its one-sided forms lstrip() and rstrip() take an optional argument, but it is a set of characters, not a prefix or suffix. "test_setup".lstrip("test_") removes every leading t, e, s, and _, leaving "up". To remove one exact piece from an end, use removeprefix() or removesuffix(). "test_setup".removeprefix("test_") is "setup", and "notes.txt".removesuffix(".txt") is "notes".

+ joins two strings, and it converts nothing:

# concatenate.py

total = 7
try:
    print("total: " + total)  # type: ignore
except TypeError as e:
    print(e)
#: can only concatenate str (not "int") to str
print("total: " + str(total))
#: total: 7

Java and JavaScript turn the 7 into text and join it. Python requires two strings, so you write the conversion, either with str() or inside an f-string.

f-Strings

Modern Python uses f-strings. Prefix the string with f and put expressions in curly braces. F-strings are readable and fast:

# fstrings.py

name = "Alice"
score = 91.5
print(f"{name} scored {score}")
#: Alice scored 91.5
print(f"{name} scored {score:.0f}%")
#: Alice scored 92%
print(f"{name!r} has {len(name)} letters")
#: 'Alice' has 5 letters
total = 7
print(f"{total = }")  # Useful for debugging
#: total = 7
print(f"|{name:>10}|{score:<8.1f}|")
#: |     Alice|91.5    |

The format spec after a colon controls width, precision, and alignment. > right-aligns and < left-aligns within the given width. A !r on the expression, as in {name!r}, formats the value with repr() instead of str(). An = after the expression, as in {total = }, prints the expression’s text, the = with any spaces you put around it, and then the value. With no format spec the value appears as its repr(), so f"{name=}" produces name='Alice'. Use = to label values in your own exploring and debugging code.

Existing code also carries two older styles: C’s printf() syntax, as in "val: %d" % val, and the str.format() method, as in "val: {}".format(val). Both still work. str.format() shares the f-string’s format mini-language, while the % form has its own, inherited from C’s printf(). F-strings replaced them.

t-Strings

An f-string produces a finished str, deciding how each value becomes text before anything else sees it. A t-string (Python 3.14, PEP 750) produces a Template instead, which keeps the literal pieces and the interpolated values apart for a consumer to assemble.

The reason to care is safety. A consumer that receives the parts separately knows which text came from the program and which came from a value, so it can quote, escape, or reject the values before they become part of the result.

# tstrings.py
from string.templatelib import Interpolation, Template

name = "Alice"
score = 91.5
message: Template = t"{name} scored {score:.0f}%"
print(message.strings)
#: ('', ' scored ', '%')
print([piece.expression
       for piece in message.interpolations])
#: ['name', 'score']

def shout(template: Template) -> str:
    parts: list[str] = []
    for piece in template:
        if isinstance(piece, Interpolation):
            parts.append(
                format(piece.value, piece.format_spec))
        else:
            parts.append(piece.upper())
    return "".join(parts)

print(shout(message))
#: Alice SCORED 92%

def safe(template: Template) -> str:
    parts: list[str] = []
    for piece in template:
        if isinstance(piece, Interpolation):
            value = str(piece.value)
            if "'" in value:
                raise ValueError(
                    f"unsafe value: {value!r}")
            parts.append(value)
        else:
            parts.append(piece)
    return "".join(parts)

print(safe(t"Hello, {name}"))
#: Hello, Alice
trouble = "Bob'; rm -rf /"
try:
    safe(t"Hello, {trouble}")
except ValueError as e:
    print(e)
#: unsafe value: "Bob'; rm -rf /"

Iterating over a Template produces the pieces in order, each either a str the author typed or an Interpolation carrying a value. An Interpolation also remembers the source text of the expression that produced it, and piece.expression reports that text. Collecting every piece.expression above uses a list comprehension, which has the same kind of for clause as the generator expression in arithmetic.py but builds a list. Iteration skips empty literal strings, so the leading '' in message.strings does not reach the loop. That skipping is why a consumer cannot assume that literals and interpolations alternate.

shout() uppercases the literal text and leaves the interpolated values in their original case. No amount of work on a finished f-string could do that reliably, because the finished string no longer says which characters came from where. safe() puts that separation to work. It passes the literal text through unchanged, and it rejects an interpolated value that contains a single quote, the way trouble does above. An f-string finishes assembling the result, quote and all, before any code can object. Uppercasing and rejecting are small demonstrations. Composite and Interpreter builds a full query from the parts the same way.

Keep using f-strings for ordinary output. Use a t-string only when a consumer must inspect, escape, or reject the interpolated values before they become part of the result, the way safe() does above.

Naming Conventions

Use snake_case for variables, functions, methods, and file names: lower case with words separated by underscores, as in this_is_snake_case.

For a constant, use all uppercase letters, as in THIS_IS_A_CONSTANT.

Class names are CapWords (Pascal cased): every word, including the first, begins with a capital letter, and no underscores separate them. For example: ThisIsMyClass.

When users call a class the way they call a function, that class may use snake_case instead, so the name reads as the action the call performs and the caller treats it as a function. The standard library names contextlib.suppress, functools.partial, and the built-ins property and staticmethod that way. Name every other class CapWords.

PEP 8 covers style issues. Tools such as ruff point out violations and fix many of them automatically.

Exercises

This chapter’s solutions give a hint, usually the shape of the code, and a full answer for each exercise.

  1. In references.py, add a line after c = a[:] that appends 99 to c. Print a and c and confirm that c changed and a still holds [1, 2, 3, 4], then explain why b.append(4) earlier did change what a sees, but appending to c does not.
  2. In truthiness.py, add an empty dictionary {} and a dictionary with one entry to the list of test values. Predict what bool() reports for each before running it, then check your prediction.
  3. In fstrings.py, add a line that formats score with two decimal places instead of zero, using {score:.2f} in place of {score:.0f}%, and a second line using the debug specifier, f"{score = }".
  4. augmented.py defines total and bitwise.py defines flags. Rename them to totalSum and flagBits, then to TOTAL_SUM and FLAG_BITS. Every version runs. Using Naming Conventions, say what each form signals to a reader who did not write the code, and which of the three a linter flags.
  5. In tstrings.py, write a third consumer, quoted(template), that wraps every interpolated value in single quotes and leaves the literal text alone, then print quoted(message). Explain why you cannot post-process an f-string the same way.
  6. Before running anything, write down what C or Java prints for -9 / 4 and -9 % 4 using integer math, then what Python prints for -9 // 4 and -9 % 4. Run print(-9 // 4, -9 % 4) and check. State the rule that predicts the sign of the result of %.