Filter concepts by levelShowing all levels.

Python · Python Fundamentals

Built-ins worth knowing well

Concepts
14
Python overview

Iteration and ordering

Walking a sequence with extra structure — a position, a partner, or a new order.

enumerate

corebeginner

enumerate(iterable) wraps any iterable in a lazy sequence of (index, item) pairs, starting at 0 by default. It replaces the manual counter loop with one built into the language.

Think of it as

A numbered coat-check ticket handed out as each coat arrives, rather than the coat-checker keeping a separate tally sheet. The number and the item travel together as one pair from the moment enumerate hands them out — there is nothing to keep in sync by hand.

python
for index, item in enumerate(sequence):        # index starts at 0
    ...

for index, item in enumerate(sequence, start=1):  # index starts at 1
    ...

What we're doing: Loop with enumerate at the default start, then at start=1, and compare against the manual counter it replaces.

enumerate_demo.pypython
letters = ["a", "b", "c"]

for i, letter in enumerate(letters):
    print(i, letter)

for i, letter in enumerate(letters, start=1):
    print(i, letter)

# the manual version enumerate replaces
i = 0
for letter in letters:
    print(i, letter)
    i += 1
3
enumerate pairs each letter with its 0-based position — no separate counter variable to declare or update.
6
start=1 shifts every index by one; the items and their relative order are unchanged.
10
The equivalent manual loop: a counter initialized before the loop and incremented by hand on every pass — exactly what enumerate exists to remove.
Output
0 a
1 b
2 c
1 a
2 b
3 c
0 a
1 b
2 c

Why this works: enumerate wraps an iterable in a second, lazy iterable that yields (index, item) tuples — unpacking each pair directly in the for statement (for i, letter in ...) is what makes the index available without a separate variable. Because it is lazy, enumerate never builds the whole list of pairs up front; it produces the next one only when the loop asks. start shifts where counting begins but never changes which item pairs with which position relative to the others — it only reshapes the numbers, not the order.

Indexing back into the sequence instead of using the paired item

Wrong

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

for i, letter in enumerate(letters):
    print(i, letters[i + 1])

Better

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

for i, letter in enumerate(letters):
    print(i, letter)

What you see: IndexError: list index out of range on the last iteration — letters[i + 1] reaches one past the end of the list.

Why: enumerate already hands over the item at position i as letter; indexing back into the original sequence with letters[i + 1] is not just redundant, it silently asks for the WRONG item — one ahead of the pair enumerate actually produced — and eventually walks off the end. The item enumerate yields is the one to use directly; there is no reason to re-derive it from the index.

Each item leaves paired with its position

an iterable

["a", "b", "c"]

enumerate() pairs each item

with a running index, lazily

(index, item) tuples

(0, 'a'), (1, 'b'), (2, 'c')

  1. an iterable — ["a", "b", "c"]
  2. enumerate() pairs each item — with a running index, lazily
  3. (index, item) tuples — (0, 'a'), (1, 'b'), (2, 'c')

enumerate over ["a", "b", "c"]

enumerate over ["a", "b", "c"]
CallResult (materialized)
list(enumerate(["a", "b", "c"]))[(0, 'a'), (1, 'b'), (2, 'c')]
list(enumerate(["a", "b", "c"], start=1))[(1, 'a'), (2, 'b'), (3, 'c')]
list(enumerate("hi"))[(0, 'h'), (1, 'i')]
next(iter(enumerate(["a", "b"])))(0, 'a') — one pair at a time, lazily

Together

python
letters = ["a", "b", "c"]
for i, letter in enumerate(letters, start=1):
    print(i, letter)

Remember: enumerate(iterable, start=0) yields lazy (index, item) pairs — unpack them directly in a for loop instead of maintaining a manual counter.

See also: zip · iter next · range

zip

corebeginner

zip(*iterables) pairs up items from two or more iterables position by position, lazily, and stops as soon as the shortest input is exhausted. zip(a, b) is the standard way to loop over two sequences together.

Think of it as

A zipper joining two rows of teeth — each pull advances both sides by exactly one tooth, and the zipper cannot go further than whichever row runs out of teeth first, no matter how much longer the other row is.

python
for a, b in zip(list_a, list_b):     # walks both together, stops at the shorter
    ...

paired = list(zip(list_a, list_b))   # materialize if a real list is needed

What we're doing: Zip two equal-length lists, then two unequal ones to see the silent truncation, and build a dict from a zip of keys and values.

zip_demo.pypython
names = ["Ann", "Bo", "Cy"]
scores = [90, 85, 100]

for name, score in zip(names, scores):
    print(name, score)

short_scores = [90, 85]
print(list(zip(names, short_scores)))

lookup = dict(zip(names, scores))
print(lookup)
4
Both lists have three items, so zip produces three pairs, one per position.
8
short_scores has only two items — zip stops after two pairs and silently drops "Cy", the third name, with no error.
10
dict(zip(keys, values)) is the standard idiom for turning two parallel lists into one dict.
Output
Ann 90
Bo 85
Cy 100
[('Ann', 90), ('Bo', 85)]
{'Ann': 90, 'Bo': 85, 'Cy': 100}

Why this works: zip builds one iterator per argument internally and calls next() on all of them together for every position, packing whatever comes back into a tuple — the moment any one of those internal calls raises StopIteration, zip stops immediately rather than padding the gap, which is why the shorter list truncates the whole result instead of raising an error. dict(zip(keys, values)) works because dict() accepts any iterable of (key, value) pairs, and that is exactly what zip produces from two parallel sequences.

Assuming zip pads short iterables instead of truncating

Wrong

python
ids = [1, 2, 3, 4]
names = ["Ann", "Bo"]

for id_, name in zip(ids, names):
    print(id_, name)
print(f"processed {len(ids)} ids")

Better

python
from itertools import zip_longest

ids = [1, 2, 3, 4]
names = ["Ann", "Bo"]

for id_, name in zip_longest(ids, names, fillvalue="(none)"):
    print(id_, name)

What you see: The loop silently processes only 2 of the 4 ids — ids 3 and 4 never appear — while the print afterward still claims 4 ids were processed, because len(ids) counts the original list, not what the loop actually saw.

Why: zip stops at the shortest input with no warning, so pairing a 4-item list against a 2-item list quietly drops the last two ids instead of raising an error — the kind of silent data loss that is easy to miss in a larger program. itertools.zip_longest is the explicit opt-in for "pad instead of truncate," with fillvalue naming what to substitute for the missing side.

