Emmanuel Akinfulubi

Functions all 5 written

Arguments, the mutable-default trap, scope and closures: the machinery behind every abstraction later in the course.

14 Functions: arguments, returns and docstrings

A function names a piece of behaviour so you can use it without re-reading it. That is the whole purpose, and it is worth keeping in view: the point is not to avoid typing the same lines twice, it is to give a chunk of logic a name and a boundary.

Defining and calling

Parameters, arguments, and functions as values

ran at build time
def area(width, height):
    return width * height

print("area(3, 4) =", area(3, 4))

# The names in the definition are PARAMETERS; the values at the call are ARGUMENTS.
# Passing by keyword makes the call readable and order-independent.
print("by keyword:  ", area(width=3, height=4))
print("reordered:   ", area(height=4, width=3))

# A function is an object, so it can be handed around without being called.
operation = area
print("through another name:", operation(2, 5))
print("the function itself:  ", area.__name__)

test("positional arguments work", area(3, 4), 12)
test("keyword arguments are order-independent", area(height=4, width=3), 12)
test("a function is a value", operation(2, 5), 10)

What it printed, when the build ran it

area(3, 4) = 12
by keyword:   12
reordered:    12
through another name: 10
the function itself:   area
ok   positional arguments work
ok   keyword arguments are order-independent
ok   a function is a value

Returning is not printing

This is the distinction that takes longest to settle. print shows something to a human and produces no value. return hands a value back to the calling code and shows nothing. A function that prints its answer cannot be used in a calculation.

The same arithmetic, one usable and one not

ran at build time
def returns_it(a, b):
    return a + b

def prints_it(a, b):
    print(a + b)

# The returned value can be used.
total = returns_it(2, 3) * 10
print("using the returned value:", total)

# The printed one cannot: the call evaluates to None.
result = prints_it(2, 3)          # prints 5 as a side effect
print("what prints_it returned: ", result)

test("a returned value is usable", returns_it(2, 3) * 10, 50)
test("a printing function returns None", prints_it(2, 3), None)

What it printed, when the build ran it

using the returned value: 50
5
what prints_it returned:  None
ok   a returned value is usable
5
ok   a printing function returns None

Trying to use the None that came back is where this becomes an error rather than a puzzle — and it is one of the most common beginner tracebacks.

The consequence: arithmetic on None

raises TypeError
def add(a, b):
    print(a + b)

total = add(2, 3)
print(total + 1)

The traceback it really produced, when the build ran it

5
Traceback (most recent call last):
  File "example.py", line 5, in <module>
    print(total + 1)
          ~~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'

Every function returns something

There is no such thing as a function that returns nothing. Falling off the end, or writing a bare return, both produce None. Knowing that removes the mystery from the traceback above.

Three ways to return None

ran at build time
def no_return():
    x = 1                # computes something and discards it

def bare_return():
    return

def explicit_none():
    return None

print("falling off the end:", no_return())
print("a bare return:      ", bare_return())
print("returning None:     ", explicit_none())

# return also exits immediately, which is what makes early returns useful.
def classify(n):
    if n < 0:
        return "negative"
    if n == 0:
        return "zero"
    return "positive"

print()
print([classify(n) for n in (-1, 0, 5)])

test("all three are None", (no_return(), bare_return(), explicit_none()), (None, None, None))
test("return exits immediately", classify(-1), "negative")

What it printed, when the build ran it

falling off the end: None
a bare return:       None
returning None:      None

['negative', 'zero', 'positive']
ok   all three are None
ok   return exits immediately

Early returns beat nested conditionals. Handling the awkward cases first and returning keeps the main logic unindented and at the bottom, where it is easy to find. The alternative — one if wrapping the whole body, then another inside it — pushes the important code to the right and makes it harder to read the further you nest.

Returning several values

A function returns one object, but that object can be a tuple, and the comma is enough to make one. At the call site it is unpacked, so it reads as though several values came back.

Multiple return values, and when to name them

ran at build time
def statistics(numbers):
    return min(numbers), max(numbers), sum(numbers) / len(numbers)

low, high, mean = statistics([2, 4, 6, 8])
print(f"low={low} high={high} mean={mean}")

# It really is one tuple.
packed = statistics([2, 4])
print("as one object:", packed, type(packed).__name__)

# Beyond two or three values, names beat positions.
from typing import NamedTuple

class Stats(NamedTuple):
    low: float
    high: float
    mean: float

def better_statistics(numbers):
    return Stats(min(numbers), max(numbers), sum(numbers) / len(numbers))

s = better_statistics([2, 4, 6, 8])
print()
print("named:", s)
print("reachable by name:", s.mean)

test("three values unpack from one tuple", (low, high, mean), (2, 8, 5.0))
test("the return value is a tuple", type(packed).__name__, "tuple")
test("a NamedTuple keeps the names", s.mean, 5.0)
test("and is still a tuple", tuple(s), (2, 8, 5.0))

What it printed, when the build ran it

low=2 high=8 mean=5.0
as one object: (2, 4, 3.0) tuple

named: Stats(low=2, high=8, mean=5.0)
reachable by name: 5.0
ok   three values unpack from one tuple
ok   the return value is a tuple
ok   a NamedTuple keeps the names
ok   and is still a tuple

Docstrings: documentation the language knows about

A string as the first statement of a function is its docstring. It is not a comment — it is stored on the function, help() reads it, and every editor and documentation tool uses it.

A docstring is data; a comment is not

ran at build time
def discount(price, percent):
    """Return price reduced by percent.

    Args:
        price: the original amount, in whole currency units.
        percent: how much to take off, 0-100.

    Raises:
        ValueError: if percent is outside 0-100.
    """
    if not 0 <= percent <= 100:
        raise ValueError(f"percent must be 0-100, got {percent}")
    return price * (1 - percent / 100)

print("discount(200, 10) =", discount(200, 10))
print()
print("the docstring is an attribute:")
print(discount.__doc__.splitlines()[0])

# A comment, by contrast, is discarded entirely.
def undocumented(x):
    # this comment is not retrievable at runtime
    return x

print()
print("a function with only a comment has __doc__ =", undocumented.__doc__)

test("the docstring is stored on the function", discount.__doc__.startswith("Return price reduced"), True)
test("a comment is not", undocumented.__doc__, None)
test("the function does its job", discount(200, 10), 180.0)

What it printed, when the build ran it

discount(200, 10) = 180.0

the docstring is an attribute:
Return price reduced by percent.

a function with only a comment has __doc__ = None
ok   the docstring is stored on the function
ok   a comment is not
ok   the function does its job

