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
- Positional arguments and keyword arguments
- Default values:
final_price()for members and non-members - The mutable default value trap
*args: a variable number of positionals**kwargs: a variable number of keyword arguments- Parameter order in the definition
- Unpacking with
*and**at the call - Returning several values (tuples, a brief introduction)
- 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 keywordRules 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 thatTrueis;final_price(12.50, member=True)reads by itself. You already used this idea in module 2 withenumerate(catalog, start=1)and in module 1 withprint(..., sep=", ", end=""):start,sepandendare 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 rateBreakdown:
basehas no default value: it's mandatory. Forgetting it producesTypeError: final_price() missing 1 required positional argument: 'base'.member=Falseandvat=BOOK_VATare optional: the short callfinal_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,
amountsis a tuple with zero or more values, and you traverse it withforlike any sequence. - The name
argsis pure convention (hereamountsis 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)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:
| 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*sequencein a call unpacks its elements as positional arguments.**dictionaryunpacks 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 EURThis 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:
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 theforasks 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: azip()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
catalogand forget to add its price, theforwill 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:strictis 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)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. UseNoneand 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 thatTruewas. Writemember=True. - Confusing
*in thedefwith*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 (likepurchase_total). - Forgetting that
zip()stays silent with mismatched lengths: for data that absolutely must be paired, usestrict=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:
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 EURBecause 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 pendingNow 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