Two sequences walked in lockstep

two iterables

["Ann", "Bo"], [90, 85]

zip() pairs by position

stops at the shorter one

tuples, one per position

('Ann', 90), ('Bo', 85)

  1. two iterables — ["Ann", "Bo"], [90, 85]
  2. zip() pairs by position — stops at the shorter one
  3. tuples, one per position — ('Ann', 90), ('Bo', 85)

zip over lists of different lengths

zip over lists of different lengths
CallResult (materialized)
list(zip([1, 2, 3], ["a", "b", "c"]))[(1, 'a'), (2, 'b'), (3, 'c')]
list(zip([1, 2, 3], ["a", "b"]))[(1, 'a'), (2, 'b')] — third item of the longer list dropped
list(zip([1, 2], ["a", "b"], [True, False]))[(1, 'a', True), (2, 'b', False)] — any number of iterables
dict(zip(["x", "y"], [1, 2])){'x': 1, 'y': 2}

Together

python
names = ["Ann", "Bo", "Cy"]
scores = [90, 85, 100]
for name, score in zip(names, scores):
    print(name, score)

Remember: zip(*iterables) pairs items by position and stops at the shortest input — no error, no padding, unless itertools.zip_longest is used instead.

See also: enumerate · dictionaries · unpacking

sorted

corebeginner

sorted(iterable, key=None, reverse=False) returns a new sorted list built from any iterable, without changing the original. key names a function called on each item to decide sort order; reverse=True sorts descending.

Think of it as

A photocopier, not a filing cabinet reorganized in place — sorted() hands back a brand-new, ordered stack of copies while the original pile of papers sits exactly where it was, unsorted and untouched.

python
sorted(iterable)                          # ascending, natural order
sorted(iterable, key=len)                 # ascending, by a derived value
sorted(iterable, key=len, reverse=True)   # descending, by a derived value

What we're doing: Sort a list of words plainly, by a key function, and in reverse — then confirm the original list is untouched by any of them.

sorted_demo.pypython
words = ["banana", "kiwi", "fig", "apple"]

print(sorted(words))
print(sorted(words, key=len))
print(sorted(words, key=len, reverse=True))
print(words)

people = [("Ann", 30), ("Bo", 25), ("Cy", 25)]
print(sorted(people, key=lambda p: p[1]))
3
Plain sorted() uses each string's natural, alphabetical ordering.
4
key=len calls len() on each word first and sorts by that result — shortest to longest.
5
reverse=True flips the order sorted() would otherwise produce, still using the same key.
6
words itself is printed last, in its original order — none of the three sorted() calls modified it.
9
key=lambda p: p[1] sorts tuples by their second element (age); Bo and Cy share age 25 and keep their original relative order — sorted() is stable.
Output
['apple', 'banana', 'fig', 'kiwi']
['fig', 'kiwi', 'apple', 'banana']
['banana', 'apple', 'kiwi', 'fig']
['banana', 'kiwi', 'fig', 'apple']
[('Bo', 25), ('Cy', 25), ('Ann', 30)]

Why this works: sorted() always builds and returns a brand-new list, so no matter how many times it is called on words, the original list keeps whatever order it started with — the final print(words) proves that directly. key=len does not sort the lengths themselves; it calls len(item) once per item to get a comparison value, then sorts the ORIGINAL items using those values, which is why the result is still a list of words, not a list of numbers. sorted() is also stable: when two items compare equal under the key (Bo and Cy both have age 25), it keeps their original relative order rather than reshuffling ties arbitrarily.

Assigning sorted()'s result away and expecting the original changed

Wrong

python
scores = [30, 10, 20]
sorted(scores)
print(scores[0])

Better

python
scores = [30, 10, 20]
scores = sorted(scores)
print(scores[0])

What you see: scores[0] prints 30 — the original first element — not 10, the smallest value, even after calling sorted(scores).

Why: sorted(scores) computes a new sorted list and returns it, but a bare expression statement with no assignment throws that return value away immediately — scores itself is never touched. The fix is either to capture the return value, as shown, or to reach for scores.sort() instead, which mutates scores in place and needs no reassignment.

sorted() vs list.sort()

sorted(words)

  • +Returns a brand-new sorted list
  • +The original iterable is left untouched
  • +Works on any iterable — tuple, set, dict, string

words.sort()

  • Sorts the list in place, returns None
  • The original list itself is reordered
  • Only exists on list — not on tuples or sets
  • sorted(words)
    • Returns a brand-new sorted list
    • The original iterable is left untouched
    • Works on any iterable — tuple, set, dict, string
  • words.sort()
    • Sorts the list in place, returns None
    • The original list itself is reordered
    • Only exists on list — not on tuples or sets

sorted() variations

sorted() variations
CallResult
sorted([3, 1, 2])[1, 2, 3]
sorted(["banana", "kiwi", "fig"], key=len)['fig', 'kiwi', 'banana'] — sorted by length
sorted([3, 1, 2], reverse=True)[3, 2, 1]
sorted({"b": 2, "a": 1})['a', 'b'] — sorting a dict sorts its keys

Together

python
words = ["banana", "kiwi", "fig"]
sorted(words)                 # ['banana', 'fig', 'kiwi'] — alphabetical
sorted(words, key=len)        # ['fig', 'kiwi', 'banana'] — by length

Remember: sorted(iterable, key=None, reverse=False) always returns a new list and never touches the original — list.sort() is the in-place version that returns None.

See also: reversed · lambda functions · higher order functions

reversed

standardbeginner

reversed(sequence) returns a lazy iterator that yields items back to front, without building a reversed copy or changing the original sequence. It needs a sequence with a known length, not any iterable.

Think of it as

Walking down a numbered hallway from the last door to the first, rather than photocopying every door in reverse order first. reversed() only needs to know where the last door is to start — it never has to lay out the whole reversed hallway in advance.

python
for item in reversed(sequence):     # lazy, back to front
    ...

backwards = list(reversed(sequence))  # materialize if a real list is needed

What we're doing: Loop backwards with reversed(), confirm the original is untouched, and compare against slicing and in-place reverse().

reversed_demo.pypython
nums = [1, 2, 3, 4]

for n in reversed(nums):
    print(n)

print(nums)
print(nums[::-1])

nums.reverse()
print(nums)
3
reversed(nums) yields 4, 3, 2, 1 lazily, one at a time, without building a new list up front.
6
nums is printed unchanged — reversed() never modifies the sequence it walks.
7
nums[::-1] produces the same order as a real, immediately-built list — a different mechanism, same result.
9
nums.reverse() is the odd one out: it reverses IN PLACE and returns None, so it cannot be used inside print() directly.
Output
4
3
2
1
[1, 2, 3, 4]
[4, 3, 2, 1]
[4, 3, 2, 1]

Why this works: reversed() works by using the sequence's length and index support to walk backwards from the last valid index to the first, producing one item per step without ever allocating a second, reversed collection — that laziness is the whole difference from nums[::-1], which builds the full reversed list immediately and costs memory proportional to the sequence length up front. nums.reverse() is unrelated to both: it is a method that reorders nums's own contents in place and returns None, which is why the final nums printed as reversed only after that call, not before.

Calling reversed() on a plain generator

Wrong

python
def count_up():
    yield 1
    yield 2
    yield 3

print(list(reversed(count_up())))

Better

python
def count_up():
    yield 1
    yield 2
    yield 3

print(list(reversed(list(count_up()))))

What you see: TypeError: argument to reversed() must be a sequence — a generator has no len() and no indexing, so reversed() has nothing to walk backwards from.

Why: reversed() needs to know the length and be able to jump to the last index directly — a plain generator only knows how to produce its next value moving forward, once, and has neither. Materializing it into a list first, as the fixed version does, gives reversed() an actual sequence to work with, at the cost of holding every item in memory at once.

reversed() vs its alternatives

reversed() vs its alternatives
CallResult
list(reversed([1, 2, 3]))[3, 2, 1] — new list, original unchanged
list(reversed("abc"))['c', 'b', 'a'] — strings work too
[1, 2, 3][::-1][3, 2, 1] — same result, builds the copy immediately
nums = [1, 2, 3]; nums.reverse(); nums[3, 2, 1] — in place, returns None

Together

python
nums = [1, 2, 3]
for n in reversed(nums):
    print(n)
print(nums)   # unchanged: [1, 2, 3]

Remember: reversed(sequence) is a lazy, backwards iterator — needs a real sequence, never modifies the original, and must be wrapped in list() to index into.

See also: sorted · extended slicing · iter next

Advertisement

Aggregating

Reducing an iterable down to one answer — a truth value, an extreme, or a total.

any and all

corebeginner

any(iterable) is True if at least one item is truthy; all(iterable) is True only if every item is. Both stop as soon as the answer is decided, and both return True/False on an empty iterable in the way their name implies logically.

Think of it as

A single lookout who can call out the answer the moment enough evidence is in. any() only needs to spot one truthy item before shouting yes and stopping; all() only needs to spot one falsy item before shouting no and stopping — neither has to keep checking after the answer is already certain.

python
any(x > 0 for x in values)     # True if at least one value is positive
all(x > 0 for x in values)     # True only if every value is positive

What we're doing: Use any() and all() with a generator expression to check a list of numbers, then confirm both short-circuit rather than scanning every item.

any_all_demo.pypython
nums = [4, 12, 7, 20]

print(any(n > 10 for n in nums))
print(all(n > 10 for n in nums))
print(all(n > 0 for n in nums))


def loud_check(n):
    print(f"checking {n}")
    return n > 10


print(any(loud_check(n) for n in nums))
3
any() is True because at least one value (12 or 20) is greater than 10.
4
all() is False because 4 and 7 are not greater than 10 — one failure is enough.
5
all() is True here because every value in nums is positive.
11
loud_check prints as it runs — watching the output shows any() stops calling it the moment 12 (the second item) returns True, never reaching 7 or 20.
Output
True
False
True
checking 4
checking 12
True

Why this works: any() and all() are both implemented as a loop that returns immediately once the answer cannot change — any() returns True the instant it sees a truthy item, without checking what comes after; all() returns False the instant it sees a falsy one, for the same reason. The loud_check example makes that concrete: only two calls happen ("checking 4" and "checking 12") even though nums has four items, because 12 already decided the answer and the remaining two items were never even generated by the generator expression, let alone checked.

Passing a list comprehension instead of a generator expression

Wrong

python
nums = [1, 2, 3, 4, 5]
print(any([expensive(n) for n in nums]))


def expensive(n):
    print(f"computing {n}")
    return n > 2

Better

python
nums = [1, 2, 3, 4, 5]
print(any(expensive(n) for n in nums))


def expensive(n):
    print(f"computing {n}")
    return n > 2

What you see: The [ ] version prints "computing 1" through "computing 5" — all five — before any() even starts checking, defeating the short-circuit any() offers.

Why: any([expensive(n) for n in nums]) builds the ENTIRE list first — every expensive(n) call runs to completion — and only then hands the finished list to any(), which has nothing left to short-circuit. Dropping the square brackets, as the fixed version does, passes a lazy generator expression instead, so any() can stop calling expensive() the moment it finds a truthy result.

One truthy item vs zero falsy items

an iterable of values

[0, "", "hi"]

any(): stop at the first truthy

all(): stop at the first falsy

True or False

as soon as the answer is certain

  1. an iterable of values — [0, "", "hi"]
  2. any(): stop at the first truthy — all(): stop at the first falsy
  3. True or False — as soon as the answer is certain

any() and all() over different inputs

any() and all() over different inputs
CallResult
any([0, "", None])False — every item is falsy
any([0, "", "hi"])True — "hi" is truthy
all([1, "x", True])True — every item is truthy
all([1, 0, "x"])False — 0 is falsy
any([]) / all([])False / True — the empty-iterable edge cases

Together

python
nums = [4, 12, 7, 20]
any(n > 10 for n in nums)     # True — 12 and 20 qualify
all(n > 0 for n in nums)      # True — every value is positive

Remember: any() is True on the first truthy item; all() is False on the first falsy one — both short-circuit, and any([]) is False while all([]) is True.

See also: truthiness · generator expressions · filter

min and max

standardbeginner

min(iterable) and max(iterable) return the smallest or largest item, comparing items directly unless key=func is given to compare by a derived value instead. Both also accept several separate arguments: min(a, b, c).

Think of it as

A single pass down a line of contestants, keeping only the current best-so-far and swapping it out whenever someone beats it — by the time the line ends, whoever is being held is the answer. key changes what counts as "beats it" without changing who is actually returned.