The docstring is also where you say what the function raises, which matters because that is part of its interface and is invisible from the signature.

Validating an argument, and saying why

raises ValueError
def discount(price, percent):
    """Return price reduced by percent."""
    if not 0 <= percent <= 100:
        raise ValueError(f"percent must be 0-100, got {percent}")
    return price * (1 - percent / 100)

discount(200, 150)

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 7, in <module>
    discount(200, 150)
    ~~~~~~~~^^^^^^^^^^
  File "example.py", line 4, in discount
    raise ValueError(f"percent must be 0-100, got {percent}")
ValueError: percent must be 0-100, got 150

Annotations: types as readable documentation

You can annotate parameters and the return value. Python does not check them at runtime — they are documentation that tools can read, and lesson 38 covers the checkers that act on them. Writing them makes a signature self-explanatory at no cost.

Annotations are recorded, not enforced

ran at build time
def repeat(text: str, times: int = 2) -> str:
    """Return text repeated, separated by spaces."""
    return " ".join([text] * times)

print(repeat("hi"))
print(repeat("hi", 3))

# The annotations are stored, and readable.
print()
print("annotations:", repeat.__annotations__)

# But nothing checks them. Passing an int where str was annotated is allowed
# through, and fails later -- inside join, for a reason the signature never
# mentioned.
print()
try:
    repeat(5, 2)
except TypeError as exc:
    print("repeat(5, 2) was permitted, then failed inside join:")
    print("   TypeError:", exc)

test("the function works as annotated", repeat("hi", 3), "hi hi hi")
test("annotations are recorded", repeat.__annotations__["times"], int)
test("the annotation did not reject the bad call",
     "annotation" in str(repeat.__annotations__).lower(), False)

What it printed, when the build ran it

hi hi
hi hi hi

annotations: {'text': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}

repeat(5, 2) was permitted, then failed inside join:
   TypeError: sequence item 0: expected str instance, int found
ok   the function works as annotated
ok   annotations are recorded
ok   the annotation did not reject the bad call

An annotation is a promise, not a guard. times: int does not stop anyone passing a string, and the failure will happen later and further away. If a value must really be an integer, validate it and raise — annotate and check, and let the annotation document what the check enforces.

A function should do one thing

The most useful habit in this lesson is not syntax. A function that fetches data, formats it and prints it cannot be tested, reused or recombined. Three functions can be.

Separating the work from the printing makes it testable

ran at build time
# One function doing three jobs.
def report_all(records):
    for r in records:
        if r["active"]:
            print(f"{r['name']}: {r['score']}")

# The same work, separated. Each piece is testable on its own.
def active(records):
    return [r for r in records if r["active"]]

def format_record(record):
    return f"{record['name']}: {record['score']}"

def report(records):
    for line in map(format_record, active(records)):
        print(line)

data = [
    {"name": "Ada", "score": 90, "active": True},
    {"name": "Alan", "score": 85, "active": False},
]

report(data)

# And now the middle pieces can be checked directly, which the first version
# made impossible without capturing stdout.
test("active() filters", [r["name"] for r in active(data)], ["Ada"])
test("format_record() formats one record", format_record(data[0]), "Ada: 90")
test("the pieces compose", [format_record(r) for r in active(data)], ["Ada: 90"])

What it printed, when the build ran it

Ada: 90
ok   active() filters
ok   format_record() formats one record
ok   the pieces compose

What to take from this lesson

  • return hands a value back; print shows it and returns None. A function that prints its answer cannot be used in a calculation.
  • Every function returns something — None if you do not say otherwise.
  • Return several values as a tuple, and use a NamedTuple once there are more than two or three.
  • A docstring is stored on the function and is the place to document what it raises.
  • Annotations document types but are not enforced at runtime.
  • Keep the computation separate from the printing, so it can be tested.

15 Default arguments, and the trap in the way they work

A default lets a parameter be omitted. The mechanism is one sentence long and has a consequence almost nobody guesses: the default expression is evaluated once, when the def line runs, and the same object is reused on every later call.

The ordinary case

Defaults, and where they are kept

ran at build time
def greet(name, greeting="hello", punctuation="!"):
    return f"{greeting}, {name}{punctuation}"

print(greet("Ada"))
print(greet("Ada", "hi"))
print(greet("Ada", punctuation="?"))         # skip one, name the next

# Parameters with defaults must come after those without.
print()
print("defaults are visible on the function:", greet.__defaults__)

test("defaults fill in the omitted arguments", greet("Ada"), "hello, Ada!")
test("a keyword argument can skip over one", greet("Ada", punctuation="?"), "hello, Ada?")
test("the defaults are stored as a tuple", greet.__defaults__, ("hello", "!"))

What it printed, when the build ran it

hello, Ada!
hi, Ada!
hello, Ada?

defaults are visible on the function: ('hello', '!')
ok   defaults fill in the omitted arguments
ok   a keyword argument can skip over one
ok   the defaults are stored as a tuple

The trap

A default that is a mutable object — a list, a dict, a set — is created once and shared by every call that relies on it. So a function that "starts with an empty list" does not: it starts with whatever the previous calls left in that one list.

One list, shared by every call

ran at build time
def collect(item, into=[]):          # the bug is on this line
    into.append(item)
    return into

print("first call: ", collect("a"))
print("second call:", collect("b"), "<- not ['b']")
print("third call: ", collect("c"), "<- it is accumulating")

# All three calls returned the SAME list object.
first = collect("d")
second = collect("e")
print()
print("are the returned lists the same object?", first is second)

# And it is visible on the function itself, which is the clinching evidence.
print("the default now holds:", collect.__defaults__)

test("the second call did not start empty", collect("x")[:2], ["a", "b"])
test("every call returns the same object", first is second, True)
test("the default itself has been mutated", len(collect.__defaults__[0]) > 1, True)

What it printed, when the build ran it

first call:  ['a']
second call: ['a', 'b'] <- not ['b']
third call:  ['a', 'b', 'c'] <- it is accumulating

are the returned lists the same object? True
the default now holds: (['a', 'b', 'c', 'd', 'e'],)
ok   the second call did not start empty
ok   every call returns the same object
ok   the default itself has been mutated

Why it happens, in one sentence. into=[] is evaluated when the def statement executes — once, as the module loads — not each time the function is called. The list it produced is stored on the function object and handed to every call that omits the argument. Nothing resets it.

The fix

Use None as the default and create the real value inside the function. This is the standard Python idiom and there is no shorter correct alternative.

None as the default, and the real value built inside

