Functions as Values

Updated

September 8, 2026

Functions as Values

A function is a value. You can pass it, store it, and return it. The boring default is a named def you hand to sorted, min, or a small dispatch dict. Keep lambda tiny. Prefer a comprehension over map and filter.

Mental model

A callable is anything you can follow with (...). Function objects are callables. So are methods. sorted does not know about tickets; it only knows how to compare keys. You pass key=some_function and sorted calls that function once per item.

A dispatch table is a dict from a name to a function. Instead of a long if/elif chain that you forget to extend, you look up the function and call it.

A lambda is a one-expression function with no name. If it needs a statement, a name, or more than one expression, use def.

map and filter apply a function to an iterable and return an iterator. A list comprehension does the same job with ordinary for and if. That is the form a teammate will edit at 5 p.m.

Worked examples

Case 1: Pass a function to sorted

Save as sort_tickets.py. by_table is an ordinary function. sorted calls it for each ticket.

# sort_tickets.py
def by_table(ticket):
    return ticket["table"]


def main():
    tickets = [
        {"id": 9, "table": 12},
        {"id": 3, "table": 4},
        {"id": 7, "table": 12},
    ]
    for t in sorted(tickets, key=by_table):
        print(f"ticket {t['id']} table {t['table']}")


if __name__ == "__main__":
    main()

Run:

uv run python sort_tickets.py

Output:

ticket 3 table 4
ticket 9 table 12
ticket 7 table 12

Equal tables keep their original order. sorted is stable.

Case 2: A dict of functions

Save as dispatch.py. The desk has three verbs. Each verb is a function. Unknown verbs raise KeyError, which you turn into a ValueError with a clear message.

# dispatch.py
def open_ticket(n):
    return f"opened table {n}"


def pay_ticket(n):
    return f"paid table {n}"


def close_ticket(n):
    return f"closed table {n}"


HANDLERS = {
    "open": open_ticket,
    "pay": pay_ticket,
    "close": close_ticket,
}


def run(verb, n):
    try:
        fn = HANDLERS[verb]
    except KeyError:
        raise ValueError(f"unknown verb {verb!r}") from None
    return fn(n)


def main():
    print(run("open", 12))
    print(run("pay", 12))
    try:
        run("refund", 12)
    except ValueError as e:
        print(type(e).__name__ + ":", e)


if __name__ == "__main__":
    main()

Run:

uv run python dispatch.py

Output:

opened table 12
paid table 12
ValueError: unknown verb 'refund'

Add a verb by writing a function and one dict line. Do not grow a twelve-branch if.

Case 3: A tiny lambda

Save as tiny_lambda.py. The key is one attribute lookup. A lambda is acceptable here. The next line uses a named function for the same job so you can see both.

# tiny_lambda.py
def by_id(ticket):
    return ticket["id"]


def main():
    tickets = [
        {"id": 9, "table": 12},
        {"id": 3, "table": 4},
    ]
    by_lambda = sorted(tickets, key=lambda t: t["id"])
    by_def = sorted(tickets, key=by_id)
    print([t["id"] for t in by_lambda])
    print([t["id"] for t in by_def])


if __name__ == "__main__":
    main()

Run:

uv run python tiny_lambda.py

Output:

[3, 9]
[3, 9]

If the key grows a second line, delete the lambda and keep the def.

Case 4: map / filter versus a comprehension

Save as open_tables.py. Both styles produce the same list. The comprehension is the one you keep.

# open_tables.py
def main():
    tickets = [
        {"id": 9, "table": 12, "status": "open"},
        {"id": 3, "table": 4, "status": "paid"},
        {"id": 7, "table": 12, "status": "open"},
    ]
    mapped = list(map(lambda t: t["id"], tickets))
    filtered = list(filter(lambda t: t["status"] == "open", tickets))
    ids = [t["id"] for t in tickets]
    open_ids = [t["id"] for t in tickets if t["status"] == "open"]
    print("map:", mapped)
    print("filter ids:", [t["id"] for t in filtered])
    print("comp:", ids)
    print("comp open:", open_ids)


if __name__ == "__main__":
    main()

Run:

uv run python open_tables.py

Output:

map: [9, 3, 7]
filter ids: [9, 7]
comp: [9, 3, 7]
comp open: [9, 7]

map and filter are not wrong. They are just noisier once the function is a lambda. A comprehension already has for and if.

The trap

Assigning a lambda to a name is a def with worse traceback names and no statements. ruff will flag it (E731). Use def.

Save as named_lambda.py:

# named_lambda.py
def main():
    bump = lambda cents: cents + 50
    print(bump(400))
    print(bump.__name__)


if __name__ == "__main__":
    main()

Run:

uv run python named_lambda.py

Output:

450
<lambda>

The fix is a one-line def:

# named_def.py
def bump(cents):
    return cents + 50


def main():
    print(bump(400))
    print(bump.__name__)


if __name__ == "__main__":
    main()

Run:

uv run python named_def.py

Output:

450
bump

Same work. A real name in traces and in help.

The boring rule

  • Pass named functions into sorted, min, max, and your own helpers.
  • A small dict of functions is better than a growing if/elif ladder.
  • lambda is for a single expression you will not reuse. Do not assign it to a name.
  • Prefer [... for ... in ... if ...] over list(map(...)) and list(filter(...)).
  • If the callable needs a body, it needs a def.

Try this

  1. In sort_tickets.py, sort by (table, id) so table 12’s tickets come out 7 then 9. A named function that returns a tuple is fine.
  2. In dispatch.py, add void that returns "voided table {n}" and call it.
  3. In open_tables.py, drop map and filter. Keep only the comprehensions. Print tickets whose table is 12.
  4. Rewrite named_lambda.py so bump also prints nothing extra — just return the value — using def.