python
max(iterable)                 # largest item, direct comparison
max(iterable, key=len)        # largest item, by a derived value
max(a, b, c)                  # largest of several separate arguments
max(iterable, default=None)   # avoid ValueError on an empty iterable

What we're doing: Find min and max plainly, with a key function, and with default= on an empty list — then confirm the multi-argument call form works too.

min_max_demo.pypython
words = ["fig", "banana", "kiwi"]

print(max(words))
print(max(words, key=len))
print(min(words, key=len))

print(max(3, 7, 2))
print(min([], default="none"))
3
Plain max() compares strings directly — alphabetically, 'kiwi' sorts after both 'fig' and 'banana'.
4
key=len compares len(word) for each word instead — banana (6 letters) wins, even though it loses alphabetically.
5
min() with the same key finds the shortest word — fig, at 3 letters.
7
max(3, 7, 2) takes separate arguments directly, with no list or tuple wrapping them.
8
default="none" is returned because the list is empty — without it, this call would raise ValueError.
Output
kiwi
banana
fig
7
none

Why this works: max() and min() both walk their input once, keeping a running best-so-far and comparing every new item against it — with no key, that comparison uses the items' own < / > directly, which for strings means alphabetical order. key=len swaps in an extra step before every comparison: instead of comparing word to word, it compares len(word) to len(word), so the function that WINS is still the original string, just judged by a different measure. default only applies to the iterable form — an empty sequence with no default raises ValueError: max() iterable argument is empty, since there is genuinely no item to return.

Calling max() on an empty iterable with no default

Wrong

python
scores = []
highest = max(scores)
print(highest)

Better

python
scores = []
highest = max(scores, default=None)
print(highest)

What you see: ValueError: max() iterable argument is empty — raised immediately, before highest is ever assigned.

Why: max() has no sensible item to return from zero candidates, so it raises rather than guessing — unlike sum()'s default of 0, there is no value that is obviously 'the max of nothing.' default=None (or any other fallback value) tells max() explicitly what to return instead of raising, for exactly the case where the iterable might legitimately be empty.

min() and max() variations

min() and max() variations
CallResult
max([3, 1, 4, 1, 5])5
min(3, 1, 4)1 — separate arguments, no list needed
max(["fig", "banana", "kiwi"], key=len)'banana' — longest string
min([], default=0)0 — avoids ValueError on empty input

Together

python
words = ["fig", "banana", "kiwi"]
max(words)             # 'kiwi' — alphabetically last
max(words, key=len)    # 'banana' — longest string

Remember: min()/max() return the item itself, not its position. Both raise ValueError on an empty iterable unless default= is given.

See also: sorted · sum len · lambda functions

sum and len

corebeginner

len(obj) returns how many items a container or sequence holds. sum(iterable, start=0) adds every item to start, left to right — it works on numbers, not strings, because Python won't guess whether + should concatenate or add.

Think of it as

len() is reading a label already stamped on the container — every built-in sequence and collection tracks its own size, so len() is a lookup, not a count-as-you-go scan. sum() is the opposite: a running total carried down a line of numbers, added one at a time, starting from start.

python
len(container)              # item count — list, str, tuple, dict, set
sum(iterable)                # total, starting from 0
sum(iterable, start=100)     # total, starting from 100

What we're doing: Use len() across four container types and sum() with and without start, then show the TypeError sum() raises on strings.

sum_len_demo.pypython
nums = [1, 2, 3, 4]
text = "hello"
lookup = {"a": 1, "b": 2}

print(len(nums))
print(len(text))
print(len(lookup))

print(sum(nums))
print(sum(nums, 100))

words = ["a", "b", "c"]
try:
    sum(words)
except TypeError as e:
    print(e)
5
len(nums) counts the four elements of the list.
6
len(text) counts characters — 5 for "hello", not bytes or codepoints beyond that.
7
len(lookup) on a dict counts its keys, 2, not its keys plus values.
9
sum(nums) adds every item to the default start, 0.
10
sum(nums, 100) adds the same items but starts the running total at 100 instead of 0.
13
sum(words) tries 0 + "a" first and fails immediately — sum() never guesses that + should mean concatenation for strings.
Output
4
5
2
10
110
unsupported operand type(s) for +: 'int' and 'str'

Why this works: len() is fast and uniform across container types because every built-in sequence and collection maintains its own size internally — len() reads that stored value rather than counting elements one by one, which is why it costs the same whether the container holds 3 items or 3 million. sum() does the opposite: it genuinely walks the iterable, adding each item to a running total that begins at start, using the same + operator that item type defines. The TypeError happens because the very first step, 0 + "a", asks a str to add to an int, and str.__add__ only knows how to concatenate with another str — sum() has no special case for strings, precisely so it never has to guess between adding and concatenating.

Calling sum() on strings expecting concatenation

Wrong

python
words = ["Py", "thon"]
print(sum(words))

Better

python
words = ["Py", "thon"]
print("".join(words))

What you see: TypeError: unsupported operand type(s) for +: 'int' and 'str' — the failure happens on the very first addition, 0 + "Py".

Why: sum()'s start defaults to 0, an int, and Python never implicitly converts between int and str — 0 + "Py" is exactly as invalid as 0 + [1, 2] would be. "".join(words) is the tool actually designed for combining strings: it is also faster than repeated concatenation, because join() computes the final length once instead of building and discarding intermediate strings.

len() vs sum()

len(obj)

  • +Reads a size every container already stores
  • +O(1) — same cost for 3 items or 3 million
  • +Works on list, str, tuple, dict, set

sum(iterable)

  • Walks the iterable, adding to start (default 0)
  • O(n) — genuinely visits every item
  • TypeError on strings — 0 + "a" is undefined
  • len(obj)
    • Reads a size every container already stores
    • O(1) — same cost for 3 items or 3 million
    • Works on list, str, tuple, dict, set
  • sum(iterable)
    • Walks the iterable, adding to start (default 0)
    • O(n) — genuinely visits every item
    • TypeError on strings — 0 + "a" is undefined

len() and sum() across types

len() and sum() across types
CallResult
len([1, 2, 3])3
len("hello")5 — counts characters, not bytes
len({"a": 1, "b": 2})2 — counts keys
sum([1, 2, 3])6
sum([1, 2, 3], 10)16 — start shifts the total

Together

python
nums = [1, 2, 3, 4]
len(nums)          # 4
sum(nums)          # 10
sum(nums, 100)     # 110 — starting from 100 instead of 0