ran at build time
def collect(item, into=None):
    if into is None:
        into = []                   # a fresh list on every call that needs one
    into.append(item)
    return into

print("first call: ", collect("a"))
print("second call:", collect("b"), "<- correct")
print("third call: ", collect("c"))

# And passing one in still works, which is the point of the parameter.
existing = ["x"]
print()
print("with a list supplied:", collect("y", existing))
print("the caller's list was updated:", existing)

test("each call starts fresh", collect("z"), ["z"])
test("independent calls do not share", collect("p") is collect("q"), False)
test("a supplied list is still used", collect("y", ["x"]), ["x", "y"])

What it printed, when the build ran it

first call:  ['a']
second call: ['b'] <- correct
third call:  ['c']

with a list supplied: ['x', 'y']
the caller's list was updated: ['x', 'y']
ok   each call starts fresh
ok   independent calls do not share
ok   a supplied list is still used

Why if into is None and not if not into. This is lesson 10's rule applying here exactly: if not into is also true for an empty list that the caller passed deliberately, so it would silently replace the caller's list with a new one and the caller would never see the appended item. Test for None.

Why the truthiness test is not good enough

ran at build time
# The subtly wrong fix.
def wrong(item, into=None):
    if not into:                    # also true for a deliberately empty list
        into = []
    into.append(item)
    return into

def right(item, into=None):
    if into is None:
        into = []
    into.append(item)
    return into

passed_to_wrong = []
returned_wrong = wrong("a", passed_to_wrong)
print("after wrong():")
print("   returned:      ", returned_wrong)
print("   caller's list: ", passed_to_wrong, "<- still empty, the update went elsewhere")

passed_to_right = []
returned_right = right("a", passed_to_right)
print("after right():")
print("   returned:      ", returned_right)
print("   caller's list: ", passed_to_right, "<- updated, as asked")

test("both return something that looks correct", (returned_wrong, returned_right), (["a"], ["a"]))
test("but the truthiness version left the caller's list empty", passed_to_wrong, [])
test("while the is-None version updated it", passed_to_right, ["a"])

What it printed, when the build ran it

after wrong():
   returned:       ['a']
   caller's list:  [] <- still empty, the update went elsewhere
after right():
   returned:       ['a']
   caller's list:  ['a'] <- updated, as asked
ok   both return something that looks correct
ok   but the truthiness version left the caller's list empty
ok   while the is-None version updated it

The same trap, wearing different clothes

Once you know the mechanism you can predict the other cases. Any default whose value is computed at definition time is frozen at that moment — a timestamp is the one that causes real confusion in production.

A timestamp default is frozen at import

ran at build time
import time

def stamped(event, when=time.time()):      # evaluated ONCE, at definition
    return (event, when)

a = stamped("first")
time.sleep(0.01)
b = stamped("second")

print("first: ", a[1])
print("second:", b[1])
print("identical timestamps:", a[1] == b[1], "<- frozen at import time")

# The fix is the same shape.
def stamped_correctly(event, when=None):
    if when is None:
        when = time.time()
    return (event, when)

c = stamped_correctly("first")
time.sleep(0.01)
d = stamped_correctly("second")
print()
print("now they differ:", c[1] != d[1])

test("the computed default never changes", a[1], b[1])
test("the None version evaluates per call", c[1] != d[1], True)

What it printed, when the build ran it

first:  1790372925.922113
second: 1790372925.922113
identical timestamps: True <- frozen at import time

now they differ: True
ok   the computed default never changes
ok   the None version evaluates per call

When a mutable default is actually fine

The rule is often taught as "never use a mutable default", which is close enough. The precise version is: the danger is mutating the shared object. A mutable default that is only read is harmless — though using a tuple makes that intent obvious and removes the risk entirely.

Read-only defaults are safe; a tuple makes that explicit

ran at build time
# Only ever read, never mutated. This is safe.
def render(items, separators=[", ", " and "]):
    if len(items) < 2:
        return "".join(items)
    return separators[0].join(items[:-1]) + separators[1] + items[-1]

print(render(["a", "b", "c"]))
print(render(["a", "b", "c"]))       # repeatable, because nothing was changed

# A tuple says "this is not going to be modified" and makes it true.
def render_safely(items, separators=(", ", " and ")):
    if len(items) < 2:
        return "".join(items)
    return separators[0].join(items[:-1]) + separators[1] + items[-1]

print()
print("with a tuple default:", render_safely(["a", "b", "c"]))

test("repeated calls agree, because nothing is mutated",
     render(["a", "b"]), render(["a", "b"]))
test("the tuple version behaves identically", render_safely(["a", "b", "c"]), render(["a", "b", "c"]))

What it printed, when the build ran it

a, b and c
a, b and c

with a tuple default: a, b and c
ok   repeated calls agree, because nothing is mutated
ok   the tuple version behaves identically

Defaults are evaluated in the enclosing scope, at definition time

The value is captured, not the name

ran at build time
limit = 10

def capped(value, ceiling=limit):
    return min(value, ceiling)

print("with limit = 10:", capped(50))

limit = 100                      # changing the variable afterwards...
print("after limit = 100:", capped(50), "<- the default did not follow")

# Because the VALUE was captured, not the name.
print("stored default:", capped.__defaults__)

test("the default captured the value at definition", capped(50), 10)
test("and is unaffected by the later reassignment", capped.__defaults__, (10,))

What it printed, when the build ran it

with limit = 10: 10
after limit = 100: 10 <- the default did not follow
stored default: (10,)
ok   the default captured the value at definition
ok   and is unaffected by the later reassignment

What to take from this lesson

  • A default is evaluated once, when the def runs — not on each call.
  • Never use a mutable default you intend to modify. Use None and build the real value inside.
  • Test the sentinel with is None, not truthiness, or a deliberately empty argument gets silently replaced.
  • The same trap covers any computed default, including timestamps.
  • A default captures the value of a name, not the name itself.

16 *args, **kwargs and keyword-only parameters

* and ** do the same job in two directions. In a definition they collect leftover arguments; at a call they spread a collection into arguments. Every confusing use of them is one of those two things.

Collecting: *args and **kwargs

Collecting whatever was passed

ran at build time
def summarise(*args, **kwargs):
    return f"args={args}  kwargs={kwargs}"

print(summarise(1, 2, 3))
print(summarise(a=1, b=2))
print(summarise(1, 2, name="Ada"))
print(summarise())

# args is a TUPLE, kwargs is a DICT. The names are convention, not syntax.
def typed(*values, **options):
    return type(values).__name__, type(options).__name__

