Filter concepts by levelShowing all levels.

Python · Python Fundamentals

Core language

Concepts
24
Python overview

Names and objects

The model the rest of the section is built on.

Variables, objects, names, and references

corebeginner

A name is a label you attach to an object. Assignment attaches the label; it never copies the object. Two names can label one object, so a change made through one name is visible through the other.

Think of it as

Objects sit in memory. Names are sticky labels you put on them. An = moves a label onto an object; it never clones what the label is stuck to.

python
retry_budget = [1, 2, 4]       # bind a name to a list object
backup_budget = retry_budget   # second name for the SAME object
backup_budget is retry_budget  # True — one object, two names
backup_budget = [1]            # rebind: moves only backup_budget

What we're doing: Show that a second name does not make a second list, and that rebinding differs from mutating.

retries.pypython
retry_budget = [1, 2, 4]
backup_budget = retry_budget

print(retry_budget is backup_budget)
print(id(retry_budget) == id(backup_budget))

backup_budget.append(8)
print(retry_budget)

backup_budget = [1]
print(retry_budget)
print(retry_budget is backup_budget)
1
Builds one list object and binds the name retry_budget to it.
2
Binds a second name to that same object. No list is copied here.
4–5
Both ask the same question — same object? — and both answer yes.
7
Mutates the shared list through the second name.
8
retry_budget shows the 8 as well, because there was only ever one list.
10
Rebinding, not mutating: only backup_budget moves, onto a new list.
11–12
retry_budget still holds the original list, and the two names now differ.
Output
True
True
[1, 2, 4, 8]
[1, 2, 4, 8]
False

Why this works: Assignment binds a name to an object rather than copying it, so line 2 gives one list a second name. Mutating through either name changes that single object. Rebinding on line 10 points one name at a different object and leaves the other name where it was.

A shallow copy still shares what is inside

Wrong

python
import copy

default_config = {"retries": [1, 2, 4]}
job_config = copy.copy(default_config)
job_config["retries"].append(8)

print(default_config)

Better

python
import copy

default_config = {"retries": [1, 2, 4]}
job_config = copy.deepcopy(default_config)
job_config["retries"].append(8)

print(default_config)

What you see: The wrong version prints {'retries': [1, 2, 4, 8]} — the defaults changed even though a copy was made.

Why: copy.copy builds a new dict, but binds its values to the same objects the original holds. The inner list is shared, so appending through the copy is visible through the original. copy.deepcopy rebuilds the nested objects too.

Two names, one object

One list, two labels — a change through either is visible through both

  • Two name labels sit on the left, one above the other: retry_budget and backup_budget.
  • An arrow runs from each label to a single box on the right holding the list [1, 2, 4].
  • There is one object and two names, so id(retry_budget) and id(backup_budget) are equal.
  • Appending through either name changes that one shared list, so the other name sees it too.

Mutable, and usable as a key

Mutable, and usable as a key
TypeMutableHashable
int, floatnoyes
str, bytesnoyes
tuplenoonly if every item is
listyesno
setyesno
dictyesno

Together

python
point = (1, 2)
tags = ["x", "y"]

hash(point)   # -3550055125485641917 — usable as a dict key
hash(tags)    # TypeError: unhashable type: 'list'

Remember: Assignment binds a name; it never copies an object. Two names on one list means an append through either is visible through both.

See also: lists · tuples · slicing

Advertisement

Scalar types

The values that stand on their own.

Numbers

standardbeginner

Python has three number types: int, float and complex. int is exact with no size limit. float is a binary approximation, so 0.1 + 0.2 gives 0.30000000000000004, not 0.3.

Think of it as

An int is a written number, as long as it needs to be. A float is a fixed 64-bit slot that holds the nearest binary value it can, which is often not the decimal you typed.

python
7 / 2      # 3.5  — / is true division, the result is always a float
4 / 2      # 2.0  — still a float
7 // 2     # 3    — // is floored division
-7 // 2    # -4   — it floors toward negative infinity, not toward zero
7 % 2      # 1    — the matching remainder
2 ** 100   # 1267650600228229401496703205376 — int has no size limit

What we're doing: See where a float loses a decimal value, and confirm that an int never does.

numbers_demo.pypython
from decimal import Decimal

order_total = 0.1 + 0.2
print(order_total)
print(order_total == 0.3)
print(Decimal('0.1') + Decimal('0.2') == Decimal('0.3'))