Remember: len() is an O(1) lookup on any sequence or collection. sum() adds numbers left to right and raises TypeError on strings — use str.join instead.

See also: min max · strings · lists

Advertisement

Producing and driving iterators

Building a lazy sequence, and the two calls underneath every for loop.

map

corebeginner

map(func, iterable) applies func to every item, lazily, and yields the results one at a time. map(func, a, b) applies func to items from a and b in parallel, stopping at the shorter one — the same behavior zip() has for pairing.

Think of it as

A conveyor belt with one machine bolted over it — every item that passes underneath gets the same operation applied before it comes out the other side, one at a time, and nothing is processed until it actually reaches the machine.

python
results = map(func, iterable)             # lazy — one func call per consumed item
results = map(func, iter_a, iter_b)       # parallel, stops at the shorter iterable
results = list(map(func, iterable))       # materialize if a real list is needed

What we're doing: Map a function over one iterable, then two in parallel, and confirm map() is lazy until consumed.

map_demo.pypython
prices = [10, 20, 30]
taxed = map(lambda p: round(p * 1.08, 2), prices)
print(list(taxed))

names = ["ann", "bo"]
scores = [90, 85]
print(list(map(lambda n, s: f"{n}: {s}", names, scores)))


def loud_double(n):
    print(f"doubling {n}")
    return n * 2


doubled = map(loud_double, [1, 2, 3])
print("map created, nothing printed yet")
print(list(doubled))
2
map() builds a lazy map object immediately — no function calls have happened yet.
3
list() is what actually consumes it, calling the lambda once per price.
7
With two iterables, map calls the function with one item from each, position by position — the same pairing zip() does.
15
This line proves map() is lazy: "map created, nothing printed yet" runs BEFORE any "doubling" line appears.
16
Only now, when list() consumes the map object, does loud_double actually run — three times, once per item.
Output
[10.8, 21.6, 32.4]
['ann: 90', 'bo: 85']
map created, nothing printed yet
doubling 1
doubling 2
doubling 3
[2, 4, 6]

Why this works: map(func, iterable) does not call func at all when it is created — it returns an iterator object that calls func(item) only when something asks it for the next value, exactly like a generator. That laziness is why 'map created, nothing printed yet' appears before any 'doubling' line: the map object exists, fully configured, but has not been touched. list(doubled) is what finally drives it, pulling one item at a time and calling loud_double on each. With two iterables, map calls func(a_item, b_item) using zip()'s own pairing rule underneath — position by position, stopping the moment either iterable runs out.

Forgetting map() is lazy and printing the map object itself

Wrong

python
nums = [1, 2, 3]
doubled = map(lambda n: n * 2, nums)
print(doubled)

Better

python
nums = [1, 2, 3]
doubled = map(lambda n: n * 2, nums)
print(list(doubled))

What you see: print(doubled) prints something like <map object at 0x...> — not the doubled numbers anyone would expect to see.

Why: map() returns an iterator, and printing an iterator shows its own repr — a memory address and a type name — not the values it would eventually produce; nothing has been computed yet. list(doubled), as the fixed version shows, is what actually drives the iterator to produce and collect every result.

One function applied to every item, lazily

an iterable

[1, 2, 3]

map(func, ...) applies func

to each item, one at a time

a lazy map object

list() to materialize

  1. an iterable — [1, 2, 3]
  2. map(func, ...) applies func — to each item, one at a time
  3. a lazy map object — list() to materialize

map() with one and several iterables

map() with one and several iterables
CallResult (materialized)
list(map(str.upper, ["a", "b"]))['A', 'B']
list(map(len, ["a", "bb", "ccc"]))[1, 2, 3]
list(map(lambda x, y: x + y, [1, 2], [10, 20]))[11, 22] — parallel across two iterables
list(map(lambda x, y: x + y, [1, 2, 3], [10, 20]))[11, 22] — stops at the shorter iterable

Together

python
prices = [10, 20, 30]
taxed = list(map(lambda p: round(p * 1.08, 2), prices))

Remember: map(func, iterable) is lazy — nothing runs until consumed — and with several iterables it pairs items positionally and stops at the shortest, same as zip().

See also: higher order functions · filter · zip

filter

standardbeginner

filter(func, iterable) keeps only the items where func(item) is truthy, lazily, yielding results one at a time. filter(None, iterable) is a shortcut that drops every falsy item directly, with no function needed.

Think of it as

A sieve, not a sorter — every item passes through the same single test, one at a time, and only the ones that pass make it to the other side. Nothing about the items that fail is kept or reported; they simply do not come out.

python
filter(func, iterable)     # keeps items where func(item) is truthy
filter(None, iterable)     # keeps items that are truthy themselves

What we're doing: Filter with an explicit predicate, then with None to drop falsy values directly, and confirm filter() is lazy until consumed.

filter_demo.pypython
nums = [1, -2, 3, -4, 5]
positives = filter(lambda n: n > 0, nums)
print(list(positives))

mixed = [0, 1, "", "hi", None, 5, []]
print(list(filter(None, mixed)))


def loud_check(n):
    print(f"checking {n}")
    return n > 0


kept = filter(loud_check, nums)
print("filter created, nothing printed yet")
print(list(kept))
2
filter() builds a lazy filter object immediately — no calls to the lambda have happened yet.
3
list() consumes it, calling the lambda once per item and keeping only the truthy results.
6
filter(None, mixed) drops every falsy value directly — 0, "", None, and [] all disappear, with no function written.
15
This line runs before any "checking" line appears — filter(), like map(), does nothing until consumed.
16
list(kept) is what finally drives it, calling loud_check once per item in nums.
Output
[1, 3, 5]
[1, 'hi', 5]
filter created, nothing printed yet
checking 1
checking -2
checking 3
checking -4
checking 5
[1, 3, 5]

Why this works: filter(func, iterable) returns an iterator that, when pulled from, fetches the next item, calls func(item), and either yields it (if truthy) or silently skips to the next one — it never builds the kept items into a list up front, which is why nothing runs until list(kept) actually consumes it. filter(None, iterable) is a special case built into filter() itself: when func is None, it tests each item's own truthiness directly instead of calling a function on it, which is exactly what bool(item) would decide.

Passing filter() a function that always returns something, not True/False

Wrong

python
words = ["", "hi", "bye"]
print(list(filter(str.upper, words)))

Better

python
words = ["", "hi", "bye"]
print(list(filter(str.strip, words)))

What you see: filter(str.upper, words) actually still works here (str.upper('') is falsy '' and gets dropped) but relying on it is fragile — filter only cares whether the RETURN VALUE is truthy, not whether the function looks like a predicate.

Why: filter() does not require func to return an actual bool — it only checks whether the return value is truthy or falsy, the same rule truthiness.js describes for any value. str.upper happens to return a falsy empty string for an empty input, which makes the wrong example look like it works, but that is incidental to what upper() is for; str.strip is at least closer to the intended idea of testing content, though a real predicate like bool or a lambda checking length is the honest choice when filtering on "has content."

filter() variations

filter() variations
CallResult (materialized)
list(filter(lambda n: n > 0, [1, -2, 3, -4]))[1, 3]
list(filter(None, [0, 1, "", "hi", None, 5]))[1, 'hi', 5] — drops every falsy item
list(filter(str.isdigit, ["12", "ab", "3x"]))['12'] — a method reference works as func
[x for x in nums if x > 0]the generator-expression equivalent of the first row

Together

python
nums = [1, -2, 3, -4, 5]
positives = list(filter(lambda n: n > 0, nums))   # [1, 3, 5]

Remember: filter(func, it) keeps items where func(item) is truthy. filter(None, it) drops falsy items directly — including a real 0, unsafe when zero is meaningful.

See also: map · truthiness · generator expressions

iter and next

standardintermediate

iter(iterable) returns an iterator — an object with its own next(). next(iterator) pulls the next value, raising StopIteration once exhausted. A for loop is exactly iter() once, then next() repeatedly, catching StopIteration to stop.

Think of it as

iter() hands out a bookmark into a book; next() turns one page and reads it, moving the bookmark forward each time. Two readers with two separate calls to iter() on the same book get two independent bookmarks — reading with one never moves the other.

python
it = iter(sequence)          # get an iterator, once
value = next(it)              # pull one value, advances the iterator
value = next(it, default)     # pull one value, or default if exhausted

What we're doing: Call iter() and next() by hand to see exactly what a for loop does automatically, including the StopIteration a for loop catches silently.

iter_next_demo.pypython
nums = [10, 20, 30]
it = iter(nums)

print(next(it))
print(next(it))
print(next(it))
print(next(it, "no more"))

it2 = iter(nums)
try:
    while True:
        print(next(it2))
except StopIteration:
    print("done")
2
iter(nums) builds one iterator, positioned before the first item.
4
Each next(it) call returns the next value and moves the position forward by one.
7
A fourth next() with a default returns "no more" instead of raising, since the third call already exhausted it.
9
it2 is a completely separate iterator from it — iter() on the same list again starts over from the beginning.
11
This manual while/try/except is exactly what a for loop does internally: call next() repeatedly and stop cleanly the moment StopIteration is raised.
Output
10
20
30
no more
10
20
30
done

Why this works: iter(nums) does not copy nums — it creates a small separate object that only remembers a position, starting before the first item. Each call to next(it) asks that object for the value at its current position and moves it forward by one; once the position runs past the last item, next() raises StopIteration rather than returning some placeholder value, because there genuinely is no next value to hand back. A for loop is syntactic sugar for exactly the while/try/except shown on the last three lines — it calls iter() once at the start, then next() on every pass, and StopIteration is what tells it to exit the loop instead of being an error the loop lets propagate.

Calling iter() again expecting it to resume, not restart

Wrong

python
nums = [1, 2, 3]
it = iter(nums)
print(next(it))

it = iter(nums)
print(next(it))

Better

python
nums = [1, 2, 3]
it = iter(nums)
print(next(it))
print(next(it))

What you see: Both prints show 1 — the second one was expected to show 2, continuing where the first left off, but it starts over instead.

Why: iter(nums) always returns a brand-new iterator positioned at the start, whether or not one was already made from the same list — it has no memory of any other iterator's progress. Reusing the SAME iterator variable across calls to next(), as the fixed version does, is what actually advances through the sequence; calling iter() again is a reset, not a resume.

What a for loop does underneath

an iterable

[10, 20, 30]

iter() gets an iterator

a fresh position, once

next() pulls one value

repeatedly, until StopIteration

  1. an iterable — [10, 20, 30]
  2. iter() gets an iterator — a fresh position, once
  3. next() pulls one value — repeatedly, until StopIteration

iter() and next() by hand

iter() and next() by hand
CallResult
it = iter([1, 2]); next(it)1
next(it) # same it, second call2
next(it) # same it, third callraises StopIteration
next(it, "done") # after exhaustion'done' — default suppresses the error

Together

python
it = iter([10, 20])
print(next(it))       # 10
print(next(it))       # 20
print(next(it, "empty"))   # 'empty' — no third item, default used instead of raising

Remember: iter(obj) returns a fresh iterator; next(it) pulls one value, raising StopIteration when exhausted. A for loop is exactly this pair, automated.

See also: generator expressions · enumerate · range

range

corebeginner

range(stop), range(start, stop), and range(start, stop, step) describe an evenly-spaced sequence of integers without storing them — each value is computed only when asked for. stop is always excluded, the same half-open rule slicing uses.

Think of it as

A formula for a sequence, not a list of its results — range(1_000_000) takes the same tiny amount of memory as range(3), because it stores only start, stop, and step and computes any value on demand, the same way a slice describes a rule rather than pre-listing every index.

python
range(stop)                # 0, 1, ..., stop - 1
range(start, stop)         # start, ..., stop - 1
range(start, stop, step)   # start, start + step, ... — stop always excluded

What we're doing: Use all three forms of range(), confirm it behaves like a real sequence (len, indexing, "in"), and count down with a negative step.

range_demo.pypython
for i in range(3):
    print(i)

print(list(range(2, 10, 3)))

r = range(3, 20, 4)
print(len(r))
print(r[2])
print(15 in r)

for i in range(5, 0, -1):
    print(i, end=" ")
print()
1
range(3) with one argument starts at 0 and excludes 3 itself — three values, not four.
4
range(2, 10, 3) starts at 2 and adds 3 each time: 2, 5, 8 — the next value, 11, is past stop and excluded.
7
len(r) is computed from start/stop/step directly — range never counted anything to know its own length.
8
r[2] indexes directly into the range without walking through the first two values first.
9
"in" works the same way — a fast membership check, not a linear scan through every value.
11
A negative step counts DOWN — start must be greater than stop for this to produce any values at all.
Output
0
1
2
[2, 5, 8]
5
11
True
5 4 3 2 1

Why this works: range does not store a list of numbers anywhere — it stores exactly three integers, start, stop, and step, and computes len(), indexing, and "in" directly from arithmetic on those three values rather than by materializing or scanning anything. r[2] is start + 2*step (3 + 2*4 = 11) computed in constant time, and 15 in r is answered the same way, by checking whether 15 fits the arithmetic sequence rather than checking it against every value one at a time. A negative step reverses the direction entirely: range(5, 0, -1) only produces values because 5 is greater than 0 in the downward direction step implies — the same call with a positive step would produce nothing.

Assuming stop is included, off-by-one

Wrong

python
total = 0
for i in range(1, 10):
    total += i
print(total)

Better

python
total = 0
for i in range(1, 11):
    total += i
print(total)

What you see: The wrong version sums 1 through 9 (total 45), silently missing 10 — no error, just a number one short of what "1 to 10" was meant to mean.

Why: range(1, 10) excludes 10 by design, the identical half-open rule slicing.js documents for seq[a:b] — stop marks where iteration stops, not the last value included. Summing 1 through 10 inclusive needs range(1, 11); the fix is always "add one past the last value actually wanted," not a special case of range().

A formula for a sequence, not a stored list

start, stop, step

range(0, 10, 2) — three ints stored

computed on demand

each value is arithmetic, not a lookup

0, 2, 4, 6, 8

stop (10) always excluded

  1. start, stop, step — range(0, 10, 2) — three ints stored
  2. computed on demand — each value is arithmetic, not a lookup
  3. 0, 2, 4, 6, 8 — stop (10) always excluded

range() forms

range() forms
CallResult (materialized)
list(range(5))[0, 1, 2, 3, 4]
list(range(2, 5))[2, 3, 4]
list(range(0, 10, 2))[0, 2, 4, 6, 8]
list(range(5, 0, -1))[5, 4, 3, 2, 1]
len(range(3, 20, 4))5 — computed, not counted

Together

python
for i in range(3):
    print(i)          # 0, 1, 2 — three iterations, stop excluded

Remember: range(start, stop, step) is a lazy, memory-cheap sequence — computed on demand, stop always excluded, and step can be negative to count down.

See also: slicing · enumerate · iter next

Advertisement

Introspection

Asking what an object IS and what it carries, without assuming either.

isinstance and issubclass

standardintermediate

isinstance(obj, type) checks whether obj is an instance of type — or of any type in a tuple of types — including subclasses. issubclass(cls, type) asks the same question about a class itself, not an instance of it.

Think of it as

isinstance asks a specific object to show ID; issubclass asks a whole class of ID whether it descends from another. bool being a subclass of int is the case that trips people up — True carries a bool ID card, but that card is also accepted anywhere an int card is, because bool inherits from int.

python
isinstance(obj, SomeType)              # is obj an instance of SomeType (or a subclass)?
isinstance(obj, (TypeA, TypeB))        # is obj an instance of any of these?
issubclass(SomeClass, SomeType)        # does SomeClass inherit from SomeType?

What we're doing: Use isinstance() with a single type and a tuple of types, then issubclass() to check inheritance directly — including the bool/int surprise.

isinstance_demo.pypython
def describe(value):
    if isinstance(value, bool):
        return "boolean"
    if isinstance(value, (int, float)):
        return "numeric"
    if isinstance(value, str):
        return "text"
    return "other"


print(describe(5))
print(describe(True))
print(describe(3.14))

print(issubclass(bool, int))
print(isinstance(True, int))
2
Checking bool FIRST matters: without this line, isinstance(True, (int, float)) below would also match, since bool is a subclass of int.
4
A tuple of types checks against either — True for 5 (int) and 3.14 (float) alike.
11
describe(True) hits the bool branch specifically, because that check runs before the broader numeric one.
15
issubclass(bool, int) confirms the relationship directly: bool really does inherit from int in Python's type hierarchy.
16
isinstance(True, int) follows from that inheritance — True the object IS an instance of int, just also more specifically a bool.
Output
numeric
boolean
numeric
True
True

Why this works: isinstance() walks up an object's class hierarchy (its type, then that type's bases, and so on) looking for a match against the type given — that walk is exactly why isinstance(True, int) is True: True's type is bool, and bool's base class is int, so the search finds int one step up. issubclass() does the identical hierarchy walk but starting from a class rather than an object's type — issubclass(bool, int) succeeds for the same reason, while issubclass(int, bool) fails because the inheritance only runs in the bool-descends-from-int direction, never the reverse.

Using type() == for a check that should accept subclasses

Wrong

python
def is_number(value):
    return type(value) == int or type(value) == float


print(is_number(True))

Better

python
def is_number(value):
    return isinstance(value, (int, float)) and not isinstance(value, bool)


print(is_number(True))

What you see: type(True) == int is False — type() returns the EXACT class, bool, never int, even though isinstance(True, int) would say True. Depending on which check the code actually wants, either result can be the surprising one.

Why: type(obj) == SomeType only ever matches the exact class, refusing any subclass relationship — the opposite failure mode from isinstance(), which accepts subclasses freely, including bool for int. Neither is universally "more correct" than the other: the fix is deciding explicitly which behavior a check needs, as the isinstance() version does by excluding bool on purpose rather than accepting it by isinstance()'s default rule.

isinstance() and issubclass() across types

isinstance() and issubclass() across types
CallResult
isinstance(5, int)True
isinstance(5, (int, float))True — matches any type in the tuple
isinstance(True, int)True — bool is a subclass of int
issubclass(bool, int)True
issubclass(int, bool)False — not the other way around

Together

python
def describe(value):
    if isinstance(value, (int, float)):
        return "numeric"
    return "other"

Remember: isinstance checks an object (including subclasses); issubclass checks a class. bool is a subclass of int, so isinstance(True, int) is True.

See also: callable · hasattr getattr setattr · truthiness

hasattr, getattr, and setattr

standardintermediate

getattr(obj, name, default) reads an attribute by its name as a string, returning default instead of raising if it's missing. setattr(obj, name, value) writes one the same way. hasattr(obj, name) checks existence first, without reading.

Think of it as

obj.name and dotted access are a shortcut for a lookup Python is always doing by name internally — getattr/setattr/hasattr just make that lookup available as a normal function call, where the name is a plain string instead of syntax baked into the source. That is what makes the name computable: obj.name cannot become obj.(some_variable), but getattr(obj, some_variable) can.

