In the previous lesson, our functions received data in the simplest possible way: one value per parameter, matched by position. Enough to get started, but too little for Papyrus: the final price calculation depends on whether the customer is a member or not, and we don't want two nearly identical functions (regular_price() and member_price()) when the logic is the same with one twist. Python offers an extraordinarily flexible argument system: positional and keyword arguments, default values, variable counts with *args and **kwargs, and unpacking at the call. In this lesson we cover it all, build the bookshop's star function — final_price(base, member=False, vat=BOOK_VAT) — and settle the debt we've been carrying since module 2: truly understanding how zip() works.

Contents

  1. Positional arguments and keyword arguments
  2. Default values: final_price() for members and non-members
  3. The mutable default value trap
  4. *args: a variable number of positionals
  5. **kwargs: a variable number of keyword arguments
  6. Parameter order in the definition
  7. Unpacking with * and ** at the call
  8. Returning several values (tuples, a brief introduction)
  9. Debt settled: zip() in depth

Positional arguments and keyword arguments

Until now we've matched arguments to parameters by position: first with first, second with second. Python also lets you match them by name (keyword arguments):

def print_label(title, price):
    print(f"{title:<12} {price:>6.2f} EUR")

print_label("Hamlet", 9.95)                 # positional
print_label(title="Hamlet", price=9.95)     # keyword
print_label(price=9.95, title="Hamlet")     # keyword: order doesn't matter
print_label("Hamlet", price=9.95)           # mixed: positionals first
print_label(price=9.95, "Hamlet")           # SyntaxError: positional after keyword

Rules and judgement:

  • In a mixed call, positionals always go before the keyword arguments.
  • Keyword arguments make the call self-explanatory: final_price(12.50, True) forces you to look at the definition to know what that True is; final_price(12.50, member=True) reads by itself. You already used this idea in module 2 with enumerate(catalog, start=1) and in module 1 with print(..., sep=", ", end=""): start, sep and end are keyword arguments.
  • Rule of thumb: pass the obvious, mandatory data by position; pass options and flags by name.

Default values

A parameter can declare a value that is used if the call doesn't provide one. It's the perfect mechanism for Papyrus's pricing function:

BOOK_VAT = 0.04
MEMBER_DISCOUNT = 0.05

def final_price(base, member=False, vat=BOOK_VAT):
    """Return the final price: member discount (if applicable) and then VAT."""
    if member:
        base = base * (1 - MEMBER_DISCOUNT)
    return round(base * (1 + vat), 2)

print(final_price(12.50))               # 13.0   -> regular customer
print(final_price(12.50, member=True))  # 12.35  -> Luis, a member
print(final_price(12.50, member=True, vat=0.21))  # 14.37 -> simulating another VAT rate

Breakdown:

  • base has no default value: it's mandatory. Forgetting it produces TypeError: final_price() missing 1 required positional argument: 'base'.
  • member=False and vat=BOOK_VAT are optional: the short call final_price(12.50) covers the most frequent case (non-member customer, book VAT) and the special cases are requested explicitly.
  • The order of operations follows the shop's convention from module 1: first the discount, then the VAT. The catalog's member prices come out spot on: 12.35, 9.83 and 15.71 EUR for The Odyssey, Hamlet and Don Quixote.

A subtlety that surprises people: default values are evaluated only once, when Python reads the def. vat=BOOK_VAT takes whatever value BOOK_VAT holds at that moment (0.04) and freezes it; if you later reassigned the constant, the default wouldn't change. With constants and immutables this is harmless... but it leads straight to Python's most famous trap.

The mutable default value trap

Because the default is evaluated only once, using a mutable object (a list, for example) as a default value means that all the calls share the same object:

def record_order(title, orders=[]):     # DANGER!
    orders.append(title)
    return orders

print(record_order("Faust"))      # ['Faust']
print(record_order("Hamlet"))     # ['Faust', 'Hamlet']  <- where did that Faust come from?!

The second order "inherits" the first because the default list is the same in both calls: it was created when the def was read and it's still there, accumulating. The correct idiom uses None as a sentinel:

def record_order(title, orders=None):
    """Add the title to the list of orders (or create a new list)."""
    if orders is None:
        orders = []                 # a FRESH list on every call
    orders.append(title)
    return orders

print(record_order("Faust"))      # ['Faust']
print(record_order("Hamlet"))     # ['Hamlet']  <- correct
Default value Safe? Examples
Immutable Yes False, 0, 0.04, "en", None
Mutable No — shared across calls [], {} (use the None pattern)

*args: a variable number of positionals

Sometimes you don't know how many values will arrive. A parameter with a leading * collects all the leftover positional arguments:

def purchase_total(*amounts):
    """Return the sum of all the amounts received."""
    total = 0.0
    for amount in amounts:
        total += amount
    return round(total, 2)

