Functions in Depth

Updated

September 8, 2026

Functions in Depth

A function is a named piece of work with a clear input and a clear output. The boring default is a short def, an explicit return, and arguments you can read at the call site. Fancy packing (*args, **kwargs) is for the edges, not for every helper.

Mental model

def binds a name to a function object. Parameters are the names in the def line. Arguments are the values you pass at the call. If you omit return, Python returns None.

Several values come back as a tuple. The caller unpacks them, or keeps the tuple.

A default fills in a parameter you skip. A keyword-only parameter (after a bare *) must be passed by name, so tax=0.1 cannot be confused with another number. *args gathers extra positional arguments into a tuple. **kwargs gathers extra keywords into a dict. A leading * or ** at the call site unpacks a sequence or a dict into arguments.

Worked examples

Case 1: def and return

Save as ticket_label.py. The function takes a ticket dict and returns a string. print happens in main, not inside label.

# ticket_label.py
def label(ticket):
    return f"ticket {ticket['id']} → table {ticket['table']}"


def main():
    t = {"id": 7, "table": 12}
    text = label(t)
    print(text)
    print(label.__name__)


if __name__ == "__main__":
    main()

Run:

uv run python ticket_label.py

Output:

ticket 7 → table 12
label

The function object has a __name__. You will need that later for decorators.

Case 2: Several values as a tuple

Save as split_shift.py. A shift string "09:00-17:00" splits into start and end. Returning two strings is returning one tuple.

# split_shift.py
def split_shift(text):
    start, end = text.split("-", 1)
    return start, end


def main():
    pair = split_shift("09:00-17:00")
    print(type(pair).__name__, pair)
    start, end = split_shift("09:00-17:00")
    print(start)
    print(end)


if __name__ == "__main__":
    main()

Run:

uv run python split_shift.py

Output:

tuple ('09:00', '17:00')
09:00
17:00

Name the two results at the call site. Do not invent a one-off class for two strings.

Case 3: Defaults

Save as add_tax.py. Most orders use the desk rate. A catering order can override it.

# add_tax.py
def with_tax(cents, rate=0.1):
    return round(cents * (1 + rate))


def main():
    print(with_tax(400))
    print(with_tax(400, 0.0))
    print(with_tax(400, rate=0.2))


if __name__ == "__main__":
    main()

Run:

uv run python add_tax.py

Output:

440
400
480

Defaults are evaluated once, when def runs, not on every call. That fact is the trap at the end of this chapter.

Case 4: Keyword-only arguments

Save as charge.py. After the *, tax and tip must be passed by name. A second positional number is a TypeError, not a silent mix-up of tax and tip.

# charge.py
def charge(cents, *, tax=0.1, tip=0):
    return round(cents * (1 + tax) + tip)


def main():
    print(charge(400))
    print(charge(400, tax=0.2, tip=50))
    try:
        charge(400, 0.2)
    except TypeError as e:
        print(type(e).__name__ + ":", e)


if __name__ == "__main__":
    main()

Run:

uv run python charge.py

Output:

440
530
TypeError: charge() takes 1 positional argument but 2 were given

Use keyword-only parameters when two numbers would be easy to swap.

Case 5: *args, **kwargs, and unpacking

Save as announce.py. Extra words land in parts. Extra flags land in flags. At the call site, * unpacks a tuple and ** unpacks a dict.

# announce.py
def announce(*parts, **flags):
    text = " ".join(str(p) for p in parts)
    if flags.get("shout"):
        text = text.upper()
    return text


def main():
    row = ("ticket", 7, "table", 12)
    print(announce(*row))
    opts = {"shout": True}
    print(announce("desk", "open", **opts))
    print(announce("quiet", "please"))


if __name__ == "__main__":
    main()

Run:

uv run python announce.py

Output:

ticket 7 table 12
DESK OPEN
quiet please

Reach for *args / **kwargs when you are forwarding arguments to another function. Do not use them to avoid naming the three parameters you actually have.

The trap

A default list is one list, shared by every call that omits bag. The second add_item("tea") still sees the latte.

Save as shared_bag.py:

# shared_bag.py
def add_item(item, bag=[]):
    bag.append(item)
    return bag


def add_item_ok(item, bag=None):
    if bag is None:
        bag = []
    bag.append(item)
    return bag


def main():
    a = add_item("latte")
    b = add_item("tea")
    print("shared:", a, b, a is b)
    c = add_item_ok("latte")
    d = add_item_ok("tea")
    print("fresh:", c, d, c is d)


if __name__ == "__main__":
    main()

Run:

uv run python shared_bag.py

Output:

shared: ['latte', 'tea'] ['latte', 'tea'] True
fresh: ['latte'] ['tea'] False

The boring default for a mutable default is None, then a new list (or dict, or set) inside the function.

The boring rule

  • Return a value. Leave print to the caller unless the function is the printer.
  • Several results: return a tuple and unpack it.
  • Defaults for immutable values (int, str, None, False) are fine. Mutable defaults are not.
  • Make easy-to-swap numbers keyword-only.
  • Name parameters. Use *args / **kwargs to forward, not to hide.
  • Unpack with * and ** at the call site when you already have a sequence or a dict.

Try this

  1. In ticket_label.py, return None on purpose (no return) and print the result. Then put the return back.
  2. In split_shift.py, add a third value: the length of the shift in hours, as a string like "8h". Unpack three names.
  3. In charge.py, add keyword-only service=0. Show that charge(400, 1) still fails and charge(400, service=1) works.
  4. In shared_bag.py, pass an explicit list into add_item_ok twice and confirm it does grow — that is the caller’s list, not a hidden default.