print()
print("the types they collect into:", typed(1, x=2))

test("positional extras arrive as a tuple", summarise(1, 2), "args=(1, 2)  kwargs={}")
test("keyword extras arrive as a dict", summarise(a=1), "args=()  kwargs={'a': 1}")
test("both are empty when nothing extra is passed", summarise(), "args=()  kwargs={}")
test("the collected types are tuple and dict", typed(1, x=2), ("tuple", "dict"))

What it printed, when the build ran it

args=(1, 2, 3)  kwargs={}
args=()  kwargs={'a': 1, 'b': 2}
args=(1, 2)  kwargs={'name': 'Ada'}
args=()  kwargs={}

the types they collect into: ('tuple', 'dict')
ok   positional extras arrive as a tuple
ok   keyword extras arrive as a dict
ok   both are empty when nothing extra is passed
ok   the collected types are tuple and dict

A genuine use: a function that accepts any number of values.

A function that takes as many values as you like

ran at build time
def longest(*words):
    if not words:
        return None
    return max(words, key=len)

print("longest('a', 'abc', 'ab') =", longest("a", "abc", "ab"))
print("longest() =", longest())

# Compare with taking a list, which forces brackets at every call.
def longest_from_list(words):
    return max(words, key=len) if words else None

print()
print("with *words:   longest('a', 'abc')      ->", longest("a", "abc"))
print("with a list:   longest_from_list(['a']) ->", longest_from_list(["a"]))

test("any number of arguments is accepted", longest("a", "abc", "ab"), "abc")
test("including none at all", longest(), None)

What it printed, when the build ran it

longest('a', 'abc', 'ab') = abc
longest() = None

with *words:   longest('a', 'abc')      -> abc
with a list:   longest_from_list(['a']) -> a
ok   any number of arguments is accepted
ok   including none at all

Spreading: the same symbols at a call

Spreading a tuple and a dict into a call

ran at build time
def area(width, height):
    return width * height

dimensions = (3, 4)
settings = {"width": 5, "height": 6}

# * spreads a sequence into positional arguments.
print("area(*dimensions) =", area(*dimensions))

# ** spreads a mapping into keyword arguments.
print("area(**settings)  =", area(**settings))

# Without the star, the whole container arrives as ONE argument.
try:
    area(dimensions)
except TypeError as exc:
    print()
    print("area(dimensions) without the star:", exc)

test("* spreads a tuple into positions", area(*dimensions), 12)
test("** spreads a dict into keywords", area(**settings), 30)
test("the two are equivalent to writing them out", area(*dimensions), area(3, 4))

What it printed, when the build ran it

area(*dimensions) = 12
area(**settings)  = 30

area(dimensions) without the star: area() missing 1 required positional argument: 'height'
ok   * spreads a tuple into positions
ok   ** spreads a dict into keywords
ok   the two are equivalent to writing them out

The symmetry, stated plainly. In a def, * and ** mean "gather the rest into this name". At a call they mean "unpack this into separate arguments". Same symbols, opposite directions — and that is the whole feature.

Passing arguments straight through

The most common real use of both together is a wrapper that accepts anything and forwards it unchanged. This is exactly the shape every decorator has, which lesson 36 builds on.

Forwarding anything, unchanged

ran at build time
calls = []

def audited(func):
    def wrapper(*args, **kwargs):
        calls.append((func.__name__, args, kwargs))
        return func(*args, **kwargs)
    return wrapper

def area(width, height=1):
    return width * height

tracked = audited(area)

print("tracked(3, 4)        =", tracked(3, 4))
print("tracked(5, height=2) =", tracked(5, height=2))
print()
print("what was recorded:")
for entry in calls:
    print("  ", entry)

test("the wrapper forwards positional arguments", tracked(3, 4), 12)
test("and keyword arguments", tracked(5, height=2), 10)
test("while seeing every call", len(calls), 4)

What it printed, when the build ran it

tracked(3, 4)        = 12
tracked(5, height=2) = 10

what was recorded:
   ('area', (3, 4), {})
   ('area', (5,), {'height': 2})
ok   the wrapper forwards positional arguments
ok   and keyword arguments
ok   while seeing every call

Keyword-only parameters, which improve ordinary code

A bare * in a signature means "everything after this must be passed by name". This matters because a positional boolean is unreadable at the call site: send(msg, True, False) tells a reader nothing.

A bare * forces the caller to name things

ran at build time
# Anything after the bare * must be named.
def send(message, *, urgent=False, retry=True):
    flags = []
    if urgent:
        flags.append("urgent")
    if retry:
        flags.append("retry")
    return f"{message} [{', '.join(flags) or 'none'}]"

print(send("hello"))
print(send("hello", urgent=True))
print(send("hello", urgent=True, retry=False))

# Positional booleans are now impossible, which is the point.
try:
    send("hello", True)
except TypeError as exc:
    print()
    print("send('hello', True):", exc)

test("keyword arguments work", send("hello", urgent=True), "hello [urgent, retry]")
test("all flags can be set", send("hi", urgent=True, retry=False), "hi [urgent]")
test("defaults apply when omitted", send("hi"), "hi [retry]")

What it printed, when the build ran it

hello [retry]
hello [urgent, retry]
hello [urgent]

send('hello', True): send() takes 1 positional argument but 2 were given
ok   keyword arguments work
ok   all flags can be set
ok   defaults apply when omitted

A rule worth adopting. If a parameter is a boolean or an option rather than the thing being operated on, put it after a *. It costs one character and makes every call site self-documenting — and it means you can reorder or add options later without breaking anyone, because nobody could have been depending on the position.

Positional-only parameters, with /

The mirror image: everything before a / can only be passed positionally. You will meet this in the standard library — len(obj) does not accept len(obj=x) — and it is occasionally useful in your own code to keep a parameter name free to change.

/ for positional-only, * for keyword-only, in one signature

ran at build time
def clamp(value, low, high, /, *, inclusive=True):
    limited = max(low, min(value, high))
    return limited if inclusive else limited - 1

print("clamp(5, 0, 10)                  =", clamp(5, 0, 10))
print("clamp(50, 0, 10)                 =", clamp(50, 0, 10))
print("clamp(50, 0, 10, inclusive=False) =", clamp(50, 0, 10, inclusive=False))

errors = []
for call in ["clamp(value=5, low=0, high=10)", "clamp(5, 0, 10, False)"]:
    try:
        eval(call)
    except TypeError as exc:
        errors.append(f"{call} -> {exc}")

print()
for e in errors:
    print("  ", e)

# The same restriction exists on built-ins.
try:
    len(obj=[1, 2])
except TypeError as exc:
    print()
    print("len(obj=[1,2]):", exc)

test("positional calls work", clamp(50, 0, 10), 10)
test("the keyword-only option works", clamp(50, 0, 10, inclusive=False), 9)
test("both invalid forms were rejected", len(errors), 2)

What it printed, when the build ran it

clamp(5, 0, 10)                  = 5
clamp(50, 0, 10)                 = 10
clamp(50, 0, 10, inclusive=False) = 9

   clamp(value=5, low=0, high=10) -> clamp() got some positional-only arguments passed as keyword arguments: 'value, low, high'
   clamp(5, 0, 10, False) -> clamp() takes 3 positional arguments but 4 were given

len(obj=[1,2]): len() takes no keyword arguments
ok   positional calls work
ok   the keyword-only option works
ok   both invalid forms were rejected

The order everything must appear in

Every kind of parameter, in the only legal order

ran at build time
def everything(pos_only, /, normal, *args, kw_only, **kwargs):
    return {
        "pos_only": pos_only,
        "normal": normal,
        "args": args,
        "kw_only": kw_only,
        "kwargs": kwargs,
    }

result = everything(1, 2, 3, 4, kw_only=5, extra=6)
for key, value in result.items():
    print(f"  {key:9} = {value!r}")

print()
print("the order is fixed: positional-only / normal, *args, keyword-only, **kwargs")

test("each part collected what it should",
     (result["pos_only"], result["normal"], result["args"], result["kw_only"], result["kwargs"]),
     (1, 2, (3, 4), 5, {"extra": 6}))

What it printed, when the build ran it

  pos_only  = 1
  normal    = 2
  args      = (3, 4)
  kw_only   = 5
  kwargs    = {'extra': 6}

the order is fixed: positional-only / normal, *args, keyword-only, **kwargs
ok   each part collected what it should

Note that a parameter after *args is automatically keyword-only — there is no way to reach it positionally, because *args has already swallowed everything.

A parameter after *args must be named

raises TypeError
def f(*args, required):
    return args, required

f(1, 2, 3)

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 4, in <module>
    f(1, 2, 3)
    ~^^^^^^^^^
TypeError: f() missing 1 required keyword-only argument: 'required'

Unpacking in other places

The same two symbols, used for merging and splitting

ran at build time
# Merging containers with the same symbols.
a = [1, 2]
b = [3, 4]
print("merged list: ", [*a, *b])

first = {"x": 1}
second = {"y": 2, "x": 99}
print("merged dict: ", {**first, **second}, "<- later wins")

# Splitting a sequence, which lesson 6 introduced. Same symbol again.
head, *tail = [1, 2, 3]
print("head/tail:   ", head, tail)

# And in a function call, mixing spread with literal arguments.
def total(*numbers):
    return sum(numbers)

print()
print("total(*a, 10, *b) =", total(*a, 10, *b))

test("lists merge with *", [*a, *b], [1, 2, 3, 4])
test("dicts merge with **, later winning", {**first, **second}["x"], 99)
test("spread and literals can be mixed in one call", total(*a, 10, *b), 20)

What it printed, when the build ran it

merged list:  [1, 2, 3, 4]
merged dict:  {'x': 99, 'y': 2} <- later wins
head/tail:    1 [2, 3]

total(*a, 10, *b) = 20
ok   lists merge with *
ok   dicts merge with **, later winning
ok   spread and literals can be mixed in one call

What to take from this lesson

  • In a definition, *args and **kwargs collect the extras as a tuple and a dict. At a call, * and ** spread a collection into arguments.
  • *args, **kwargs together is how a wrapper forwards anything unchanged.
  • Put options and booleans after a bare * so callers must name them.
  • / makes the parameters before it positional-only; anything after *args is keyword-only automatically.
  • The same symbols merge containers and split sequences.

17 Scope, closures and the LEGB rule

When Python meets a name it searches four places in a fixed order: Local, Enclosing, Global, Built-in. That order is the LEGB rule, and knowing it turns two of the most baffling errors in the language into obvious ones.

The four scopes, seen at once

Local, Enclosing, Global, Built-in

ran at build time
shared = "global"

def outer():
    shared = "enclosing"

    def inner():
        shared = "local"
        return shared

    return inner(), shared

inner_saw, outer_saw = outer()
print("inner saw: ", inner_saw)
print("outer saw: ", outer_saw)
print("module saw:", shared)

# With the local removed, the search moves outward one step at a time.
def find_enclosing():
    shared = "enclosing"

    def inner():
        return shared          # no local, so the enclosing one is found
    return inner()

def find_global():
    def inner():
        return shared          # no local or enclosing, so the global one
    return inner()

print()
print("no local   ->", find_enclosing())
print("no local or enclosing ->", find_global())
print("built-in   ->", len("abc"), "(len came from the builtins scope)")

test("the innermost binding wins", inner_saw, "local")
test("and does not affect the enclosing one", outer_saw, "enclosing")
test("nor the global one", shared, "global")
test("with no local, the enclosing scope is used", find_enclosing(), "enclosing")
test("with neither, the global is used", find_global(), "global")

What it printed, when the build ran it

inner saw:  local
outer saw:  enclosing
module saw: global

no local   -> enclosing
no local or enclosing -> global
built-in   -> 3 (len came from the builtins scope)
ok   the innermost binding wins
ok   and does not affect the enclosing one
ok   nor the global one
ok   with no local, the enclosing scope is used
ok   with neither, the global is used

Reading is easy; assigning changes everything

A function can read an outer name without ceremony. But assigning to a name anywhere in a function makes that name local for the whole function — decided when the function is compiled, before a single line of it runs.

Reading an outer name versus assigning to it

ran at build time
count = 10

def just_reading():
    return count * 2              # reads the global, no problem

def assigning():
    count = 99                    # creates a LOCAL, unrelated to the global
    return count

print("reading the global: ", just_reading())
print("assigning locally:  ", assigning())
print("the global is still:", count)

# The evidence: Python records which names are local at compile time.
print()
print("locals of assigning():   ", assigning.__code__.co_varnames)
print("globals just_reading() uses:", just_reading.__code__.co_names)

test("reading reaches the global", just_reading(), 20)
test("assigning creates a separate local", assigning(), 99)
test("leaving the global untouched", count, 10)
test("the compiler already knows 'count' is local in assigning()",
     "count" in assigning.__code__.co_varnames, True)

What it printed, when the build ran it

reading the global:  20
assigning locally:   99
the global is still: 10

locals of assigning():    ('count',)
globals just_reading() uses: ('count',)
ok   reading reaches the global
ok   assigning creates a separate local
ok   leaving the global untouched
ok   the compiler already knows 'count' is local in assigning()

Now the error that follows from it. Because the assignment makes the name local everywhere in the function, reading it before that assignment runs is reading a local that has no value yet — not the global.

The classic: local before assignment

raises UnboundLocalError
count = 10

def bump():
    count = count + 1
    return count

bump()

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 7, in <module>
    bump()
    ~~~~^^
  File "example.py", line 4, in bump
    count = count + 1
            ^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

Read the error literally and it makes sense. "cannot access local variable 'count' where it is not associated with a value" — Python is telling you it decided count is local (because the function assigns to it) and that the local has no value at the moment you read it. The global count = 10 is never consulted. The same applies to +=, which is an assignment.

global and nonlocal, and why to avoid the first

global reaches the module; nonlocal reaches the enclosing function

ran at build time
count = 0

def bump_global():
    global count                  # "assignments here mean the module-level name"
    count += 1
    return count

print("calls:", bump_global(), bump_global(), bump_global())
print("the module-level count is now:", count)

# nonlocal targets the nearest ENCLOSING function scope, not the module.
def make_counter():
    total = 0

    def increment():
        nonlocal total            # not global -- the enclosing 'total'
        total += 1
        return total

    return increment

counter = make_counter()
print()
print("independent counter:", counter(), counter(), counter())

# Two counters do not share state, because each call to make_counter made its own.
other = make_counter()
print("a second counter starts fresh:", other())

test("global lets a function rebind a module-level name", count, 3)
test("nonlocal rebinds the enclosing one", counter(), 4)
test("each closure has its own state", other(), 2)

What it printed, when the build ran it

calls: 1 2 3
the module-level count is now: 3

independent counter: 1 2 3
a second counter starts fresh: 1
ok   global lets a function rebind a module-level name
ok   nonlocal rebinds the enclosing one
ok   each closure has its own state

Prefer returning a value to using global. A function that rebinds module state can be called in the wrong order, cannot be tested in isolation, and makes every caller depend on invisible history. nonlocal is more defensible because the state is genuinely private to the closure — but a class (lesson 28) is usually clearer once there is more than one piece of it.

Mutation is not assignment, which is the exception to all of this

You need global to rebind a name. You do not need it to mutate the object the name already points at — because mutating is not assigning. This trips people in both directions.

Mutating needs no declaration; rebinding does

ran at build time
items = []
config = {"debug": False}

def add_item(x):
    items.append(x)               # mutating: no global needed
    return len(items)

def replace_items():
    global items
    items = ["replaced"]          # rebinding: global IS needed

def set_debug(value):
    config["debug"] = value       # mutating a dict: no global needed

add_item("a")
add_item("b")
set_debug(True)
print("after mutation:", items, config)

replace_items()
print("after rebinding:", items)

test("mutation reaches the module-level list without global", len(items), 1)
test("mutating a dict works the same way", config["debug"], True)

What it printed, when the build ran it

after mutation: ['a', 'b'] {'debug': True}
after rebinding: ['replaced']
ok   mutation reaches the module-level list without global
ok   mutating a dict works the same way

Closures capture names, not values

A closure remembers the variable from its enclosing scope, not a snapshot of what that variable held when the function was defined. That is usually what you want, and in a loop it is the cause of the most confusing bug in this lesson.

Late binding in a loop, and three ways to fix it

ran at build time
# Three functions, all closing over the same 'i'.
funcs = [lambda: i for i in range(3)]
print("expected [0, 1, 2], got:", [f() for f in funcs])
print("because all three look up the same 'i', which ended at 2")

# Fix one: a default argument, which captures the VALUE at definition time.
by_default = [lambda i=i: i for i in range(3)]
print()
print("with a default argument:", [f() for f in by_default])

# Fix two: a factory, giving each closure its own scope.
def make(value):
    return lambda: value

by_factory = [make(i) for i in range(3)]
print("with a factory:         ", [f() for f in by_factory])

# Fix three: functools.partial, which binds the argument now.
from functools import partial
by_partial = [partial(lambda v: v, i) for i in range(3)]
print("with functools.partial: ", [f() for f in by_partial])

test("the naive version captures the name, so all agree", [f() for f in funcs], [2, 2, 2])
test("a default argument captures the value", [f() for f in by_default], [0, 1, 2])
test("a factory gives each its own scope", [f() for f in by_factory], [0, 1, 2])
test("partial binds the argument immediately", [f() for f in by_partial], [0, 1, 2])

What it printed, when the build ran it

expected [0, 1, 2], got: [2, 2, 2]
because all three look up the same 'i', which ended at 2

with a default argument: [0, 1, 2]
with a factory:          [0, 1, 2]
with functools.partial:  [0, 1, 2]
ok   the naive version captures the name, so all agree
ok   a default argument captures the value
ok   a factory gives each its own scope
ok   partial binds the argument immediately

You can inspect what a closure actually captured, which makes the mechanism concrete rather than mysterious.

What a closure physically holds

ran at build time
def make_multiplier(factor):
    def multiply(x):
        return x * factor
    return multiply

triple = make_multiplier(3)
print("triple(5) =", triple(5))

# The captured variables are stored on the function.
print()
print("free variables:", triple.__code__.co_freevars)
print("their cells:   ", triple.__closure__)
print("the value inside:", triple.__closure__[0].cell_contents)

test("the closure works", triple(5), 15)
test("it records which names it closed over", triple.__code__.co_freevars, ("factor",))
test("and the captured value is retrievable", triple.__closure__[0].cell_contents, 3)

What it printed, when the build ran it

triple(5) = 15

free variables: ('factor',)
their cells:    (<cell at 0x10ab1f340: int object at 0x10b3067f0>,)
the value inside: 3
ok   the closure works
ok   it records which names it closed over
ok   and the captured value is retrievable

Comprehensions have their own scope

From lesson 12: a comprehension's loop variable does not leak. The reason is that a comprehension runs in its own implicit function scope — which also means it can read enclosing names but assigning inside it stays inside it.

Comprehension scope, and the walrus exception

ran at build time
multiplier = 10

# The comprehension can read an enclosing name.
scaled = [n * multiplier for n in range(4)]
print("scaled:", scaled)

# Its loop variable does not escape.
print("is 'n' defined out here?", "n" in dir())

# A plain for loop's variable does.
for m in range(4):
    pass
print("is 'm' defined out here?", "m" in dir())

# The walrus operator is the deliberate exception: it assigns to the ENCLOSING scope.
values = [1, 2, 3, 4, 5]
filtered = [squared for n in values if (squared := n * n) > 4]
print()
print("filtered:", filtered)
print("'squared' leaked on purpose, holding:", squared)

test("the comprehension reads the enclosing name", scaled, [0, 10, 20, 30])
test("its loop variable does not leak", "n" in dir(), False)
test("a for loop's variable does", "m" in dir(), True)
test("the walrus assigns outward deliberately", squared, 25)

What it printed, when the build ran it

scaled: [0, 10, 20, 30]
is 'n' defined out here? False
is 'm' defined out here? True

filtered: [9, 16, 25]
'squared' leaked on purpose, holding: 25
ok   the comprehension reads the enclosing name
ok   its loop variable does not leak
ok   a for loop's variable does
ok   the walrus assigns outward deliberately

What to take from this lesson

  • Names are looked up Local, Enclosing, Global, Built-in, in that order.
  • Assigning to a name anywhere in a function makes it local for the whole function. Reading it before the assignment is UnboundLocalError.
  • global rebinds a module-level name; nonlocal rebinds an enclosing function's. Prefer returning values to either.
  • Mutating an object needs no declaration; rebinding a name does.
  • Closures capture the variable, not its value — which is why a loop produces identical closures unless you bind the value with a default, a factory, or partial.

18 lambda, map and filter, and when to use none of them

A lambda is a function written as an expression. It exists because sometimes you need a function in a place where a def would be awkward — as an argument, mostly. Outside that, a named function is almost always better, and this lesson is as much about when not to use these tools as how.

What a lambda is

A lambda is an ordinary function with no name

ran at build time
# These two are the same function, written two ways.
def double_def(x):
    return x * 2

double_lambda = lambda x: x * 2

print("def version:   ", double_def(5))
print("lambda version:", double_lambda(5))

# A lambda can take any arguments a def can.
add = lambda a, b=10, *rest: a + b + sum(rest)
print()
print("add(1)        =", add(1))
print("add(1, 2)     =", add(1, 2))
print("add(1, 2, 3, 4) =", add(1, 2, 3, 4))

# The difference that shows up in a traceback: a lambda has no useful name.
print()
print("def name:   ", double_def.__name__)
print("lambda name:", double_lambda.__name__)

test("both compute the same thing", double_def(5), double_lambda(5))
test("a lambda takes defaults and *args too", add(1, 2, 3, 4), 10)
test("but it has no meaningful name", double_lambda.__name__, "<lambda>")

What it printed, when the build ran it

def version:    10
lambda version: 10

add(1)        = 11
add(1, 2)     = 3
add(1, 2, 3, 4) = 10

def name:    double_def
lambda name: <lambda>
ok   both compute the same thing
ok   a lambda takes defaults and *args too
ok   but it has no meaningful name

Never assign a lambda to a name. double = lambda x: x * 2 is strictly worse than def double(x): return x * 2 — same length, but the def version gets a real name in every traceback, can hold a docstring, and can grow to two lines later without being rewritten. PEP 8 says this explicitly, and the reason is the <lambda> above: when it fails in production you will want to know which one failed.

A lambda can only be one expression

This is the real limitation. No statements — no if block, no loop, no assignment, no return. A conditional expression is allowed because it is an expression, and that is where lambdas start becoming unreadable.

A lambda body cannot contain a statement

raises SyntaxError
classify = lambda n:
    if n > 0:
        return "positive"
    return "negative"

The traceback it really produced, when the build ran it

  File "example.py", line 1
    classify = lambda n:
                        ^
SyntaxError: invalid syntax

One expression only, and the point where it stops being readable

ran at build time
# A conditional expression is legal, and fine at this size.
sign = lambda n: "positive" if n > 0 else "negative"
print("sign(5) :", sign(5))
print("sign(-5):", sign(-5))

# Nested, it stops being readable -- and this is where people stop and shouldn't.
messy = lambda n: "positive" if n > 0 else ("zero" if n == 0 else "negative")
print()
print("the nested version works:", [messy(n) for n in (5, 0, -5)])

# The same logic as a def, which anyone can read.
def describe(n):
    if n > 0:
        return "positive"
    if n == 0:
        return "zero"
    return "negative"

print("the def version agrees: ", [describe(n) for n in (5, 0, -5)])

test("a conditional expression is allowed in a lambda", sign(5), "positive")
test("nesting works but should not be written", [messy(n) for n in (5, 0, -5)],
     ["positive", "zero", "negative"])
test("the def version is equivalent", [describe(n) for n in (5, 0, -5)],
     [messy(n) for n in (5, 0, -5)])

What it printed, when the build ran it

sign(5) : positive
sign(-5): negative

the nested version works: ['positive', 'zero', 'negative']
the def version agrees:  ['positive', 'zero', 'negative']
ok   a conditional expression is allowed in a lambda
ok   nesting works but should not be written
ok   the def version is equivalent

Where lambda genuinely belongs: key=

Sorting, max, min and groupby all take a function that extracts what to compare. Writing that inline is exactly the case lambda was added for.

key= is what lambda is for

ran at build time
people = [
    {"name": "Ada", "age": 36},
    {"name": "Alan", "age": 41},
    {"name": "Grace", "age": 36},
]

by_age = sorted(people, key=lambda p: p["age"])
print("by age:      ", [p["name"] for p in by_age])

oldest = max(people, key=lambda p: p["age"])
print("oldest:      ", oldest["name"])

# Sorting by two things: return a tuple, and it compares left to right.
by_age_then_name = sorted(people, key=lambda p: (p["age"], p["name"]))
print("age, then name:", [p["name"] for p in by_age_then_name])

# Descending on one field, ascending on another: negate the numeric one.
mixed = sorted(people, key=lambda p: (-p["age"], p["name"]))
print("age desc, name asc:", [p["name"] for p in mixed])

test("key= extracts what to compare", [p["name"] for p in by_age][0], "Ada")
test("max takes the same key function", oldest["name"], "Alan")
test("a tuple key sorts by several fields", [p["name"] for p in by_age_then_name],
     ["Ada", "Grace", "Alan"])
test("negating reverses one field only", [p["name"] for p in mixed], ["Alan", "Ada", "Grace"])

What it printed, when the build ran it

by age:       ['Ada', 'Grace', 'Alan']
oldest:       Alan
age, then name: ['Ada', 'Grace', 'Alan']
age desc, name asc: ['Alan', 'Ada', 'Grace']
ok   key= extracts what to compare
ok   max takes the same key function
ok   a tuple key sorts by several fields
ok   negating reverses one field only

For the common cases — pulling out an item or an attribute — the standard library has named functions that are faster and say what they mean.

itemgetter and attrgetter, which name what they do

ran at build time
from operator import itemgetter, attrgetter
from collections import namedtuple

people = [
    {"name": "Ada", "age": 36},
    {"name": "Alan", "age": 41},
]

# itemgetter replaces lambda p: p["age"]
print("with a lambda:    ", [p["name"] for p in sorted(people, key=lambda p: p["age"])])
print("with itemgetter:  ", [p["name"] for p in sorted(people, key=itemgetter("age"))])

# It can take several keys at once, returning a tuple.
print("two keys at once: ", itemgetter("name", "age")(people[0]))

# attrgetter does the same for attributes.
Person = namedtuple("Person", "name age")
records = [Person("Ada", 36), Person("Alan", 41)]
print()
print("with attrgetter:  ", [p.name for p in sorted(records, key=attrgetter("age"))])

test("itemgetter matches the lambda", sorted(people, key=itemgetter("age"))[0]["name"], "Ada")
test("it can extract several keys", itemgetter("name", "age")(people[0]), ("Ada", 36))
test("attrgetter does the same for attributes",
     [p.name for p in sorted(records, key=attrgetter("age"))], ["Ada", "Alan"])

What it printed, when the build ran it

with a lambda:     ['Ada', 'Alan']
with itemgetter:   ['Ada', 'Alan']
two keys at once:  ('Ada', 36)

with attrgetter:   ['Ada', 'Alan']
ok   itemgetter matches the lambda
ok   it can extract several keys
ok   attrgetter does the same for attributes

map and filter, and the comprehension that replaces them

The same four operations, twice

ran at build time
numbers = [1, 2, 3, 4, 5]

# map applies a function to every item; filter keeps the ones that pass a test.
doubled_map = list(map(lambda n: n * 2, numbers))
evens_filter = list(filter(lambda n: n % 2 == 0, numbers))

# The comprehension versions, which most Python programmers find clearer.
doubled_comp = [n * 2 for n in numbers]
evens_comp = [n for n in numbers if n % 2 == 0]

print("map:        ", doubled_map)
print("comprehension:", doubled_comp)
print()
print("filter:     ", evens_filter)
print("comprehension:", evens_comp)

# Combining both is where map/filter gets hard to read...
combined_mf = list(map(lambda n: n * 2, filter(lambda n: n % 2 == 0, numbers)))
# ...and the comprehension stays flat.
combined_comp = [n * 2 for n in numbers if n % 2 == 0]

print()
print("map(filter(...)):", combined_mf, " <- read it right to left")
print("comprehension:  ", combined_comp, " <- read it left to right")

test("map and the comprehension agree", doubled_map, doubled_comp)
test("filter and the comprehension agree", evens_filter, evens_comp)
test("and so do the combined forms", combined_mf, combined_comp)

What it printed, when the build ran it

map:         [2, 4, 6, 8, 10]
comprehension: [2, 4, 6, 8, 10]

filter:      [2, 4]
comprehension: [2, 4]

map(filter(...)): [4, 8]  <- read it right to left
comprehension:   [4, 8]  <- read it left to right
ok   map and the comprehension agree
ok   filter and the comprehension agree
ok   and so do the combined forms

When map is the better choice. When the function already exists and has a name. map(str.strip, lines) is cleaner than [line.strip() for line in lines], and map(int, values) is cleaner than [int(v) for v in values] — there is no lambda and no repeated loop variable. The moment you write map(lambda ...), a comprehension is clearer.

map with a named function, laziness, and two iterables

ran at build time
lines = ["  ada  ", " alan", "grace  "]
values = ["1", "2", "3"]

# No lambda needed: the function already has a name.
print("map with an existing function:", list(map(str.strip, lines)))
print("the comprehension equivalent: ", [line.strip() for line in lines])
print()
print("map(int, ...):", list(map(int, values)))

# map is lazy, which matters on large inputs.
import sys
lazy = map(int, range(100_000))
eager = [int(n) for n in range(100_000)]
print()
print("map object:  ", f"{sys.getsizeof(lazy):>7,} bytes")
print("list result: ", f"{sys.getsizeof(eager):>7,} bytes")

# map over two iterables at once, which a comprehension needs zip for.
print()
print("map over two:", list(map(lambda a, b: a + b, [1, 2], [10, 20])))

test("map with a named function is tidy", list(map(str.strip, lines)), ["ada", "alan", "grace"])
test("map is lazy, so it holds nothing", sys.getsizeof(lazy) < sys.getsizeof(eager), True)
test("map can walk two iterables", list(map(lambda a, b: a + b, [1, 2], [10, 20])), [11, 22])

What it printed, when the build ran it

map with an existing function: ['ada', 'alan', 'grace']
the comprehension equivalent:  ['ada', 'alan', 'grace']

map(int, ...): [1, 2, 3]

map object:        56 bytes
list result:  800,984 bytes

map over two: [11, 22]
ok   map with a named function is tidy
ok   map is lazy, so it holds nothing
ok   map can walk two iterables

Lambdas capture names, exactly like any other closure

The late-binding behaviour from lesson 17 is not a lambda quirk — it applies to every function — but it is where people meet it, because lambdas are what end up in loops.

The loop trap, in the place you will actually meet it

ran at build time
# The same trap as lesson 17, since a lambda is just a function.
multipliers = [lambda x: x * i for i in range(1, 4)]
print("all use the final i:", [f(10) for f in multipliers])

# Bind the value with a default argument.
fixed = [lambda x, i=i: x * i for i in range(1, 4)]
print("bound per lambda:   ", [f(10) for f in fixed])

test("late binding affects lambdas too", [f(10) for f in multipliers], [30, 30, 30])
test("a default argument fixes it", [f(10) for f in fixed], [10, 20, 30])

What it printed, when the build ran it

all use the final i: [30, 30, 30]
bound per lambda:    [10, 20, 30]
ok   late binding affects lambdas too
ok   a default argument fixes it

What to take from this lesson

  • A lambda is a one-expression function with no name. It cannot contain statements.
  • Never assign a lambda to a name — use def, and get a real name in tracebacks.
  • Use a lambda as a key= argument, and prefer itemgetter or attrgetter for the common extractions.
  • Prefer a comprehension to map(lambda ...) or filter(lambda ...); prefer map when the function already has a name.
  • Lambdas close over names, so the loop trap from lesson 17 applies to them too.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.