print(purchase_total(12.35))               # 12.35 -> Luis takes one book
print(purchase_total(12.35, 9.83, 15.71))  # 37.89 -> he takes three
print(purchase_total())                    # 0.0   -> just browsing today
  • Inside the function, amounts is a tuple with zero or more values, and you traverse it with for like any sequence.
  • The name args is pure convention (here amounts is more expressive); what matters is the *.
  • You already knew this without realizing it: print("a", "b", "c") accepts any number of arguments thanks to this mechanism.

**kwargs: a variable number of keyword arguments

The double asterisk collects the leftover keyword arguments into a dictionary (name-value pairs; dictionaries are studied in depth in module 4, here you only need the idea):

def book_card(title, **details):
    """Print a card with the title and any extra details."""
    print(f"Card for '{title}'")
    for field, value in details.items():
        print(f"  {field}: {value}")

book_card("Faust", author="Goethe", year=1808, stock=0)
Card for 'Faust'
  author: Goethe
  year: 1808
  stock: 0

Every book at Papyrus can carry different details (publisher, edition, language...) without changing the function's signature. details.items() walks the name-value pairs; again, the name kwargs (keyword arguments) is just convention.

Parameter order in the definition

When you combine several categories, the order in the def is fixed:

def example(required, with_default=1, *args, **kwargs):
    ...
Position Category Example
1st Required positionals base
2nd With a default value member=False, vat=BOOK_VAT
3rd *args *amounts
4th **kwargs **details