key_space = 2 ** 100
print(key_space)
print(-7 // 2, -7 % 2)
3
Neither 0.1 nor 0.2 has an exact binary form, so each is stored as the nearest double.
4
Printing shows the error that survived the addition.
5
== compares the stored values exactly, so the sum is not equal to 0.3.
6
Decimal works in base 10, so the same sum is exact.
8–9
An int grows to fit: 2 ** 100 prints all 31 digits, with nothing rounded.
10
// floors toward negative infinity, so -7 // 2 is -4 and the remainder is +1.
Output
0.30000000000000004
False
True
1267650600228229401496703205376
-4 1

Why this works: A float is IEEE-754 binary64: a fixed 64-bit slot holding the nearest binary fraction to what you wrote. One tenth is not representable in binary, so the error exists before any addition happens. An int has no fixed width, so it stays exact however large it gets.

The numeric tower — each widens the one below

complex

A pair of floats, written 2 + 3j. Real part and imaginary part.

float

IEEE-754 double. Approximate: 0.1 + 0.2 is 0.30000000000000004.

int

Exact whole number. Grows to any size, so nothing ever overflows.

  1. complex — A pair of floats, written 2 + 3j. Real part and imaginary part.
  2. float — IEEE-754 double. Approximate: 0.1 + 0.2 is 0.30000000000000004.
  3. int — Exact whole number. Grows to any size, so nothing ever overflows.

Comparing floats with ==

Wrong

python
measured_rate = 0.1 + 0.2
if measured_rate == 0.3:
    print("on target")
else:
    print("off target")

Better

python
import math

measured_rate = 0.1 + 0.2
if math.isclose(measured_rate, 0.3):
    print("on target")
else:
    print("off target")

What you see: The wrong version prints "off target" for a value that reads as 0.30000000000000004.

Why: The sum lands a fraction above 0.3, so == is False. math.isclose asks whether two floats are within a tolerance, which is the question you meant. For money, use decimal.Decimal instead and keep the comparison exact.

Every arithmetic operator

Every arithmetic operator
WrittenResult
7 / 23.5 — true division, always a float
7 // 23 — floor division
-7 // 2-4 — floors toward negative infinity, not zero
7 % 21 — the matching remainder
-7 % 21 — same sign as the divisor, not the dividend
divmod(7, 2)(3, 1) — quotient and remainder together
2 ** 101024 — also how you write a square root: x ** 0.5
abs(-5)5

Together

python
retries, cap = 7, 2

retries // cap             # 3 — how many full batches fit
retries % cap               # 1 — what is left over
divmod(retries, cap)        # (3, 1) — both, in one call

Comparing numbers

Comparing numbers
WrittenResult
1 < 2 < 3True — chained, means (1 < 2) and (2 < 3)
1 < 3 < 2False — the second half fails
5 == 5.0True — int and float compare by value
round(2.5)2 — rounds to the nearest EVEN number, not up
round(3.5)4 — the same rule; 2 and 4 are both even

Together

python
retries = 7

0 <= retries < 10           # True — a chained range check, one comparison
round(retries / 2, 1)       # 3.5 — round(value, ndigits)

Remember: The / operator always returns a float, and // floors toward negative infinity — so -7 // 2 is -4, not -3.

See also: names and references · strings

Strings

corebeginner

A str is an immutable sequence of Unicode code points. Nothing edits one in place: every method that looks like a change returns a new string and leaves the original alone.

Think of it as

A string is a printed label, not a whiteboard. You cannot rub out a letter — you print a second label and point the name at that one instead.

python
tag = "  Async  "
parts = ["python", "async"]

tag.strip()           # 'Async' — a new str; tag is unchanged
tag[2]                # 'A' — a 1-character str
f"tag={tag.strip()}"  # 'tag=Async' — the default way to format
", ".join(parts)      # 'python, async' — one pass, one allocation

What we're doing: Turn one line of user-supplied tags into a clean, de-duplicated label without touching the original input.

tags.pypython
raw_tags = "  Python , WEB-dev ,, python , Async  "
seen = set()
clean = []

for tag in raw_tags.split(","):
    normalised = tag.strip().lower()
    if normalised and normalised not in seen:
        seen.add(normalised)
        clean.append(normalised)

label = ", ".join(clean)
print(f"{len(clean)} tags: {label}")
print(f"one character: {label[0]!r}")
print(f"input unchanged: {raw_tags!r}")
1
One string as it arrived: stray spaces, mixed case, an empty entry and a duplicate.
5
split(",") hands back a list of new strings. raw_tags itself is never touched.
6
strip() returns a new string, then lower() returns another. Neither edits tag.
7–9
Drop the empty entry and anything already seen, keeping first-seen order.
11
join walks the list once and copies each piece exactly once — the linear way to build a string.
12
An f-string evaluates each expression in braces and formats the results into a new string.
13
label[0] is a one-character string, not a character. Python has no char type.
14
raw_tags still has its original spaces and capitals. Nothing above could have changed it.
Output
3 tags: python, web-dev, async
one character: 'p'
input unchanged: '  Python , WEB-dev ,, python , Async  '

Why this works: Not one line here changes a string. split, strip, lower and join each return a fresh str and leave their input alone, which is why raw_tags reads the same at the end as at the start. Building the result with join means every character is copied once, rather than once per tag.

Trying to edit a string in place

Wrong

python
tag = "async"
tag[0] = "A"

Better

python
tag = "async"
tag = tag.capitalize()   # 'Async'

What you see: TypeError: 'str' object does not support item assignment

Why: A str has nowhere to write. You rebind the name to a new string instead; the old string is untouched and is collected once nothing refers to it.

A string, and its replacement

Every method that "changes" a string returns a second one and leaves the first.

  • The string "async" is drawn as five boxes in a row, one box per code point, indexed 0 to 4 underneath.
  • The box at index 2 is highlighted: tag[2] is the one-character string "y". Python has no separate character type, and no box can be rewritten.
  • An arrow leads down to a second, separate row of five boxes holding A, S, Y, N and C — the result of tag.upper().
  • That second row is a new string object. The first row is unchanged, because tag still names it.

The str methods worth knowing

The str methods worth knowing
CallGives back
" a ".strip()'a'
"py".upper()'PY'
"PY".lower()'py'
"a,b".split(",")['a', 'b']
"-".join(["a", "b"])'a-b'
"ab".replace("a", "X")'Xb'
"report.py".startswith("rep")True
"report.py".endswith(".py")True
"a,b,c".count(",")2
"a,b".find("b")2
"a,b".find("z")-1 — no exception, unlike .index()
"tmp_log".removeprefix("tmp_")'log'
"log.txt".removesuffix(".txt")'log'
"7".zfill(3)'007'
"a\nb".splitlines()['a', 'b']
"k=v".partition("=")('k', '=', 'v')
"a".center(5, "-")'--a--'
"user_42".isidentifier()True
"42".isdigit()True
"Straße".casefold()'strasse' — for caseless comparison
"py".encode("utf-8")b'py' — a bytes, not a str

Together

python
raw = "  Async-Task  "
cleaned = raw.strip()          # 'Async-Task'
cleaned.lower()                 # 'async-task'
cleaned.startswith("Async")     # True
cleaned.replace("-", "_")       # 'Async_Task'
"/".join(cleaned.split("-"))    # 'Async/Task'

Operators, and the older formatting

Operators, and the older formatting
WrittenResult
"py" + "thon"'python' — both sides must be str
"-" * 5'-----'
"th" in "python"True — substring test, not a character test
len("python")6 — characters, not bytes
"python"[-1]'n' — counts from the right
"a" < "b"True — compares by code point
"Z" < "a"True — every uppercase letter sorts before lowercase
r"a\nb"a backslash and an n, not a newline
"%s=%d" % ("a", 1)'a=1' — the pre-f-string style, still in old code

Together

python
word = "py" + "thon"   # 'python' — + only works str-to-str

word[-1]                 # 'n' — negative indices count from the right
"th" in word              # True — substring test

Remember: No string method edits in place — every one returns a new str. Build with "".join(parts), not += in a loop.

See also: names and references · bytes · slicing · f strings

f-strings

corebeginner

An f-string embeds expressions directly inside a string literal: f"{price}" evaluates price and inserts it. A colon after the expression switches on a small formatting language for width, precision and alignment.

Think of it as

Think of an f-string as a template with two parts per slot: what to compute (the expression) and how to print it (the format spec after the colon). Python fills in the first and formats with the second — leave the spec off and you get whatever repr the value happens to have, decimals and all.

python
total = 59.699999999999996

f"{total}"        # '59.699999999999996' — no spec, no rounding
f"{total:.2f}"    # '59.70' — 2 digits after the point
f"{total=}"       # 'total=59.699999999999996' — name and value, for debugging

What we're doing: Print a receipt line for a priced item without showing sixteen digits of floating-point noise.

receipt.pypython
price = 19.9
quantity = 3
total = price * quantity

print(f"{quantity} x ${price} = ${total}")
print(f"{quantity} x ${price:.2f} = ${total:.2f}")
3
price * quantity is ordinary float arithmetic — it inherits the usual binary rounding.
5
No format spec on price or total: each prints str() of whatever float it actually is.
6
:.2f fixes two digits after the point on both values — this is the line meant for a customer.
Output
3 x $19.9 = $59.699999999999996
3 x $19.90 = $59.70

Why this works: A float is stored in binary, so most decimal amounts — 19.9 included — are not exact, and multiplying compounds the error into visible noise. str() does not hide this; :.2f rounds for display without touching the underlying value used for further arithmetic.

Printing a computed float with no format spec

Wrong

python
print(f"Total: ${total}")

Better

python
print(f"Total: ${total:.2f}")

What you see: Total: $59.699999999999996 — technically correct, unusable on a receipt.

Why: f"{total}" is exactly str(total). There is no implicit rounding for money or for anything else; the format spec is what asks for it.

The four parts of a replacement field

f"{total!r:>10}"

f"

The f prefix — Marks the string as an f-string. Without it, {total} is literal text.

{total

The expression — Any Python expression — a name, an attribute, a call, arithmetic.

!r

The conversion — Optional. !r calls repr(), !s calls str(), !a calls ascii().

:>10

The format spec — Everything after the colon: alignment, width, precision, type.

}"

Close — Ends the replacement field, then the string.

  • Whole: f"{total!r:>10}"
  • f" — The f prefix: Marks the string as an f-string. Without it, {total} is literal text.
  • {total — The expression: Any Python expression — a name, an attribute, a call, arithmetic.
  • !r — The conversion: Optional. !r calls repr(), !s calls str(), !a calls ascii().
  • :>10 — The format spec: Everything after the colon: alignment, width, precision, type.
  • }" — Close: Ends the replacement field, then the string.

Format spec, by what you are printing

Format spec, by what you are printing
WrittenPrints
f"{name}"async
f"{name!r}"'async' — repr, quotes included
f"{ratio=}"ratio=0.0721 — name and value, for debugging
f"{amount:.2f}"1234.57
f"{amount:,.2f}"1,234.57
f"{ratio:.1%}"7.2%
f"{name:>10}" async — right-aligned in 10
f"{name:<10}|"async |
f"{name:^11}|" async |
f"{n:03}"042
f"{n:#x}"0x2a
f"{n:_>6}"____42 — pad with any character

Together

python
name = "async"
total = 19.9 * 3

print(f"{name!r}")      # 'async'
print(f"{total:.2f}")   # 59.70
print(f"{total=:.2f}")  # total=59.70

Remember: f"{value}" is str(value) with no rounding. Add :.2f, or the noise a float actually carries prints in full.

See also: strings · numbers

Bytes and bytearrays

standardintermediate

bytes is an immutable sequence of integers from 0 to 255, and bytearray is the mutable version. Neither one is text: you encode a str to bytes and decode bytes back to str, and you name the encoding both times.

Think of it as

A str is meaning; bytes is the wire format. encode writes that meaning down under a named encoding, decode reads it back, and bytearray is the copy you are allowed to edit in place.

python
raw = display_name.encode("utf-8")   # str -> bytes
text = raw.decode("utf-8")           # bytes -> str
buffer = bytearray(raw)              # a mutable copy
buffer[0] = 67                       # bytearray only; bytes raises TypeError

What we're doing: Encode a name to bytes, see what they really are, edit a copy, and decode it back.

encode_name.pypython
display_name = "café"

raw = display_name.encode("utf-8")
print(raw)
print(len(display_name), len(raw))
print(raw[0], type(raw[0]).__name__)
print(raw[0:1])

buffer = bytearray(raw)
buffer[0] = 67
print(buffer)
print(bytes(buffer).decode("utf-8"))
3
encode turns the str into bytes. The encoding is named, never guessed.
5
Four characters became five bytes, because é needs two bytes in UTF-8.
6
Indexing hands back the integer 99, not a byte. The type name proves it.
7
Slicing hands back bytes, so raw[0:1] prints as b"c".
9
bytearray(raw) copies those bytes into a buffer you are allowed to edit.
10
A bytearray slot holds an int, so assign 67 — the byte value for "C".
12
decode crosses back to str, using the same encoding you encoded with.
Output
b'caf\xc3\xa9'
4 5
99 int
b'c'
bytearray(b'Caf\xc3\xa9')
Café

Why this works: A str stores characters; bytes stores the numbers those characters turn into under one specific encoding. Indexing a sequence of numbers gives you a number, which is why raw[0] is 99 rather than b"c" — slicing is what keeps you in bytes.

The boundary text has to cross

str

"café" — 4 characters

utf-8 codec

encode out, decode back

bytes

5 ints, each 0-255

  1. str — "café" — 4 characters
  2. utf-8 codec — encode out, decode back
  3. bytes — 5 ints, each 0-255

Decoding with an encoding the bytes were not written in

Wrong

python
raw = "café".encode("utf-8")
text = raw.decode("ascii")

Better

python
raw = "café".encode("utf-8")
text = raw.decode("utf-8")

What you see: UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 3: ordinal not in range(128)

Why: Bytes carry no record of the encoding that produced them. Decoding picks one for them, so a wrong pick either raises, as it does here, or corrupts the text in silence — decoding these same bytes as latin-1 returns "café" and no error at all.

Crossing between str and bytes

Crossing between str and bytes
CallResult
"py".encode()b'py' — utf-8 is the default
b"py".decode()'py'
"café".encode()b'caf\xc3\xa9' — 5 bytes
len("café")4 — characters, not bytes
b"\xff".decode("utf-8")UnicodeDecodeError — not a silent mangle
b"\xff".decode("utf-8", "replace")'\ufffd' — lossy, but no exception

Together

python
name = "café"
encoded = name.encode("utf-8")

encoded          # b'caf\xc3\xa9'
len(name)        # 4 — one entry per character
len(encoded)     # 5 — é takes two bytes in utf-8
encoded.decode("utf-8") == name   # True — round-trips exactly

Working with the bytes themselves

Working with the bytes themselves
CallResult
raw[0]112 — an int, not a one-byte bytes
raw[0:1]b'p' — slicing keeps the type
b"a,b".split(b",")[b'a', b'b'] — the separator must be bytes
b"-".join([b"a", b"b"])b'a-b'
b" a ".strip()b'a'
b"py".upper()b'PY'
b"py".hex()'7079' — a str, for logs and digests
bytes.fromhex("7079")b'py' — the way back
bytearray(b"py")[0] = 80bytearray(b'Py') — bytes cannot do this

Together

python
raw = b"  payload  "

cleaned = raw.strip()          # b'payload'
digest = cleaned.hex()         # '7061796c6f6164' — safe to log or store as text
bytes.fromhex(digest) == cleaned   # True — the way back

Remember: Indexing bytes gives an int, not a one-byte bytes: b"abc"[0] is 97. Slice to stay in bytes: b"abc"[0:1] is b'a'.

See also: strings · slicing

Advertisement

Containers

Four ways to hold more than one thing, and how to pick between them.

Lists

corebeginner

A list is an ordered, changeable row of items. Positions start at 0, the row holds anything you put in it — even a mix of types — and it grows when you append.

Think of it as

A numbered row of slots. Each slot holds one item, the numbering never has gaps, and adding to the right-hand end costs the same whether the row holds three items or three million.

python
order_skus = ["SKU-8841", "SKU-2210"]  # ordered, mutable
order_skus.append("SKU-9007")          # grow at the right-hand end
order_skus[0] = "SKU-0001"             # replace one slot in place
order_skus[-1]                         # 'SKU-9007' — last slot

What we're doing: Collect the SKUs on an order, dropping repeats but keeping the order the customer added them in.

orders.pypython
line_items = ["SKU-8841", "SKU-2210", "SKU-8841", "SKU-9007"]

unique_skus = []
for sku in line_items:
    if sku not in unique_skus:
        unique_skus.append(sku)

print(unique_skus)
print(unique_skus[0], unique_skus[-1], len(unique_skus))
print(sorted(unique_skus))
print(unique_skus.sort())
print(unique_skus)
1
The raw order repeats SKU-8841. Order matters here, so a list is the right container.
3
Start empty. A list has no fixed size — it grows as items go in.
5
not in scans every slot filled so far, so this loop slows down on a very long order.
6
append adds one slot on the right-hand end. Its cost does not grow with the list.
9
Index 0 is the first slot, -1 is the last, and len() counts the slots.
10
sorted() builds a NEW sorted list and leaves unique_skus exactly as it was.
11
sort() reorders the list in place and returns None — that None is what prints.
12
The list itself is now sorted, because sort() changed it rather than copying it.
Output
['SKU-8841', 'SKU-2210', 'SKU-9007']
SKU-8841 SKU-9007 3
['SKU-2210', 'SKU-8841', 'SKU-9007']
None
['SKU-2210', 'SKU-8841', 'SKU-9007']

Why this works: A list remembers the order items went in, so de-duplicating by hand keeps the sequence the customer built. sort() and sorted() differ in the one way that trips people up: sort() changes the list and hands back None, while sorted() leaves the list alone and hands back a new one.

A list is a numbered row of slots

Positions are numbered from both ends; the row grows on the right.

  • Four numbered slots sit in a row. Slot 0 holds the string SKU-8841, slot 1 holds SKU-2210, slot 2 holds SKU-9007 and slot 3 holds SKU-4413.
  • The same slots are numbered again underneath from the right: -4, -3, -2 and -1, so index -1 is always the last item.
  • A dashed slot sits off the right-hand end labelled append(). That is where a new item lands, and the cost does not grow with the row.
  • A line under the four filled slots marks the ground that a membership test or insert(0, x) has to walk, which is why both cost O(n).

A list used as a default argument is shared by every call

Wrong

python
def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags


print(add_tag("urgent"))
print(add_tag("billing"))
print(add_tag("refund"))

Better

python
def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags


print(add_tag("urgent"))
print(add_tag("billing"))
print(add_tag("refund"))

What you see: The wrong version prints ['urgent'], then ['urgent', 'billing'], then ['urgent', 'billing', 'refund']. The fixed version prints ['urgent'], then ['billing'], then ['refund'].

Why: Default values are evaluated once, when the def line runs — not on each call. That one list is stored on the function and handed to every call that leaves the argument out, so each append piles onto the last call’s result. Defaulting to None and building the list inside the body gives every call its own.

list methods, and what each hands back

list methods, and what each hands back
CallResultMutates?
items.append(4)Noneyes — adds to the end
items.extend([3, 4])Noneyes — adds each item
items.insert(0, 9)Noneyes — O(n), shifts everything
items.remove(2)Noneyes — first match, else ValueError
items.pop()the last itemyes
items.pop(0)the first itemyes — O(n)
items.reverse()Noneyes — in place
items.sort()Noneyes — in place
sorted(items)a new sorted listno — leaves items alone
items.index(2)the position, else ValueErrorno
items.count(1)how many times it occursno
items.copy()a new, shallow copyno

Together

python
scores = [3, 1, 4, 1, 5]
scores.append(9)     # in place — scores is now [3, 1, 4, 1, 5, 9]
scores.sort()         # in place — [1, 1, 3, 4, 5, 9]
scores.pop()           # 9 — removed and returned; scores is [1, 1, 3, 4, 5]

Remember: list.sort() rearranges the list and returns None. sorted(items) leaves it alone and hands you a new list.

See also: names and references · tuples · slicing

Tuples

standardbeginner

A tuple is a fixed sequence: once it exists you cannot add, remove or replace an item. Reach for one when the length is fixed and each position means something — a coordinate, a database row.

Think of it as

A printed form with a fixed number of boxes. The second box always holds the latitude. You can read any box, but you cannot add one or take one away.

python
coordinates = ("Berlin", 52.52, 13.40)
city, latitude, longitude = coordinates
single_port = ("8080",)  # the comma makes the tuple

What we're doing: Show what a tuple fixes, what it leaves alone, and when it stops being hashable.

python
coordinates = ("Berlin", 52.52, 13.40)
city, latitude, longitude = coordinates
print(city, latitude, longitude)

print(type(("Berlin")), type(("Berlin",)))

readings = ("sensor-7", [1, 2])
readings[1].append(3)
print(readings)

try:
    hash(readings)
except TypeError as error:
    print("TypeError:", error)
1
Three items in a fixed order: the name, then the latitude, then the longitude.
2
Unpacking reads the three positions out into three names, left to right.
5
("Berlin") is a string in parentheses. ("Berlin",) — with the comma — is a one-item tuple.
7
The second slot holds a list. The tuple is fixed; that list is not.
8
Mutating the list works, and the tuple now reports different contents.
12
hash walks every element, so one list inside makes the whole tuple unhashable.
Output
Berlin 52.52 13.4
<class 'str'> <class 'tuple'>
('sensor-7', [1, 2, 3])
TypeError: unhashable type: 'list'

Why this works: A tuple fixes its bindings — which object sits in which slot. It does not freeze those objects, so a mutable element stays mutable. That is also why hashing a tuple fails the moment one element cannot be hashed.

Fixed slots versus a growable row

Tuple

("Berlin", 52.52, 13.40) — three slots, fixed

List

["Berlin", "Paris"] — one growable row

Dict key

visit_counts[("Berlin", 2026)] — hashable

  • Tuple — ("Berlin", 52.52, 13.40) — three slots, fixed
  • List — ["Berlin", "Paris"] — one growable row
  • Dict key — visit_counts[("Berlin", 2026)] — hashable

A one-item tuple needs the trailing comma

Wrong

python
allowed_ports = ("8080")
print(type(allowed_ports), len(allowed_ports))

Better

python
allowed_ports = ("8080",)
print(type(allowed_ports), len(allowed_ports))

What you see: The wrong version prints <class 'str'> 4; the better one prints <class 'tuple'> 1.

Why: Parentheses around a single value only group it, so you get the string back. Every later loop or len() then runs over its characters instead of over one port.

Everything a tuple does

Everything a tuple does
WrittenResult
type(("eu",))tuple — the trailing comma makes it
type(("eu"))str — parentheses alone are just grouping
(1, 2) + (3,)(1, 2, 3) — a new tuple; neither input changes
(1, 2) * 2(1, 2, 1, 2)
point.count(1)how many times it occurs
point.index(2)the position, else ValueError
point[0] = 9TypeError: 'tuple' object does not support item assignment
hash((1, [2]))TypeError: unhashable type: 'list'

Together

python
point = (3, 4)
origin = (0, 0)

point + origin     # (3, 4, 0, 0) — concatenation, not vector addition
point * 2           # (3, 4, 3, 4)
point.count(3)      # 1

Remember: The comma makes a tuple, not the parentheses: ("Berlin") is a string, ("Berlin",) is a one-item tuple.

See also: lists · dictionaries · names and references

Sets

standardbeginner

A set holds unique items in no particular order. Adding a duplicate changes nothing, and testing membership is a hash lookup rather than a scan, so it stays fast as the set grows.

Think of it as

A guest list, not a queue. You can ask whether a name is on it in one step, but there is no first name and no second name.

python
active_user_ids = {17, 4, 23}    # a set literal
seen_user_ids = set()            # the empty set — {} is a dict

active_user_ids | seen_user_ids  # union
active_user_ids & seen_user_ids  # intersection
active_user_ids - seen_user_ids  # in the first, not the second
active_user_ids ^ seen_user_ids  # in one or the other, not both
17 in active_user_ids            # membership, one hash lookup

What we're doing: Compare two days of visitor ids: how many were distinct, who came both days, who came only once.

visitors.pypython
monday_visitors = ["u17", "u04", "u23", "u17", "u04"]
tuesday_visitors = ["u23", "u91", "u04"]

monday = set(monday_visitors)
tuesday = set(tuesday_visitors)

print(len(monday_visitors), len(monday))
print(sorted(monday | tuesday))
print(sorted(monday & tuesday))
print(sorted(monday - tuesday))
print(sorted(monday ^ tuesday))
print("u91" in monday)
1
Five visits were logged, but two of them repeat an earlier visitor.
4–5
set() over a list drops the repeats and keeps one of each id.
7
The list still holds five entries; the set holds three.
8
Union: everyone who visited on either day. sorted() is here so the printed order is predictable.
9
Intersection: the visitors the two days share.
10
Difference: came on Monday and did not come back.
11
Symmetric difference: in one day or the other, never in both.
12
Membership is one hash lookup, whatever the size of the set.
Output
5 3
['u04', 'u17', 'u23', 'u91']
['u04', 'u23']
['u17']
['u17', 'u91']
False

Why this works: A set stores each element once, keyed by its hash. That is what makes de-duplication a single call and membership a single lookup. The four operators read those stored elements and each returns a new set.

Reaching into a set by position

Wrong

python
active_user_ids = {17, 4, 23}
print(active_user_ids[0])

Better

python
active_user_ids = {17, 4, 23}
print(sorted(active_user_ids)[0])

What you see: TypeError: 'set' object is not subscriptable

Why: A set places elements by hash, so there is no first element to fetch. Sort it into a list when you need a position, or iterate over it when you do not.

What each operator selects

The two days from the example — every operator names a region

  • Two overlapping circles. The left circle is Monday’s visitors and holds u17, u04 and u23. The right circle is Tuesday’s visitors and holds u23, u91 and u04.
  • The overlap is drawn in the accent colour and holds u04 and u23 — the visitors the two days share.
  • The left crescent holds u17 alone. The right crescent holds u91 alone.
  • A legend under the picture reads: | gives all four ids, & gives u04 and u23, - gives u17, and ^ gives u17 and u91.

Combining two sets

Combining two sets
OperatorMethodResult
active | trialled.union(){1, 2, 3, 4} — in either
active & trialled.intersection(){2, 3} — in both
active - trialled.difference(){1} — in the first only
active ^ trialled.symmetric_difference(){1, 4} — in exactly one
active <= trialled.issubset()True if every item is in trialled
active >= trialled.issuperset()True if it holds all of trialled

Together

python
active = {1, 2, 3}
trialled = {2, 3, 4}

active | trialled   # {1, 2, 3, 4} — every id in either group
active & trialled   # {2, 3} — ids in both groups
active - trialled   # {1} — active only, not trialled

Changing one set

Changing one set
CallResult
active.add("u17")None — already present is not an error
active.discard("u99")None — missing is not an error
active.remove("u99")KeyError if it is missing
active.pop()an arbitrary element, removed
active.update(other)None — adds every item of other
active.isdisjoint(other)True when they share nothing

Together

python
active = {1, 2, 3}
active.add(4)              # in place — active is now {1, 2, 3, 4}
active.discard(99)          # in place, no error — active is unchanged
active.isdisjoint({100})    # True — nothing in common

Remember: {} builds an empty dict, not an empty set — use set(). A set has no order either, so indexing one raises TypeError.

See also: lists · tuples · dictionaries

Dictionaries

corebeginner

A dictionary maps keys to values. You look up a value by its key, and Python reaches it by hashing that key instead of scanning the entries, so lookups stay fast as the dictionary grows.

Think of it as

A list is numbered; a dictionary is labelled. You name the label you want and Python goes straight to that entry, instead of walking the entries until one matches.

python
settings = {"host": "db.internal", "port": 5432}

settings["timeout"] = 30                # add, or overwrite what is there
settings["port"]                        # 5432 — KeyError if the key is absent
settings.get("retry_budget", 3)         # 3 — a default instead of an error
"host" in settings                      # True — membership tests the keys

What we're doing: Count how many requests each region served, read a total back for a region that never appeared, then watch a values() view follow a later insert.

per_region.pypython
requests = [
    {"user_id": 41, "region": "us-east-1"},
    {"user_id": 42, "region": "eu-west-1"},
    {"user_id": 43, "region": "us-east-1"},
    {"user_id": 44, "region": "ap-south-1"},
    {"user_id": 45, "region": "eu-west-1"},
]

per_region = {}
for record in requests:
    region = record["region"]
    per_region[region] = per_region.get(region, 0) + 1

print(per_region)
print(list(per_region))
print(per_region.get("sa-east-1", 0))

totals = per_region.values()
per_region["sa-east-1"] = 0
print(list(totals))
9
An empty pair of braces makes an empty dict, not an empty set.
11
record["region"] indexes a dict too. That key is always present, so indexing is safe here.
12
get returns the default 0 the first time a region is seen, so the count starts at 1.
14
The dict prints in the order the regions were first inserted.
15
Iterating a dict yields its keys, in that same insertion order.
16
No request came from sa-east-1. get hands back 0 rather than raising KeyError.
18
values() is a live view onto the dict, not a copied list of numbers.
19–20
Adding a key changes what the view yields — the 0 appears without touching totals.
Output
{'us-east-1': 2, 'eu-west-1': 2, 'ap-south-1': 1}
['us-east-1', 'eu-west-1', 'ap-south-1']
0
[2, 2, 1, 0]

Why this works: A dict finds a key by hashing it, so the counter for a region is reached in one step however many regions there are. get supplies a default instead of raising, and values() stays attached to the dict rather than snapshotting it.

Adding or deleting keys while you iterate a dict

Wrong

python
per_region = {"us-east-1": 412, "eu-west-1": 87, "ap-south-1": 5}

for region, hits in per_region.items():
    if hits < 10:
        del per_region[region]

Better

python
per_region = {"us-east-1": 412, "eu-west-1": 87, "ap-south-1": 5}

for region, hits in list(per_region.items()):
    if hits < 10:
        del per_region[region]

print(per_region)

What you see: The wrong version stops with RuntimeError: dictionary changed size during iteration. The better version prints {'us-east-1': 412, 'eu-west-1': 87}.

Why: items() is a live view, so deleting a key changes the thing being iterated underneath the loop. Wrapping the view in list() takes a snapshot first, and the loop then edits a dict nobody is walking.

A dict is a table of key/value rows

One hash lands on the row — the rows above it are never read

  • A two-column table stands for one dictionary: the left column holds keys, the right holds their values.
  • Three rows, in the order they were inserted: "us-east-1" maps to 412, "eu-west-1" maps to 87, and "ap-south-1" maps to 5.
  • An arrow on the left, labelled d["eu-west-1"], hashes that key and lands directly on the middle row instead of walking the row above it.
  • Notes under the table record that insertion order is kept from Python 3.7 onward, and that a key the dictionary does not hold raises KeyError.

dict methods, and what each hands back

dict methods, and what each hands back
CallResult
settings["region"]the value, else KeyError
settings.get("region")the value, else None
settings.get("region", "eu")the value, else the fallback
settings.setdefault("k", 5)the existing value, else inserts 5
settings.pop("k")the value, and removes it
settings.update({"b": 2})None — merges in place
defaults | overridesa new merged dict; the right side wins
settings.keys()a live view of the keys
settings.values()a live view of the values
settings.items()a live view of (key, value) pairs
list(settings.items())[('a', 1)] — a real list of tuples
settings.copy()a new, shallow copy

Together

python
settings = {"region": "eu-west"}

settings.get("timeout", 30)     # 30 — key absent, fallback used, dict unchanged
settings.setdefault("timeout", 30)   # inserts it this time
settings.pop("timeout")          # 30 — removed, and handed back

Remember: d[key] raises KeyError when the key is absent. Use d.get(key, default) whenever the key might not be there.

See also: lists · tuples · sets

Advertisement

Taking pieces of a sequence

One notation, used by every sequence type above.

Slicing

corebeginner

seq[start:stop] builds a new sequence holding the items from start up to but not including stop. The item beginning at the stop position is the first one left out.

Think of it as

A position is not an item. Positions are the gaps between items, numbered from 0 at the left edge. seq[1:4] takes whatever lies between position 1 and position 4.

python
records[1:4]   # positions 1, 2, 3 — position 4 is left out
records[:3]    # from the start
records[3:]    # to the end
records[-2:]   # the last two
records[:]     # a shallow copy

What we're doing: Cut a list of user records into fixed-size pages without dropping a row or running off the end.

pagination.pypython
records = ["user_id=101", "user_id=102", "user_id=103", "user_id=104", "user_id=105"]
PAGE_SIZE = 2


def page(rows, page_number):
    start = (page_number - 1) * PAGE_SIZE
    return rows[start : start + PAGE_SIZE]


for number in (1, 2, 3, 4):
    print(number, page(records, number))
1
Five records, so the last page is short and the page after it is empty.
6
Page 1 starts at position 0, page 2 at position 2, page 3 at position 4.
7
stop is start + PAGE_SIZE, so the slice is PAGE_SIZE long — stop minus start.
11
Page 4 starts at position 6, past the end, so the slice is empty rather than an error.
Output
1 ['user_id=101', 'user_id=102']
2 ['user_id=103', 'user_id=104']
3 ['user_id=105']
4 []

Why this works: Because stop is excluded, start + PAGE_SIZE is both the end of this page and the start of the next. The two pages meet at that position exactly once, so no record is duplicated or lost. A start beyond the end is clamped to the length, which is why page 4 is empty instead of raising.

Indexing raises where slicing stays quiet

Wrong

python
records = ["user_id=101", "user_id=102", "user_id=103"]
print(records[10])

Better

python
records = ["user_id=101", "user_id=102", "user_id=103"]
print(records[10:20])

What you see: The wrong version stops with "IndexError: list index out of range". The better version prints [].

Why: A plain index must name a real item, so an out-of-range one raises. Slice bounds are clamped to the sequence first, so an out-of-range slice hands back an empty sequence and the program carries on.

Positions sit between the items

seq[1:4] takes what lies between position 1 and position 4 — three items

  • Five boxed records in a row, labelled 101 to 105.
  • Six numbered positions, 0 to 5, sit on the boundaries between and around the boxes — not on the boxes themselves.
  • Beneath the same boundaries, the negative names -5, -4, -3, -2 and -1 count back from the right; the final boundary has no negative name, so it is labelled "end".
  • A bracket spans position 1 to position 4 and highlights the three boxes 102, 103 and 104. Box 105, which starts at position 4, is outside the bracket.

What each slice hands back

What each slice hands back
SliceResult
items[2:5]index 2, 3 and 4 — never 5
items[:3]the first three
items[-2:]the last two
items[::2]every second item
items[::-1]a reversed copy

Together

python
records = list(range(23))
page, per_page = 2, 10
start = (page - 1) * per_page

records[start:start + per_page]
# [10, 11, 12, 13, 14, 15, 16, 17, 18, 19] — page 2, ten per page

Remember: stop is never included. For bounds inside the sequence len(seq[a:b]) is b - a, and seq[:i] + seq[i:] rebuilds the original for any i.

See also: lists · extended slicing · names and references

Extended slicing

standardintermediate

A slice can take a third number, the step: seq[start:stop:step]. The step is how far to move each time. A negative step walks backwards, and it also swaps which end an omitted start and an omitted stop stand for.

Think of it as

The step is the length of your stride, and its sign is the direction you face. Turn around and the two ends swap roles: start now means the far end of the sequence, stop the near one.

python
seq[start:stop:step]

readings[::2]     # every second item, forwards
readings[::-1]    # every item, backwards
readings[5:1:-1]  # index 5 down to index 2

What we're doing: Read the same list of sensor readings five ways, varying the step and the two bounds.

readings.pypython
readings = [10, 20, 30, 40, 50, 60]

print(readings[::2])
print(readings[::-1])
print(readings[5:1:-1])
print(readings[1:5:-1])
print(readings[:2:-1])
3
Step 2 from the front: indexes 0, 2 and 4.
4
Step -1 with both ends left out: every item, back to front.
5
Start 5, stop 1, walking down — indexes 5, 4, 3 and 2. The stop index is never included.
6
Walking down from index 1, stop 5 is already behind you, so nothing is collected.
7
An omitted start with a negative step means the last item, not the first.
Output
[10, 30, 50]
[60, 50, 40, 30, 20, 10]
[60, 50, 40, 30]
[]
[60, 50, 40]

Why this works: The step sets both the size of the jump and the direction. Once the direction is backwards, start has to be further along the sequence than stop, and an omitted start means the last item rather than the first.

A step of zero is not the same as no step

Wrong

python
readings = [10, 20, 30, 40, 50, 60]
print(readings[::0])

Better

python
readings = [10, 20, 30, 40, 50, 60]
print(readings[::1])  # or readings[:]

What you see: ValueError: slice step cannot be zero

Why: A step of zero would never move along the sequence, so there is no result to give. Leave the step out, or write 1, when you want every item.

The stride and its direction

The step is the size of the jump; its sign is the direction of travel

  • Two rows, each showing the same list of six readings: 10, 20, 30, 40, 50, 60.
  • The top row is labelled readings[::2]. Its first, third and fifth cells — 10, 30 and 50 — are highlighted, and an arrow beneath the row points right, for a stride of two travelling forwards.
  • The bottom row is labelled readings[::-1]. Every cell is highlighted, and an arrow beneath the row points left, for a stride of one travelling backwards.

Plain slice assignment versus stepped

Plain slice assignment versus stepped
WrittenLength rule
items[1:3] = [20, 30, 40, 50]can grow or shrink the list — step 1 only
items[::2] = [10, 40, 160]must match exactly — 3 positions need 3 items

Together

python
items = [1, 2, 3, 4, 5]
items[1:3] = [20, 30, 40, 50]   # [1, 20, 30, 40, 50, 4, 5] — grew by two

Remember: With a negative step, start must sit after stop: readings[5:1:-1] returns four items and readings[1:5:-1] returns nothing.

See also: slicing · lists · strings

Advertisement

Unpacking

Binding several names at once, by position.

Unpacking

corebeginner

Assignment can bind several names at once. Put a comma-separated list of names on the left, and Python takes the iterable on the right apart position by position — one name per position.

Think of it as

The same rule as a plain =, applied once per position. host, port = endpoint is two assignments written on one line, not a new kind of statement.

python
host, port = ("db.internal", 5432)             # two names, two positions
host, port = parse_endpoint(raw_endpoint)      # a function's two return values
retry_budget, timeout = timeout, retry_budget  # swap — the right side is built first

for resource, amount in limits.items():        # one key/value pair per turn
    print(resource, amount)

What we're doing: Read a host and a port out of one string, swap two settings, then walk a mapping one pair at a time.

endpoint.pypython
def parse_endpoint(text):
    host, port = text.split(":")
    return host, int(port)


endpoint = parse_endpoint("db.internal:5432")
host, port = endpoint
print(host, port)

retry_budget, timeout = 3, 30
retry_budget, timeout = timeout, retry_budget
print(retry_budget, timeout)

limits = {"cpu": 2, "memory_mb": 512}
for resource, amount in limits.items():
    print(resource, "->", amount)
2
split returns a two-item list, so two names on the left take it apart.
3
The comma builds a tuple, so the function hands back one object holding two values.
7
That same two-position tuple, unpacked into two names at the call site.
10
Both sides are comma lists: two values on the right, two names on the left.
11
The tuple (30, 3) exists before either name is rebound, so no temporary is needed.
15
items() yields a (key, value) pair each turn, and the two names take that pair apart.
Output
db.internal 5432
30 3
cpu -> 2
memory_mb -> 512

Why this works: All three are the same rule. Python builds the whole right side, checks it holds one value per name, then binds the names left to right. The swap needs no temporary variable because (timeout, retry_budget) is a finished tuple before retry_budget is rebound.

Looping over a dict instead of over its items

Wrong

python
limits = {"cpu": 2, "memory_mb": 512}
for resource, amount in limits:
    print(resource, amount)

Better

python
limits = {"cpu": 2, "memory_mb": 512}
for resource, amount in limits.items():
    print(resource, amount)

What you see: The wrong version stops with ValueError: too many values to unpack (expected 2). The better version prints "cpu 2" and then "memory_mb 512".

Why: Iterating a dict yields its keys, so the loop is handed the string "cpu" and tries to unpack three characters into two names. items() is what yields (key, value) pairs. A two-letter key would be worse — it would unpack without complaint and bind the two halves of the key.

What one unpacking assignment does, in order

Right side first

("db.internal", 5432) is built

Counts must match

2 values, 2 names — otherwise ValueError

Position by position

slot 0 → host, slot 1 → port

Both names bound

left to right, in one statement

  1. Right side first — ("db.internal", 5432) is built
  2. Counts must match — 2 values, 2 names — otherwise ValueError
  3. Position by position — slot 0 → host, slot 1 → port
  4. Both names bound — left to right, in one statement

The shapes unpacking takes

The shapes unpacking takes
WrittenUse it for
host, port = endpointa fixed-shape tuple, positions named
a, (b, c) = (1, (2, 3))nested — mirror the shape of the data
for k, v in mapping.items()looping over key/value pairs
_, port = endpointdiscarding a position you do not need
host, port = get_endpoint()a function's return value, unpacked directly

Together

python
host, port = get_endpoint()   # 'db.internal', 5432

for key, value in config.items():
    print(key, value)
# region eu
# zone a

Remember: The right side is built in full before any name is bound, which is why a, b = b, a swaps. The counts must match exactly, or ValueError.

See also: tuples · dictionaries · extended iterable unpacking

Extended iterable unpacking

standardintermediate

Put a star on one name in an assignment target and it absorbs every item the other names do not take. That lets you bind the ends of a sequence without knowing how many items sit between them.

Think of it as

The plain names are reserved seats and are filled first, from both ends inward. The starred name is the overflow bin: it takes whatever remains, and it is a list even when nothing remains.

python
first, *rest = line_items          # rest: every item after the first
*leading, last = line_items        # leading: every item before the last
first, *middle, last = line_items  # both ends named, middle absorbs
first, *rest = "abc"               # rest is ['b', 'c'] — a list, not a str

What we're doing: Bind the ends of a sequence without counting the middle, and check what the starred name really holds.

invoice_lines.pypython
line_items = ["setup fee", "seat 1", "seat 2", "seat 3", "discount"]

first, *middle, last = line_items
print(first, middle, last)
print(type(middle).__name__)

header, *body = ("id", "name", "email")
print(header, body, type(body).__name__)

only_item, *extras = ["setup fee"]
print(only_item, extras)

initial, *remainder = "abc"
print(initial, remainder)
1
Five items. Nothing in the code below depends on that number.
3
first takes the head, last takes the tail, and middle absorbs the three in between.
5
The type name reads "list". A starred target is always bound to a list.
7
The source is a tuple here, and body still comes out as a list.
10
One item covers the one plain name, so extras absorbs nothing and is bound to [].
13
A str is a sequence of characters, so remainder holds them as a list of strings.
Output
setup fee ['seat 1', 'seat 2', 'seat 3'] discount
list
id ['name', 'email'] list
setup fee []
a ['b', 'c']

Why this works: Python fills the plain names first, one item each, counting in from both ends, then collects the remaining items into a new list and binds that to the starred name. The list is built fresh, which is why the result never follows the source type: a tuple or a str on the right still leaves a list on the left. A star in a function call, send_invoice(*line_items), spreads a sequence into arguments instead — the same symbol, a separate feature.

The ends are named, the middle is absorbed

first

one plain name, one item

*middle

one starred name, a list of the leftovers

last

one plain name, one item

  • first — one plain name, one item
  • *middle — one starred name, a list of the leftovers
  • last — one plain name, one item

Two starred names in one assignment

Wrong

python
line_items = ["setup fee", "seat 1", "seat 2", "discount"]
first, *middle, *last = line_items

Better

python
line_items = ["setup fee", "seat 1", "seat 2", "discount"]
first, *middle, last = line_items
print(first, middle, last)

What you see: SyntaxError: multiple starred expressions in assignment. The better version prints: setup fee ['seat 1', 'seat 2'] discount

Why: Two stars leave no single answer for where the leftover items should be split, so the language allows one per target list. This one is caught while the file is compiled, so no line of the module runs and nothing it would have printed appears.

Where the star can go

Where the star can go
WrittenBinds
first, *rest = itemsfirst=1, rest=[2, 3, 4, 5]
*init, last = itemsinit=[1, 2, 3, 4], last=5
first, *mid, last = itemsfirst=1, mid=[2, 3, 4], last=5
a, b, *c = itemsa=1, b=2, c=[3, 4, 5]

Together

python
items = [1, 2, 3, 4, 5]

first, *rest = items    # first=1, rest=[2, 3, 4, 5]
*init, last = items      # init=[1, 2, 3, 4], last=5

Remember: The starred name is always a list, whatever the source was: first, *rest = "abc" leaves rest as ['b', 'c'].

See also: unpacking · lists · slicing

Advertisement

Comprehensions

One expression that builds a container, in four flavours.

Comprehensions

corebeginner

A comprehension is one expression that builds a whole container: [expression for item in iterable if condition]. It walks the iterable once, keeps what the condition allows, and collects what the expression produces.

Think of it as

A conveyor belt with one gate on it. The iterable feeds items in, the if gate drops the ones you do not want, the output expression stamps each survivor into its final shape, and the brackets catch them in a new container.

python
[expression for item in iterable if condition]  # list
{expression for item in iterable}               # set
{key: value for item in iterable}               # dict
(expression for item in iterable)               # generator — lazy, not a tuple

[cell for row in grid for cell in row]          # nested: the order of two for statements

What we're doing: Pull the paid skus out of an order, flatten a grid, and then look for the loop name afterwards.

order_report.pypython
line_items = [
    {"sku": "A-100", "qty": 2, "paid": True},
    {"sku": "B-204", "qty": 1, "paid": False},
    {"sku": "C-330", "qty": 4, "paid": True},
]

paid_skus = [item["sku"] for item in line_items if item["paid"]]
print(paid_skus)

grid = [[1, 2], [3, 4], [5, 6]]
print([cell for row in grid for cell in row])

try:
    print(item)
except NameError as error:
    print("NameError:", error)
1–5
Three order lines. One of them is unpaid, so the filter has work to do.
7
All four parts on one line: what to build, the name, the source, then the gate.
8
Two of the three items were paid, so the new list holds two skus.
11
Two for clauses, read left to right: row from grid first, then cell from that row — the same order as writing the two for statements.
14
item was the loop name on line 7. Outside the brackets that name was never created.
16
Printing the error shows the real message without ending the run.
Output
['A-100', 'C-330']
[1, 2, 3, 4, 5, 6]
NameError: name 'item' is not defined

Why this works: The brackets name the container and everything inside them is a single expression, whose body runs in a scope of its own. That scope is why item is missing on line 14, and it is what lets a comprehension sit inside a call or an argument without disturbing the names around it. Extra for clauses nest inside one another left to right, exactly as the for statements they replace would.

The four parts, in the order they run

iterable

in line_items — the source

binding

for item — one name per pass

filter

if item["paid"] — optional

expression

item["sku"] — the element built

  1. iterable — in line_items — the source
  2. binding — for item — one name per pass
  3. filter — if item["paid"] — optional
  4. expression — item["sku"] — the element built

Running a comprehension for its side effect

Wrong

python
user_names = ["ada", "grace", "linus"]
greetings = [print("welcome,", name) for name in user_names]
print(greetings)

Better

python
user_names = ["ada", "grace", "linus"]
for name in user_names:
    print("welcome,", name)

What you see: Both versions print the three greetings. The wrong one then prints [None, None, None] — a three-item list built and thrown away.

Why: print returns None, so the comprehension collects one None per name and hands back a list nobody reads. A comprehension is an expression whose point is the value it produces; when the point is the effect instead, a for loop states that in the code rather than hiding it inside brackets.

Which bracket builds which container

Which bracket builds which container
WrittenYou get
[x for x in items]a list, built now
{x for x in items}a set, duplicates dropped
{k: v for k, v in pairs}a dict, last key wins
(x for x in items)a generator, nothing built yet

Together

python
line_items = [{"sku": "A-100", "paid": True}, {"sku": "B-2", "paid": False}]

[item["sku"] for item in line_items if item["paid"]]   # ['A-100'] — a list
sum(1 for item in line_items if item["paid"])            # 1 — a generator, summed

Remember: Build with a comprehension; act with a for loop. The loop name lives inside the brackets only, so reading it afterwards raises NameError.

See also: list comprehensions · set comprehensions · dict comprehensions · generator expressions

List comprehensions

standardbeginner

[expr for item in iterable] builds a new list in one pass. It runs at once and holds every result in memory, so by the next line the list is finished: you can index it, and walk it twice.

Think of it as

A loop turned inside out: what you want out moves to the front, and the for clause and any filter follow in the order you would have written them indented. Round brackets instead of square ones give a generator expression, which hands items over one at a time rather than building a list.

python
[amount * 2 for amount in line_items]                     # expression, then for
[amount for amount in line_items if amount > 0]           # the filter if goes LAST
[amount if amount > 0 else 0.0 for amount in line_items]  # a conditional expr goes FIRST
[order_id for batch in batches for order_id in batch]     # outer for, then inner

What we're doing: Clean up a list of billed amounts two different ways, then flatten the batches they arrived in.

line_items.pypython
line_items = [12.5, -3.0, 40.0, 0.0, 7.25]

charges_only = [amount for amount in line_items if amount > 0]
print(charges_only)

clamped = [amount if amount > 0 else 0.0 for amount in line_items]
print(clamped)

print(len(line_items), len(charges_only), len(clamped))

batches = [["A-1001", "A-1002"], ["A-1003"], ["A-1004", "A-1005"]]
order_ids = [order_id for batch in batches for order_id in batch]
print(order_ids)
1
Five raw amounts: three charges, one refund of -3.0, and one zero.
3
The filter if sits at the end. An amount that fails it never reaches the new list.
6
x if cond else y is the output expression, so it sits at the front, before the for.
9
Filtering dropped two amounts; the conditional expression kept all five.
11
Three batches, each holding its own list of order ids.
12
Two for clauses, left to right: take a batch, then take each order_id inside it.
13
One flat list, in the order a pair of nested loops would have visited.
Output
[12.5, 40.0, 7.25]
[12.5, 0.0, 40.0, 0.0, 7.25]
5 3 5
['A-1001', 'A-1002', 'A-1003', 'A-1004', 'A-1005']

Why this works: Everything before the first for is the output expression, which is why a conditional expression lives there — it produces the value that goes into the list. A trailing if is a separate clause that decides whether an item reaches that expression at all, so one changes values and the other changes the length. Extra for clauses nest in the order they are written, outermost first.

Runs in this order, written in another

iterable

for amount in line_items

filter

if amount > 0 — written last

expression

amount * 2 — written first

new list

built now, held in memory

  1. iterable — for amount in line_items
  2. filter — if amount > 0 — written last
  3. expression — amount * 2 — written first
  4. new list — built now, held in memory

Putting the conditional expression where the filter goes

Wrong

python
line_items = [12.5, -3.0, 40.0, 0.0, 7.25]
clamped = [amount for amount in line_items if amount > 0 else 0.0]
print(clamped)

Better

python
line_items = [12.5, -3.0, 40.0, 0.0, 7.25]
clamped = [amount if amount > 0 else 0.0 for amount in line_items]
print(clamped)

What you see: The wrong version stops with "SyntaxError: invalid syntax", pointing at the else. The better version prints [12.5, 0.0, 40.0, 0.0, 7.25].

Why: The trailing if is a filter clause and takes a condition and nothing else, so there is no else for it to pair with. x if cond else y is one expression that produces a value, and values belong in the output slot at the front. Moving the if to the front but leaving the else behind fails as well: [amount if amount > 0 for amount in line_items] raises "SyntaxError: expected 'else' after 'if' expression".

A filter if versus a conditional expression

A filter if versus a conditional expression
WrittenResult length
[n for n in nums if n % 2 == 0]shorter — odd numbers are dropped
[n if n % 2 == 0 else -n for n in nums]same length — every item kept, transformed
[cell for row in grid for cell in row]flattened — outer loop runs first

Together

python
nums = [1, 2, 3, 4, 5]

[n for n in nums if n % 2 == 0]              # [2, 4] — 2 items
["even" if n % 2 == 0 else "odd" for n in nums]   # 5 items — same length as nums

Remember: The filter if goes at the END. A conditional expression goes at the FRONT, because x if cond else y is the output expression, not a filter.

See also: comprehensions · conditional expressions · generator expressions

Set comprehensions

standardbeginner

A set comprehension is {expr for item in iterable}: it runs the expression once per item and collects the results in a set. Braces instead of brackets, and repeated results collapse into one as it goes.

Think of it as

The same loop a list comprehension runs, with a bucket at the end instead of a row. Each result is filed under its hash, so the second copy of a value lands on the first and the bucket keeps one.

python
domains = {email.split("@")[1] for email in signup_emails}  # braces, not brackets
lowered_tags = {tag.lower() for tag in raw_tags if tag}     # if filters before collecting
seen_domains = set()                                        # {} would be an empty dict

What we're doing: Reduce a list of signup records to the distinct email domains they came from.

signup_domains.pypython
signups = [
    {"user_id": 101, "email": "ada@example.com"},
    {"user_id": 102, "email": "grace@corp.io"},
    {"user_id": 103, "email": "alan@example.com"},
    {"user_id": 104, "email": "edsger@corp.io"},
]

all_domains = [record["email"].split("@")[1] for record in signups]
domains = {record["email"].split("@")[1] for record in signups}

print(all_domains)
print(len(all_domains), len(domains))
print(sorted(domains))
print("corp.io" in domains)
1–6
Four signup records: two from example.com, two from corp.io.
8
Square brackets keep every result, repeats and all — one domain per record.
9
The same expression in braces builds a set, so the second example.com lands on the first.
12
Four records went in and two distinct domains came out.
13
sorted() is here because a set has no order of its own: printing domains gave the two names one way round on one run of this file and the other way round on the next.
14
Membership on a set is a hash lookup, not a scan of every element.
Output
['example.com', 'corp.io', 'example.com', 'corp.io']
4 2
['corp.io', 'example.com']
True

Why this works: A set holds one copy per distinct value, keyed by hash. The comprehension hands it results one at a time, so the de-duplication happens as the set is built rather than in a second pass over a list.

An empty set is set(), not {}

Wrong

python
domains = {}
domains.add("example.com")

Better

python
domains = set()
domains.add("example.com")
print(domains)

What you see: The wrong version stops with "AttributeError: 'dict' object has no attribute 'add'". The better one prints {'example.com'}.

Why: Braces belong to dicts and sets both, and what is inside tells them apart: a key: value pair makes a dict, one expression per item makes a set. Bare braces hold nothing to decide with, so the older meaning wins and you get a dict.

Four records in, two domains out

signups

four records, in order

expression

record["email"].split("@")[1]

hash

a repeat lands on the copy already there

set

two domains, no order

  1. signups — four records, in order
  2. expression — record["email"].split("@")[1]
  3. hash — a repeat lands on the copy already there
  4. set — two domains, no order

Two more shapes

Two more shapes
WrittenNote
{item for group in groups for item in group}flattens — same left-to-right order as list-comprehensions
{t.upper() for t in tags}idiomatic
set(t.upper() for t in tags)identical result — the comprehension IS this, spelled shorter

Together

python
groups = [["a", "b"], ["b", "c"]]

sorted({item for group in groups for item in group})   # ['a', 'b', 'c']

Remember: Braces with a for and no colon build a set, so duplicates collapse. Bare {} is an empty dict — the empty set is set().

See also: sets · comprehensions · list comprehensions

Dictionary comprehensions

standardbeginner

A dict comprehension builds a new dictionary from an iterable in one expression: {key_expr: value_expr for item in iterable}. The colon between the two expressions is the whole difference between this and a set comprehension.

Think of it as

A loop that fills a dict, folded into one expression. Each pass produces one key and one value, and writing a key that is already there replaces it rather than raising.

python
{key_expr: value_expr for item in iterable}   # the colon makes it a dict

{name: value for name, value in zip(columns, row)}        # pair two sequences
{post_id: slug for slug, post_id in slug_to_id.items()}   # invert a mapping
{name: value for name, value in LIVE.items() if value != DEFAULTS[name]}

What we're doing: Cut a live config down to only the settings this deployment overrode, and build a record from two parallel sequences.

config_diff.pypython
DEFAULTS = {"host": "db.internal", "port": 5432, "timeout": 30, "retry_budget": 3}
LIVE = {"host": "db.internal", "port": 6543, "timeout": 30, "retry_budget": 10}

overridden = {name: value for name, value in LIVE.items() if value != DEFAULTS[name]}
print(overridden)

columns = ("user_id", "region", "plan")
row = (41, "us-east-1", "pro")
record = {name: value for name, value in zip(columns, row)}
print(record)

same_but_no_colon = {name.upper() for name in overridden}
print(type(overridden).__name__, type(same_but_no_colon).__name__)
1–2
Two versions of one config: the values that ship, and the values this deployment runs.
4
.items() yields (name, value) pairs. The trailing if drops every pair that still matches the default.
5
Two settings differ, so the new dict holds two entries and nothing else.
9
zip pairs each column name with the value beside it; the colon turns those pairs into entries.
12
Braces again, a for again, no colon — so this builds a set of names instead of a dict.
13
The two type names confirm it: one colon is the entire difference.
Output
{'port': 6543, 'retry_budget': 10}
{'user_id': 41, 'region': 'us-east-1', 'plan': 'pro'}
dict set

Why this works: The comprehension evaluates the key expression and the value expression once per pair, then stores that entry in a brand-new dict. Anything that yields pairs can be the source, which is why .items() and zip() both drop straight in. Remove the colon and the same braces build a set.

One pair in, one entry out

Pairs in

zip(columns, row) or settings.items()

if condition

optional — drops a pair early

key : value

the colon is what makes it a dict

New dict

a repeated key overwrites the earlier one

  1. Pairs in — zip(columns, row) or settings.items()
  2. if condition — optional — drops a pair early
  3. key : value — the colon is what makes it a dict
  4. New dict — a repeated key overwrites the earlier one

Inverting a mapping whose values are not unique

Wrong

python
slug_to_id = {"getting-started": 41, "intro": 41, "deploy-guide": 42}

id_to_slug = {post_id: slug for slug, post_id in slug_to_id.items()}
print(id_to_slug)
print(len(slug_to_id), len(id_to_slug))

Better

python
slug_to_id = {"getting-started": 41, "intro": 41, "deploy-guide": 42}

slugs_by_id = {}
for slug, post_id in slug_to_id.items():
    slugs_by_id.setdefault(post_id, []).append(slug)

print(slugs_by_id)
print(len(slug_to_id), sum(len(slugs) for slugs in slugs_by_id.values()))

What you see: The wrong version prints {41: 'intro', 42: 'deploy-guide'} and then 3 2 — a slug went missing and nothing was raised.

Why: Two slugs point at post 41, so both produce the key 41 and the second entry overwrites the first. A clash between duplicate keys is never reported, so the only sign is a dict shorter than the one you started with. Collecting the slugs into a list keeps all three, and the better version prints 3 3.

Where the pairs come from

Where the pairs come from
SourceBuilds
{i: name for i, name in enumerate(names)}index to value
{name: len(name) for name in names}a single list, value computed
{k: v for k, v in d.items() if v}filtered copy of an existing dict
dict.fromkeys(names, 0)same keys, one shared default — no comprehension needed

Together

python
names = ["ada", "grace", "linus"]

{i: name for i, name in enumerate(names)}   # {0: 'ada', 1: 'grace', 2: 'linus'}
{name: len(name) for name in names}          # {'ada': 3, 'grace': 5, 'linus': 5}

Remember: Duplicate keys are not detected. The last pair written wins, so inverting a dict whose values repeat hands back a shorter dict.

See also: dictionaries · comprehensions · set comprehensions

Generator expressions

coreintermediate

A generator expression is a comprehension written in round brackets. It builds no container: each item is computed at the moment something asks for it, so memory stays flat however long the source is.

Think of it as

A generator expression stores the loop, not the results. It takes hold of the source the moment it is written, computes no item until something iterates over it, and hands each item out once and then forgets it.

python
line_totals = (line.total for line in line_items)  # no total computed yet
next(line_totals)                                 # now one item is computed

sum(line.total for line in line_items)       # sole argument — no extra brackets
sum((line.total for line in line_items), 0)  # not sole — bracket the generator

What we're doing: Total an order without building an intermediate list, and measure what the generator costs.

order_total.pypython
import sys

line_items = [
    ("sku-A1", 250, 2),
    ("sku-B7", 990, 1),
    ("sku-C3", 120, 4),
]

line_totals = (unit_price * quantity for _, unit_price, quantity in line_items)
print(line_totals)
print(next(line_totals))
print(sum(line_totals))

print(sum(unit_price * quantity for _, unit_price, quantity in line_items))

print(sys.getsizeof([n * n for n in range(1_000_000)]))
print(sys.getsizeof(n * n for n in range(1_000_000)))
9
Round brackets build a generator. Not one multiplication has run at this point.
10
Printing it shows the generator object itself, never the items — this is how most people first meet one. The hex address is that object, so it reads differently on every run.
11
next() asks for one item, so exactly one multiplication runs: 250 * 2.
12
sum() takes the rest. It carries on from where next() stopped, so the first line is already gone: 990 + 480.
14
A fresh generator, and the only argument to the call, so it needs no brackets of its own. All three lines this time.
16–17
The list holds a million numbers. The generator holds one loop, whatever the source length.
Output
<generator object <genexpr> at 0x00000203553DD120>
500
1470
1970
8448728
208

Why this works: The brackets build an object that remembers the loop and its current position, and nothing else. Items exist one at a time, which is why next() and sum() share a single pass over the source and why the size of the generator does not move with the length of that source.

Dropping the brackets when the generator is not the only argument

Wrong

python
line_items = [("sku-A1", 250, 2), ("sku-B7", 990, 1)]
print(sum(unit_price * quantity for _, unit_price, quantity in line_items, 0))

Better

python
line_items = [("sku-A1", 250, 2), ("sku-B7", 990, 1)]
print(sum((unit_price * quantity for _, unit_price, quantity in line_items), 0))

What you see: SyntaxError: Generator expression must be parenthesized. The better version prints 1490.

Why: The brackets may be dropped only when the generator expression is the sole positional argument and there are no keyword arguments. Add a second argument and the parser can no longer see where the expression ends, so it stops at compile time.

Items arrive one at a time, and only once

Source

line_items — any length

Generator

holds the loop, not the items

sum()

pulls one item at a time

Exhausted

a second sum() gives 0

  1. Source — line_items — any length
  2. Generator — holds the loop, not the items
  3. sum() — pulls one item at a time
  4. Exhausted — a second sum() gives 0

What accepts a generator directly

What accepts a generator directly
CallWorks?
sum(x for x in rows)yes — pulls one item at a time
any(x for x in rows)yes — stops at the first True
all(x for x in rows)yes — stops at the first False
sorted(x for x in rows)yes — but this DOES read it all into memory
len(gen)no — TypeError: object of type 'generator' has no len()
gen[0]no — TypeError: 'generator' object is not subscriptable

Together

python
any(n > 3 for n in range(5))   # True — stops as soon as one qualifies
sorted(n for n in [3, 1, 2])     # [1, 2, 3] — reads the whole thing to sort it

Remember: A generator is one-shot. After sum() has walked it, it is empty: a second sum() returns 0 and max() raises ValueError. Use list() to keep the items.

See also: comprehensions · list comprehensions · lists

Advertisement

Truth and absence

What counts as true, and what stands for nothing at all.

Conditional expressions

standardbeginner

A conditional expression picks one of two values: value_if_true if condition else value_if_false. It is an expression, not a statement, so it fits anywhere a value fits — an f-string, a call, a comprehension.

Think of it as

A fork that hands back a value. Python tests the condition, walks down one side only, and the whole expression collapses to the single value it found there.

python
value_if_true if condition else value_if_false   # the shape: value first, else required

status = "retry" if attempts < retry_budget else "give up"
label = "user " + ("guest" if user_id is None else "member")  # parens — it binds loosely
tier = "gold" if points > 900 else "silver" if points > 500 else "bronze"  # chains like elif

What we're doing: Choose a status word, guard a division, and pluralise a label without leaving the expression.

retry_report.pypython
retry_budget = 3
attempts = 3

status = "retry" if attempts < retry_budget else "give up"
print(status)

line_items = [40, 0, 10]
share = [200 / count if count else 0.0 for count in line_items]
print(share)

print(f"{len(line_items)} item{'s' if len(line_items) != 1 else ''} queued")
4
The value comes first, the condition second. attempts is 3, so the condition is False and the right-hand value wins.
8
The conditional is the item the comprehension builds, and 200 / count runs only when count is non-zero.
11
The expression supplies the plural: three items gives 's', one item gives an empty string.
Output
give up
[5.0, 0.0, 20.0]
3 items queued

Why this works: Python evaluates the condition first and then exactly one branch, so the side you do not take never runs. That is what makes 200 / count safe here: with count at 0 the division is never reached, where a plain 200 / count raises ZeroDivisionError. And because the whole thing evaluates to a value, it can be the item a comprehension builds or the text an f-string interpolates.

Leaving the else off

Wrong

python
retry_budget = 3
attempts = 1

status = "retry" if attempts < retry_budget

Better

python
retry_budget = 3
attempts = 1

status = "retry" if attempts < retry_budget else "give up"

What you see: SyntaxError: expected 'else' after 'if' expression — the file does not start running at all.

Why: A conditional expression has to produce a value whichever way the condition goes, so there is no one-armed form. When there is no sensible second value, write a real if statement, or make the fallback None.

One condition in, one value out

condition

attempts < retry_budget

one branch

the side you do not take is never evaluated

one value

usable in an f-string, a call, a comprehension

  1. condition — attempts < retry_budget
  2. one branch — the side you do not take is never evaluated
  3. one value — usable in an f-string, a call, a comprehension

A conditional expression versus or

A conditional expression versus or
WrittenWhen retry_budget is 0
retry_budget if retry_budget is not None else 30 — the explicit check wins
retry_budget or 33 — or treats 0 as missing, silently

Together

python
retry_budget = 0

retry_budget if retry_budget is not None else 3   # 0 — a legitimate zero survives
retry_budget or 3                                   # 3 — wrong here: 0 is falsy

Remember: Value first, condition second, and else is mandatory. It binds looser than +, so parenthesise it whenever it sits inside a larger expression.

See also: truthiness · list comprehensions

Truthiness

corebeginner

Every object has a truth value, so if value: works on anything, not only on a bool. False, None, zero and every empty container are falsy. Everything else is truthy, including the string "False".

Think of it as

Every object answers one question about itself: am I empty or zero? A container answers with its length, a number by comparing itself to zero, and an object with no opinion answers no — which counts as true.

python
if line_items:                          # truthy: the list holds an item
    send_invoice(line_items)

display_name = raw_name or "anonymous"  # or returns an operand, not a bool
print(bool(""), bool("False"))          # False True

What we're doing: See which values are falsy, watch bool() pick a rule, and catch and / or handing back an operand.

truth_values.pypython
falsy = [False, None, 0, 0.0, "", b"", (), [], {}, set(), range(0)]
print(any(falsy))
print(bool("False"), bool([0]), bool(0.1))


class RetryBudget:
    def __init__(self, remaining):
        self.remaining = remaining

    def __len__(self):
        return self.remaining


class Connection:
    pass


print(bool(RetryBudget(3)), bool(RetryBudget(0)), bool(Connection()))
print(0 or "fallback")
print([] and "unreachable")
1
Eleven falsy values: False and None, the two zeros, and seven empty containers.
2
any() reports False, so not one of those eleven values counts as true.
3
Content decides, not meaning: "False" has characters, [0] has an item, 0.1 is not zero.
10–11
No __bool__ on this class, so bool() drops to __len__. A length of 0 means falsy.
14–15
Connection defines neither method, so every instance of it is truthy.
18
A budget of 3 is truthy, a budget of 0 is falsy, and the Connection is truthy.
19
0 is falsy, so or moves on and hands back the second operand — the string itself.
20
[] is falsy, so and stops there and hands back [], leaving the string unevaluated.
Output
False
True True True
True False True
fallback
[]

Why this works: An if statement never demands a bool. It calls bool() on whatever you hand it, and bool() works down a fixed ladder: __bool__, then __len__, then truthy. The operators and / or short-circuit and return the operand that settled the answer rather than True or False, which is what makes raw_name or "anonymous" a working default — for a value where blank and missing deserve the same answer.

Treating the result of and / or as a bool

Wrong

python
retry_delays = []
longest = retry_delays and max(retry_delays)
print(longest + 1)

Better

python
retry_delays = []
longest = max(retry_delays) if retry_delays else 0
print(longest + 1)

What you see: The wrong version stops with TypeError: can only concatenate list (not "int") to list. The better version prints 1.

Why: The guard was there because max() raises on an empty sequence, and it does stop max() running. But and returns the falsy operand itself, so longest is the empty list rather than a number. A conditional expression states the fallback value outright, so the name always holds a number.

How bool() answers — the first rung that applies wins

__bool__

Defined? Python calls it and takes its word. It must return True or False.

__len__

No __bool__? Length decides: 0 is falsy, any other length is truthy.

Truthy

Neither method? The object is truthy. That is the default for every class.

  1. __bool__ — Defined? Python calls it and takes its word. It must return True or False.
  2. __len__ — No __bool__? Length decides: 0 is falsy, any other length is truthy.
  3. Truthy — Neither method? The object is truthy. That is the default for every class.

Cases that surprise people

Cases that surprise people
WrittenResult
bool(float("nan"))True — NaN is not zero, so it is truthy
bool(0.0j)False — a zero complex number is falsy too
bool(1 + 0j)True — any non-zero complex number is
class X: def __len__(self): return -1bool(X()) raises ValueError

Together

python
bool(float("nan"))   # True — NaN is not zero
bool(0.0j)             # False — a zero complex number is falsy too

Remember: Empty is not missing. if not retry_budget: also fires on 0 and "", so test if retry_budget is None: when you mean absent.

See also: none · conditional expressions · lists

None

standardbeginner

None is the single object that means "no value". A running program holds exactly one of it, which is why you ask whether something is missing with is None rather than with == None.

Think of it as

One object, many names. Every None in the program is that same one object, so x is None asks which object you are holding — a question no class can answer on your behalf.

python
cached_token = None                # bind a name to the one None object
if cached_token is None:           # identity, never == None
    cached_token = fetch_token()


def add_tag(tag, tags=None):       # None as the sentinel default
    tags = [] if tags is None else tags

What we're doing: Show that every None is the same object, and that a function with no return statement still returns it.

tokens.pypython
cached_token = None
fallback_token = None

print(cached_token is None, cached_token is fallback_token)
print(id(cached_token) == id(fallback_token), type(None).__name__)


def parse_config(path):
    print("reading", path)


result = parse_config("settings.toml")
print(result, result is None)

print(bool(None), None == False)
1–2
Two names, both bound to None. No second None is built — there is only ever one.
4
is asks about identity. Both answers are True, because both names hold that one object.
5
id() reports which object a name is bound to; both report the same one. Its type is NoneType.
8–9
parse_config prints and then stops. It has no return statement at all.
12
The call still produces a value: a function that falls off the end returns None.
15
None is falsy, so bool(None) is False. It is still not equal to False.
Output
True True
True NoneType
reading settings.toml
None True
False False

Why this works: NoneType has one instance, so assigning None anywhere binds a name to that same object and id() matches. A function with no return statement finishes by returning it, which makes "returned nothing" and "returned None" the same result. Being falsy is a separate property from being equal to False, so None == False stays False.

Many names, one object

cached_token

a name assigned None

parse_config()

a call with no return statement

the one None

both land here — same object, same id()

  • cached_token — a name assigned None
  • parse_config() — a call with no return statement
  • the one None — both land here — same object, same id()

Testing for None with == lets the other object answer

Wrong

python
class Wildcard:
    def __eq__(self, other):
        return True


matched_rule = Wildcard()
if matched_rule == None:
    print("no rule matched")
else:
    print("rule:", type(matched_rule).__name__)

Better

python
class Wildcard:
    def __eq__(self, other):
        return True


matched_rule = Wildcard()
if matched_rule is None:
    print("no rule matched")
else:
    print("rule:", type(matched_rule).__name__)

What you see: The wrong version prints "no rule matched" while holding a real Wildcard. The better version prints "rule: Wildcard".

Why: == hands the question to the object: matched_rule == None calls Wildcard.__eq__, which returns True for every comparison, so the branch for a missing rule runs on a rule that exists. is compares identity — which object you are holding — and a class cannot redefine that. Most classes answer honestly, which is what makes the rare dishonest one so hard to track down.

Three ways to return None

Three ways to return None
WrittenNote
def f(): passno return statement at all
def f(): returna bare return, no value
def f(): return Noneexplicit — reads as a deliberate choice, not an oversight

Together

python
def f1(): pass
def f2(): return
def f3(): return None

f1() is None, f2() is None, f3() is None   # (True, True, True) — identical

Remember: Test with is None, never == None. A class can define __eq__ and claim equality with anything; identity cannot be overridden.

See also: names and references · lists · truthiness

Advertisement

Identity, equality and mutability

Two questions a name and its object can answer differently, and what that difference costs at a function boundary.

is vs ==

corebeginner

is asks whether two names point at the exact same object; == asks whether they count as equal, via __eq__. Two separately built objects can hold an equal value and still fail an is check — that gap is where most is/== bugs live.

Think of it as

is checks an address; == asks a question and trusts the answer it gets back. CPython quietly reuses one object for a few common values — small integers, some short strings — which makes is happen to agree with == in a quick demo, right up until a value leaves that reserved range.

python
cached_row is None          # identity — correct: None has exactly one instance
first_name == second_name   # equality — correct: compares the characters
status_code == 200          # equality — never write "is" against a literal
cached_row is live_row      # identity — are these two names the same object?

What we're doing: Watch is agree with == by coincidence for a small int, then disagree once a value is built at runtime, and confirm neither test works for NaN.

compare.pypython
import json
import math

small_a, small_b = 5, 5
print(small_a is small_b, small_a == small_b)

page_a = json.loads('{"views": 12000}')["views"]
page_b = json.loads('{"views": 12000}')["views"]
print(page_a == page_b, page_a is page_b)

label_a = f"user-{page_a}"
label_b = f"user-{page_a}"
print(label_a == label_b, label_a is label_b)

not_a_number = float("nan")
print(not_a_number == not_a_number, math.isnan(not_a_number))
5
Both True: 5 falls inside the range of small ints CPython pre-builds once and reuses everywhere.
7–8
Two separate calls to json.loads build two separate int objects, even though both hold 12000.
9
== reports True — same value. is reports False — 12000 sits outside the small-int cache.
11–12
Two f-strings, built independently at runtime, produce two distinct string objects.
13
Equal value, different objects — the same shape of answer as the ints above.
16
A NaN never equals itself by design (IEEE 754), so == cannot detect it. math.isnan() can.
Output
True True
True False
True False
False True

Why this works: is only ever answers "same object", which is a fact about how a value was built, not about what it means. CPython's small-int and string caches make identity and equality agree for values typed as literals, but json.loads, string formatting, arithmetic and every other runtime computation build fresh objects that == still recognises as equal and is does not. NaN is the sharpest case: it fails == against itself entirely, by specification, so even equality needs a dedicated test.

Comparing a parsed value against a literal with is

Wrong

python
def parse_status(raw_status):
    return int(raw_status)


def is_not_found(status_code):
    return status_code is 404


received = parse_status("404")
print(is_not_found(received))

Better

python
def parse_status(raw_status):
    return int(raw_status)


def is_not_found(status_code):
    return status_code == 404


received = parse_status("404")
print(is_not_found(received))

What you see: The wrong version prints False for a status code that is plainly 404 — the not-found branch never runs. Python flags the line itself: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?

Why: int(raw_status) builds a new int object at runtime, and 404 sits outside the range CPython pre-builds and reuses (-5 to 256), so the parsed value and the literal 404 inside is_not_found are two different objects with the same value. is compares identity, not value, so the check silently fails. == is the right question here, and CPython's own warning is pointing at exactly this line the moment it sees is used against a literal.

Two different questions

is — identity

  • +Asks: the exact same object?
  • +True only when id(a) == id(b)
  • +A class cannot override it
  • +The only correct test for None, True, False

== — equality

  • Asks: does this object call itself equal?
  • Calls a.__eq__(b) and trusts the answer
  • A class can override __eq__ to lie
  • The right test for comparing values
  • is — identity
    • Asks: the exact same object?
    • True only when id(a) == id(b)
    • A class cannot override it
    • The only correct test for None, True, False
  • == — equality
    • Asks: does this object call itself equal?
    • Calls a.__eq__(b) and trusts the answer
    • A class can override __eq__ to lie
    • The right test for comparing values

is vs == across common comparisons

is vs == across common comparisons
Comparedis==
5 and 5 (small-int cache)TrueTrue
a view count parsed twice from JSONFalseTrue
two f-strings built from the same valueFalseTrue
float("nan") and float("nan")FalseFalse
None and NoneTrueTrue
two custom objects with equal fieldsFalseTrue, if __eq__ compares fields

Together

python
import json

small_a, small_b = 5, 5
small_a is small_b                 # True — CPython caches -5..256

page_a = json.loads('{"views": 12000}')["views"]
page_b = json.loads('{"views": 12000}')["views"]
page_a == page_b, page_a is page_b   # (True, False) — equal, but two objects

Remember: is asks "same object?", == asks "same value?". Reserve is for None, True, False and identity checks — use == for anything parsed, computed, or built twice.

See also: none · names and references · hashability

Hashability

standardbeginner

A hashable object can produce hash(obj), an int used to file it in a dict or set. The rule that makes this work: equal objects must return equal hashes — which is why every mutable built-in refuses to be hashed at all.

Think of it as

A hash is a locker number computed from a value, used to find it again without checking every locker. The rule that keeps lockers findable: equal values get the same number. A value that could change after its locker was assigned would leave the number on the door wrong.

python
hash("retry-count")           # works — str is immutable
seen_ids = {(1, "a"), (2, "b")}   # a set of tuples — each tuple must be hashable
cache = {}
cache[user_id, filter_tuple] = result   # any hashable key, including a tuple

What we're doing: Watch a mutable value get rejected as a key, see hashability travel through a tuple, and give a custom class a hash that agrees with its equality.

hashing.pypython
scores = {}
scores["alice"] = 91

try:
    scores[["alice", "bob"]] = 0
except TypeError as e:
    print(e)

coords = (1, 2)
print(hash(coords) == hash((1, 2)))

nested = (1, [2, 3])
try:
    hash(nested)
except TypeError as e:
    print(e)


class Tag:
    def __init__(self, name):
        self.name = name

    def __eq__(self, other):
        return self.name == other.name

    def __hash__(self):
        return hash(self.name)

    def __repr__(self):
        return f"Tag({self.name!r})"


print({Tag("beta"), Tag("beta")})
4–7
A list has no __hash__ at all, so using one as a dict key fails before the assignment happens.
10
Two tuples holding the same values hash the same — hash(coords) walks its elements, same as == would compare them.
12–16
The tuple itself is not mutable, but hash() still has to hash what is inside it — and one list inside is enough to fail.
19–28
Tag pairs __eq__ with a __hash__ that hashes the same field __eq__ compares, which is the contract hashability requires.
32
Two Tag("beta") instances are different objects but compare equal and hash equal, so the set keeps only one.
Output
cannot use 'list' as a dict key (unhashable type: 'list')
True
unhashable type: 'list'
{Tag('beta')}

Why this works: A dict or set stores each entry in the bucket its hash points to, and finds it again the same way — by hashing it and looking in that bucket. That only works if hashing gives the same answer every time, which a mutable value cannot promise. A tuple delegates its hash to its elements, so nesting one mutable object is enough to make the whole tuple unhashable. A custom class starts out hashable by id(), but defining __eq__ without __hash__ would let two equal instances land in different buckets — which is exactly the inconsistency the rule forbids — so Python disables hashing for you until you supply a __hash__ that agrees.

A list inside a memoization key

Wrong

python
results = {}


def build_report(user_id, filters):
    return f"report for {user_id} with {filters}"


def fetch_report(user_id, filters):
    key = (user_id, filters)
    if key not in results:
        results[key] = build_report(user_id, filters)
    return results[key]


print(fetch_report(42, ["active", "verified"]))

Better

python
results = {}


def build_report(user_id, filters):
    return f"report for {user_id} with {filters}"


def fetch_report(user_id, filters):
    key = (user_id, tuple(filters))
    if key not in results:
        results[key] = build_report(user_id, filters)
    return results[key]


print(fetch_report(42, ["active", "verified"]))

What you see: TypeError: cannot use 'tuple' as a dict key (unhashable type: 'list') — raised on the `key not in results` line, before the cache is ever consulted.

Why: filters arrives as a list because that is the natural shape for a variable number of values, but a list has no __hash__, and neither does a tuple that holds one. tuple(filters) converts it once, at the point the key is built, into something hashable — the fix belongs where the key is constructed, not deeper inside dict internals.

What can be hashed

Hashable

int, float, str, bytes

tuple — only if every item is

frozenset

Not hashable

list

dict

set

  • Hashable — the value cannot change once it is stored
    • int, float, str, bytes
    • tuple — only if every item is
    • frozenset
  • Not hashable — the value could change after it is stored
    • list
    • dict
    • set

Hashable, by type

Hashable, by type
Typehash() works?Why
int, float, str, bytesyesimmutable — the value cannot change under a stored hash
tupleonly if every item ishash() walks each element and combines them
frozensetyesan immutable set, built for this exact case
list, dict, set, bytearraynomutable — a stored hash could go stale
a plain object()yeshashes by id() — every instance is its own bucket

Together

python
hash(42)                # 42 — small ints hash to themselves
hash((1, "ok"))          # combines the hash of every element (varies by run — str hashing is salted)
hash([1, 2])             # TypeError: unhashable type: 'list'

Making a class hashable

Making a class hashable
WrittenResult
class Tag: passhashable — default hash is based on id()
adds __eq__, no __hash__unhashable — Python sets __hash__ to None for you
adds __eq__ and a matching __hash__hashable — equal instances hash equal

Together

python
class Tag:
    def __init__(self, name):
        self.name = name

    def __eq__(self, other):
        return self.name == other.name

    def __hash__(self):
        return hash(self.name)   # must agree with __eq__

Remember: Equal objects must hash equal, so anything mutable is unhashable by design. tuple(a_list) turns a list into a usable dict key or set member.

See also: is vs equals · mutable vs immutable · dictionaries · sets

Mutable vs immutable objects

corebeginner

A mutable object can change while staying the same object — id() is unchanged, contents are not. An immutable one never changes: every edit builds a new object. Passing one into a function decides whether it can edit what the caller holds.

Think of it as

A mutable object is a whiteboard: write on it and everyone holding a marker to that same board sees the new line. An immutable object is a printed page: "editing" it means printing a new page and handing that one out instead — whoever kept the old page is still reading the old text.

python
values = [1, 2]
values.append(3)          # mutates in place — values is now [1, 2, 3]

label = "v1"
label += ".2"              # rebinds — a new string; the original "v1" is untouched

def add_item(items, item):
    items.append(item)     # visible to the caller — items is the caller's own list

What we're doing: Run the same += pattern inside a function once on a list and once on a string, and watch only one of them leak back to the caller.

aliasing.pypython
def extend_list(values, extra):
    before = id(values)
    values += extra
    print(id(values) == before)
    return values


def extend_text(label, suffix):
    before = id(label)
    label += suffix
    print(id(label) == before)
    return label


tags = ["core"]
extend_list(tags, ["beta"])
print(tags)

title = "build"
extend_text(title, "!")
print(title)
3
list defines __iadd__, so += calls it directly and extends the existing list — no new object.
10
str has no __iadd__, so += falls back to label = label + suffix — a new string, bound only locally.
16
True — extend_list edited the exact list object tags refers to.
17
tags now reads ['core', 'beta'] — the function's edit is visible outside it.
20
False — a new string was built inside extend_text.
21
title still reads "build" — the local rebind inside the function never reached it.
Output
True
['core', 'beta']
False
build

Why this works: A function parameter is bound to the same object the caller passed — never a copy. When that object is mutable and the function edits it in place, as values += extra does through list.__iadd__, the caller sees the edit through their own name. str has no __iadd__ to fall back on, so label += suffix instead builds a new string and rebinds the local name label to it, leaving the caller's title exactly where it was. The operator reads identically in both functions; only the type decides which of those two things actually happens.

A "read-only" function mutates the list it was handed

Wrong

python
def deduplicate(tags):
    tags.sort()
    unique = []
    for tag in tags:
        if not unique or unique[-1] != tag:
            unique.append(tag)
    return unique


original_tags = ["beta", "core", "beta", "alpha"]
sorted_unique = deduplicate(original_tags)
print(original_tags)

Better

python
def deduplicate(tags):
    tags = sorted(tags)   # sorted() returns a new list — the argument is untouched
    unique = []
    for tag in tags:
        if not unique or unique[-1] != tag:
            unique.append(tag)
    return unique


original_tags = ["beta", "core", "beta", "alpha"]
sorted_unique = deduplicate(original_tags)
print(original_tags)

What you see: The wrong version leaves original_tags as ['alpha', 'beta', 'beta', 'core'] — reordered by a function whose job was to return a new, deduplicated list, not to touch the one it was given.

Why: tags.sort() mutates in place, and tags is the same list object as original_tags — a function parameter never receives a copy. sorted(tags) instead builds a new list and rebinds the local name tags to it, so the caller's list is never touched. A function that hands back a computed result should build new values rather than edit the ones it was handed, unless mutating the argument is the function's stated job.

What += actually does

Mutable — list

  • +append(), sort(), += all edit the object in place
  • +id() before and after is the same
  • +Every alias, and every caller, sees the change

Immutable — str

  • Every "change" builds a new object
  • id() before and after differs
  • Other names holding the original see nothing
  • Mutable — list
    • append(), sort(), += all edit the object in place
    • id() before and after is the same
    • Every alias, and every caller, sees the change
  • Immutable — str
    • Every "change" builds a new object
    • id() before and after differs
    • Other names holding the original see nothing

In place, or a new object?

In place, or a new object?
Typex += yA method call
listmutates in place — id(x) unchanged.append(), .sort() mutate in place too
strrebinds x to a new objectevery str method returns a new string
tuplerebinds x to a new objectno mutating method exists at all
dictTypeError — dict has no +.update(), .pop() mutate in place
int, floatrebinds x to a new objectno mutating methods — numbers have none

Together

python
values = [1, 2]
before = id(values)
values += [3]
id(values) == before   # True — same list, extended in place

label = "v1"
before = id(label)
label += ".2"
id(label) == before   # False — a new string was built

Remember: A function receives the same object the caller holds, never a copy. Mutating it is visible to the caller; += on an immutable one only ever rebinds a local name.

See also: names and references · is vs equals · hashability · lists

Advertisement