python
getattr(obj, "name")             # same as obj.name — raises if missing
getattr(obj, "name", default)    # same, but returns default instead of raising
setattr(obj, "name", value)      # same as obj.name = value
hasattr(obj, "name")             # True/False, no AttributeError either way

What we're doing: Read, check, and write an attribute by name, then use a NAME chosen at runtime — the case dotted syntax cannot express at all.

attr_demo.pypython
class Config:
    def __init__(self):
        self.timeout = 30


config = Config()

print(getattr(config, "timeout"))
print(getattr(config, "retries", 3))
print(hasattr(config, "timeout"))
print(hasattr(config, "retries"))

setattr(config, "timeout", 60)
print(config.timeout)

for field in ["timeout", "retries"]:
    print(field, getattr(config, field, "unset"))
8
getattr(config, "timeout") reads the attribute by its string name — identical result to config.timeout.
9
"retries" does not exist on config, but the default 3 is returned instead of raising AttributeError.
10
hasattr checks existence without reading — True because timeout is set.
11
hasattr for "retries" is False — it was never set, and no exception is raised by asking.
13
setattr writes the attribute the same way obj.name = value would, just with the name as a string.
16
This loop is the actual payoff: field is a variable, not a literal name, so obj.field would look up a real attribute called 'field' — getattr(config, field, ...) is the only way to look up a name that is only known at runtime.
Output
30
3
True
False
60
timeout 60
retries unset

Why this works: Dotted attribute access, obj.name, only works when name is written directly into the source as an identifier — Python resolves it at parse time, not from a variable. getattr(obj, name, default) takes name as an ordinary string value instead, so it can be a variable, a value read from a config file, or the result of a loop, none of which obj.name can express. hasattr is implemented in terms of getattr internally: it calls getattr(obj, name) in a try block and returns True on success, False if AttributeError was raised — which is also why hasattr never itself raises, no matter what name is passed.

Using hasattr then getattr as two separate calls

Wrong

python
if hasattr(config, "timeout"):
    value = getattr(config, "timeout")
else:
    value = 30

Better

python
value = getattr(config, "timeout", 30)

What you see: Not an error — but the wrong version does two attribute lookups (hasattr's internal getattr, then the explicit one) where one call does the same job.

Why: hasattr(obj, name) already performs a getattr internally just to see whether it raises — calling getattr(obj, name) again afterward repeats that lookup for no benefit. getattr(obj, name, default) does the check-and-read in one call, which is both shorter and avoids a narrow race where an attribute could theoretically change between the hasattr check and the later getattr call.

hasattr / getattr / setattr on a simple object

hasattr / getattr / setattr on a simple object
CallResult
getattr(config, "timeout")30 — same as config.timeout, if it exists
getattr(config, "retries", 3)3 — default used, no AttributeError, if missing
hasattr(config, "timeout")True
setattr(config, "timeout", 60)None — but config.timeout is now 60

Together

python
field = "timeout"
value = getattr(config, field, None)   # read by a NAME chosen at runtime

Remember: getattr/setattr/hasattr work by NAME as a string — the only way to look up an attribute chosen at runtime.

See also: callable · isinstance issubclass · function objects

callable

standardintermediate

callable(obj) returns True if obj can be called with (), without actually calling it. Functions, classes, and methods are callable; most everyday values — an int, a str, a list — are not, unless their type defines __call__.

Think of it as

A check for whether something HAS a trigger, without pulling it — callable() looks for a __call__ slot on the object's type and reports whether one exists, the same way isinstance() looks up a class hierarchy without touching the object's data.

python
callable(obj)   # True if obj() would be legal, without actually calling it

What we're doing: Check callable() against a function, a class, plain data, and an instance with __call__ defined, to see exactly what qualifies.

callable_demo.pypython
def greet():
    return "hi"


print(callable(greet))
print(callable(list))
print(callable(5))
print(callable("hi"))
print(callable([1, 2, 3]))


class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):
        return value * self.factor


times_three = Multiplier(3)
print(callable(times_three))
print(times_three(10))
5
A plain function is always callable — calling it is the entire point of a function.
6
list is a class — calling it, list(), is how a new list instance gets built.
7
5, an int, has no __call__ — 5() would raise TypeError, and callable() reports that in advance.
9
A list INSTANCE, unlike the list CLASS on line 6, is plain data with nothing to call.
16
__call__ is what makes an instance itself callable, not just its class — defining it turns times_three(10) into legal syntax.
Output
True
True
False
False
False
True
30

Why this works: callable(obj) looks at type(obj) and checks whether that type defines a __call__ method — it never inspects the object's data or runs any code. A function's type always defines __call__ (that is what makes function() legal at all), and every class does too, because calling a class is exactly how __init__ gets invoked to build a new instance. A list instance's type, list, defines plenty of methods but not __call__, so an actual list is never callable even though the list class that built it is. Multiplier defining __call__ is what makes times_three(10) legal — callable() reports that possibility exists before the call is ever attempted.

Confusing a class being callable with an instance of it being callable

Wrong

python
class Config:
    def __init__(self, timeout):
        self.timeout = timeout


config = Config(30)
print(callable(config))
config()

Better

python
class Config:
    def __init__(self, timeout):
        self.timeout = timeout


config = Config(30)
print(callable(Config))

What you see: callable(config) correctly prints False, and config() raises TypeError: 'Config' object is not callable — Config the class is callable, but config the instance is not, unless __call__ is defined.

Why: Config() being legal syntax is about calling the CLASS to construct an instance — that is unrelated to whether the resulting instance, config, can itself be called afterward. Those are two different callables checks entirely: callable(Config) is True because classes are always callable, callable(config) is False unless Config specifically defines __call__, which this version does not.

callable() across common objects

callable() across common objects
Objectcallable()?
a plain function, len, printTrue
a class, e.g. list, dict, or a user-defined oneTrue — calling it constructs an instance
5, "hi", [1, 2, 3]False — plain data, nothing to call
an instance whose class defines __call__True

Together

python
callable(len)        # True
callable(list)       # True — list() constructs a new list
callable([1, 2, 3])  # False — an actual list has nothing to call

Remember: callable(obj) checks whether obj() would be legal, without running it — True for functions/classes always, for an instance only if __call__ is defined.

See also: function objects · isinstance issubclass · first class functions

Advertisement