Breaking the order (def f(member=False, base)) is SyntaxError: parameter without a default follows parameter with a default. In practice, most of your functions will use only the first two categories — *args/**kwargs are for genuinely variable cases, not a shortcut to avoid thinking about the signature.

Unpacking with * and ** at the call

The asterisks also work in the opposite direction: they distribute a sequence or a dictionary across the parameters of the call:

faust_data = ["Faust", 21.00]
print_label(*faust_data)        # equivalent to print_label("Faust", 21.00)

options = {"member": True, "vat": 0.04}
print(final_price(21.00, **options))   # equivalent to member=True, vat=0.04 -> 20.75
  • *sequence in a call unpacks its elements as positional arguments.
  • **dictionary unpacks name-value pairs as keyword arguments (the keys must match parameter names).
  • Mnemonic: in the def, the asterisks pack (many arguments → one variable); at the call, they unpack (one variable → many arguments).

Returning several values (a brief introduction)

return can deliver several values separated by commas; technically it returns a tuple (an immutable sequence we'll cover in depth in module 4), and the usual thing is to unpack it on receipt — the same mechanism as the multiple assignment from module 1:

def price_summary(base, member=False):
    """Return (final price, savings versus the undiscounted price)."""
    final = final_price(base, member=member)
    full_price = final_price(base)
    return final, round(full_price - final, 2)

price, savings = price_summary(12.50, member=True)
print(f"Luis pays {price} EUR and saves {savings} EUR")   # Luis pays 12.35 EUR and saves 0.65 EUR

This is all we need for now; tuples will get their own lesson (04-02).

Debt settled: zip() in depth

In module 2 we used zip() in passing; the time has come to understand it. Papyrus keeps its data in three parallel lists — element i of each one describes the same book:

catalog = ["The Odyssey", "Hamlet", "Don Quixote"]
prices = [12.50, 9.95, 15.90]
stocks = [4, 6, 8]

zip() receives several sequences and produces tuples with the elements paired up by position: first ("The Odyssey", 12.50, 4), then ("Hamlet", 9.95, 6), and so on. It works like the zipper on a jacket: it joins one tooth from each side at every step.

for title, price, stock in zip(catalog, prices, stocks):
    print(f"{title:<12} {price:>6.2f} EUR  stock {stock}")

Compare it with the index pattern we used until now:

With indexes With zip()
Code for i, title in enumerate(catalog): ... prices[i] ... stocks[i] for title, price, stock in zip(...)
Readability So-so: prices[i] forces you to trace i High: each variable has its own name
Gives you the index? Yes No (combine it with enumerate if you need it)
Lists of different lengths? IndexError if the one you index into is longer Stops at the shortest, silently

Important details:

  • zip() is lazy: it doesn't build a list of tuples in memory, it hands them out as the for asks for them. If you want to see them all at once: list(zip(catalog, prices))[('The Odyssey', 12.5), ('Hamlet', 9.95), ('Don Quixote', 15.9)]. Practical consequence: a zip() that's already been traversed is exhausted; create a new one to traverse it again.
  • Stopping at the shortest sequence is convenient but dangerous: if you add "Faust" to catalog and forget to add its price, the for will simply skip the book without warning. Since Python 3.10, zip(catalog, prices, strict=True) raises an error if the lengths don't match — notice: strict is a keyword argument, like the ones you've just learned.
  • zip() also combines beautifully with this lesson's functions:
def print_member_price_list(titles, bases):
    """Print each title with its member price."""
    for title, base in zip(titles, bases):
        print(f"{title:<12} {final_price(base, member=True):>6.2f} EUR")

print_member_price_list(catalog, prices)
The Odyssey   12.35 EUR
Hamlet         9.83 EUR
Don Quixote   15.71 EUR

These parallel lists are starting to feel fragile (three lists to keep in sync by hand); in module 4 you'll meet structures that gather each book's data in a single place. Until then, zip() is Papyrus's official glue.

Common Mistakes and Tips

  • Positional after keyword in the call (final_price(member=True, 12.50)): SyntaxError. Positions first, then names.
  • Repeating an argument (final_price(12.50, base=12.50)): TypeError: got multiple values for argument 'base'. The value arrived by position and by name at the same time.
  • [] or {} as a default value: the star trap. Use None and create the object inside. Linters (like the ones VS Code suggests) flag it; listen to them.
  • Passing flags by position (final_price(12.50, True)): it works, but in a month nobody will remember what that True was. Write member=True.
  • Confusing * in the def with * in the call: in the definition it packs; in the call it unpacks. When in doubt, ask yourself which side of the parentheses you're on.
  • Overusing *args/**kwargs: an explicit signature documents and validates itself. Save the asterisks for genuinely variadic functions (like purchase_total).
  • Forgetting that zip() stays silent with mismatched lengths: for data that absolutely must be paired, use strict=True (Python ≥ 3.10).
  • Tip: when designing a signature, order parameters from most to least important and give a default to everything that has a "normal" value. The typical call should be the shortest one.

Exercises

Exercise 1: the variadic receipt

Write receipt(customer, *amounts, member=False) that prints a line Receipt for <customer>, then each amount passed through final_price(amount, member=member) (one per line, format :>6.2f), and finally Total: X.XX EUR with the sum of the final prices. Test it with receipt("Luis", 12.50, 9.95, 15.90, member=True) — the total must be 37.89 EUR — and with receipt("Marta", 21.00).

Exercise 2: restocking without surprises

Ana wrote this function to record pending restocks and something is wrong:

def log_restock(title, pending=[]):
    pending.append(title)
    return pending

Run log_restock("Faust") and then log_restock("Hamlet"), observe the problem, explain why it happens and fix it with the None sentinel pattern.

Exercise 3: the full price list with zip()

With catalog, prices and stocks (plus "Faust", 21.00, 0 appended: use catalog + ["Faust"], etc., so as not to modify the originals), print with a single loop over zip() a table title | regular price | member price | status, where the status is out of stock if the stock is 0 and available otherwise. Use final_price() for both prices.

Solutions

Exercise 1:

def receipt(customer, *amounts, member=False):
    """Print the receipt for a purchase with final prices."""
    print(f"Receipt for {customer}")
    total = 0.0
    for amount in amounts:
        final = final_price(amount, member=member)
        total += final
        print(f"{final:>6.2f} EUR")
    print(f"Total: {round(total, 2)} EUR")

receipt("Luis", 12.50, 9.95, 15.90, member=True)   # 12.35 / 9.83 / 15.71, Total: 37.89 EUR
receipt("Marta", 21.00)                            # 21.84, Total: 21.84 EUR

Because member comes after *amounts, it can only be passed by name — Python will never mistake it for one more amount. It's a widely used design trick.

Exercise 2: The second call returns ['Faust', 'Hamlet']: both calls share the same default list, created only once when the def was read. The fix:

def log_restock(title, pending=None):
    """Add a title to the restock list (a new one if none is passed)."""
    if pending is None:
        pending = []
    pending.append(title)
    return pending

Now every call without a list receives a freshly created one, and anyone who already has their own list can pass it: log_restock("Hamlet", pending=my_list).

Exercise 3:

titles = catalog + ["Faust"]
bases = prices + [21.00]
units = stocks + [0]

print(f"{'Title':<12} {'Regular':>8} {'Member':>8}  Status")
for title, base, stock in zip(titles, bases, units):
    status = "out of stock" if stock == 0 else "available"
    print(f"{title:<12} {final_price(base):>6.2f} EUR {final_price(base, member=True):>6.2f} EUR  {status}")

Each variable in the for receives its element paired up by zip(); the ternary from module 2 resolves the status in one line. Output: Faust appears with 21.84 EUR / 20.75 EUR and out of stock.

Conclusion

Papyrus's functions now speak a rich language: positional arguments for the essentials, keywords for the options (member=True), default values for the common case — dodging the mutable trap —, *args/**kwargs for the variable parts and unpacking with */** at the calls. On top of that, zip() is no longer magic: it's the zipper that walks the shop's lists in parallel, and final_price(base, member=False, vat=BOOK_VAT) is now established as the official pricing function. In the next lesson we'll meet an abbreviated way to write tiny, anonymous functions — lambdas — and put them to work where they truly shine: as the criterion for sorted(), min() and max() so we can, at last, sort the Papyrus catalog by price.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved