You mentioned something at the end of Week 1 — the sales formatter had three nearly-identical functions. Each one formatted a product slightly differently, and when you found a bug, you fixed it in all three separately. What was the actual design problem there?
The functions differed by one optional thing — whether to show the price, whether to abbreviate the SKU. I didn't know how to make a parameter optional, so I duplicated the function and hardcoded the variation.
Default arguments solve that. They let a single function handle the common case automatically and the specific case when the caller asks for something different. Think about the standard packing procedure at the warehouse loading dock. Unless a customer specifies otherwise: medium box, bubble wrap. That's the default. Special orders say "large box" or "foam inserts" — and only those callers have to say anything.
So the function remembers what to do for the ninety percent of callers who don't care, and the ten percent who do care just specify their override.
Exactly. Here's what that looks like in Python:
def format_product_line(
product: dict,
show_price: bool = True,
show_category: bool = False,
currency: str = "USD",
) -> str:
line = f"{product['name']} ({product['sku']})"
if show_price:
line += f" — ${product['price']:.2f} {currency}"
if show_category:
line += f" [{product['category']}]"
return lineYou can skip show_price and show_category entirely when calling it, and it falls back to the defaults? And if I want the category I just name it — show_category=True — without specifying the ones I don't care about?
That's keyword arguments. When you write show_category=True, Python matches the value by name, not by position. You can skip any defaulted parameter you don't need to override, in any order.
catalog_product = {"name": "Widget-A", "sku": "SKU-1001", "price": 24.99, "category": "Hardware"}
format_product_line(catalog_product) # default: price, no category
format_product_line(catalog_product, show_category=True) # add category, keep price
format_product_line(catalog_product, show_price=False) # no price
format_product_line(catalog_product, currency="EUR") # European exportThree functions down to one. I'm equal parts relieved and annoyed at past-me.
Past-you had the right instinct — three nearly-identical functions felt wrong. Now you have the vocabulary.
There's a rule about ordering, right? You can't put a parameter with a default before one without a default?
Hard rule, enforced at definition time. Parameters without defaults must come before parameters with defaults. Python needs to know which value belongs to which parameter by position. If a defaulted parameter came first, Python couldn't tell whether f(5) was filling the defaulted one or the required one.
# Valid — required params first
def filter_products(products: list[dict], category: str = None, max_price: float = float('inf')): ...
# SyntaxError — default before required
def filter_products(category: str = None, products: list[dict]): ...So products is required — no default — and it goes first. The optional filters trail behind it.
Exactly. Now there is a trap inside defaults that catches experienced developers. What do you think this does?
def log_restock(sku: str, log: list = []) -> list:
log.append(sku)
return log
print(log_restock("SKU-1001"))
print(log_restock("SKU-2042"))I'd expect two separate lists — one with each SKU. But... the default [] is created when the function is defined, not when it's called?
Exactly right. Python creates the default list once at definition time. Every call that doesn't pass its own log shares that same object. The second call appends to the same list the first call used:
print(log_restock("SKU-1001")) # ['SKU-1001']
print(log_restock("SKU-2042")) # ['SKU-1001', 'SKU-2042'] ← same list!That's a silent accumulation bug. The function looks like it creates a fresh log each time, but it's actually sharing one across calls.
The fix: use None as the default and create the list inside the function body:
def log_restock(sku: str, log: list = None) -> list:
if log is None:
log = []
log.append(sku)
return logStrings, numbers, None, and tuples are safe as defaults — they're immutable, so sharing doesn't matter. Lists, dicts, and sets are not safe.
Wait — so None is safe because it's immutable, and if I use None as a sentinel I create the mutable object fresh inside the body every call?
That's the canonical pattern. None is the signal — "the caller didn't provide one, create a fresh one." This shows up everywhere in professional Python. Once you know to look for it, you see it in the standard library constantly.
So for the filter function — category=None is safe because I'm using None to mean "no category filter," not as a mutable default. I'm not appending to it or modifying it.
Right. None as a sentinel is different from a mutable container as a default. The sentinel is safe. The container is not. You just made that distinction correctly without me prompting it.
Okay. Let me think about filter_products. No filter means everything passes. With min_price=0.0 and max_price=float('inf'), every product's price is in that range. With category=None, the category check is skipped. Diane can call it with no optional args and get the full catalog back.
catalog = [...]
filter_products(catalog) # all products
filter_products(catalog, category="Hardware") # only Hardware
filter_products(catalog, max_price=25.0) # under $25
filter_products(catalog, max_price=25.0, category="Safety") # cheap Safety gearThat third call — you skipped min_price entirely and jumped to max_price by name. Python matches max_price=25.0 by the keyword, ignores min_price and leaves it at its default. That's exactly how keyword arguments work with defaults.
Write the function. I know what it needs to do.
Go. And when you're done — tomorrow we hit the case where you don't know how many arguments someone will pass at all. Not optional parameters with known names. A truly variable number of positional arguments, collected into a tuple. That's *args — the * on the receiving side of a function call.
Default arguments give a parameter a value when the caller doesn't supply one. They are written as param=default_value in the function signature.
Keyword arguments allow a caller to pass arguments by name rather than by position: f(x=1, z=3) skips y if y has a default. You can specify only the parameters you need to override.
def describe_product(
name: str,
category: str = "General",
in_stock: bool = True,
) -> str:
status = "in stock" if in_stock else "out of stock"
return f"{name} [{category}] — {status}"
describe_product("Widget-A") # uses both defaults
describe_product("Hard-Hat", category="Safety") # overrides one
describe_product("Clearance", in_stock=False) # overrides the other by nameOrdering rule: parameters without defaults must come before parameters with defaults. Python enforces this at definition time with a SyntaxError.
Pitfall 1: Mutable default arguments. The default value is created once at function definition, not once per call. A default list or dict accumulates state across all calls that use it. Fix: use None as the default and create the mutable object inside the function body when None is detected.
Pitfall 2: float('inf') for "no upper limit." Any finite price satisfies price <= float('inf'), so the upper-bound check effectively disappears when you want no limit. This pattern is cleaner than using None and checking explicitly.
Pitfall 3: Positional call overriding the wrong default. f(products, 10.0) fills min_price even if you meant to set max_price. When a function has multiple defaulted parameters, callers should use keyword syntax to be explicit: f(products, max_price=10.0).
Default arguments interact with Python's function object model: the defaults are stored on the function as func.__defaults__ (positional) and func.__kwdefaults__ (keyword-only). You can inspect them at runtime, which is how some decorator and introspection patterns work. Understanding that defaults live on the function object — not in the call frame — is the key to understanding the mutable default trap.
Sign up to write and run code in this lesson.
You mentioned something at the end of Week 1 — the sales formatter had three nearly-identical functions. Each one formatted a product slightly differently, and when you found a bug, you fixed it in all three separately. What was the actual design problem there?
The functions differed by one optional thing — whether to show the price, whether to abbreviate the SKU. I didn't know how to make a parameter optional, so I duplicated the function and hardcoded the variation.
Default arguments solve that. They let a single function handle the common case automatically and the specific case when the caller asks for something different. Think about the standard packing procedure at the warehouse loading dock. Unless a customer specifies otherwise: medium box, bubble wrap. That's the default. Special orders say "large box" or "foam inserts" — and only those callers have to say anything.
So the function remembers what to do for the ninety percent of callers who don't care, and the ten percent who do care just specify their override.
Exactly. Here's what that looks like in Python:
def format_product_line(
product: dict,
show_price: bool = True,
show_category: bool = False,
currency: str = "USD",
) -> str:
line = f"{product['name']} ({product['sku']})"
if show_price:
line += f" — ${product['price']:.2f} {currency}"
if show_category:
line += f" [{product['category']}]"
return lineYou can skip show_price and show_category entirely when calling it, and it falls back to the defaults? And if I want the category I just name it — show_category=True — without specifying the ones I don't care about?
That's keyword arguments. When you write show_category=True, Python matches the value by name, not by position. You can skip any defaulted parameter you don't need to override, in any order.
catalog_product = {"name": "Widget-A", "sku": "SKU-1001", "price": 24.99, "category": "Hardware"}
format_product_line(catalog_product) # default: price, no category
format_product_line(catalog_product, show_category=True) # add category, keep price
format_product_line(catalog_product, show_price=False) # no price
format_product_line(catalog_product, currency="EUR") # European exportThree functions down to one. I'm equal parts relieved and annoyed at past-me.
Past-you had the right instinct — three nearly-identical functions felt wrong. Now you have the vocabulary.
There's a rule about ordering, right? You can't put a parameter with a default before one without a default?
Hard rule, enforced at definition time. Parameters without defaults must come before parameters with defaults. Python needs to know which value belongs to which parameter by position. If a defaulted parameter came first, Python couldn't tell whether f(5) was filling the defaulted one or the required one.
# Valid — required params first
def filter_products(products: list[dict], category: str = None, max_price: float = float('inf')): ...
# SyntaxError — default before required
def filter_products(category: str = None, products: list[dict]): ...So products is required — no default — and it goes first. The optional filters trail behind it.
Exactly. Now there is a trap inside defaults that catches experienced developers. What do you think this does?
def log_restock(sku: str, log: list = []) -> list:
log.append(sku)
return log
print(log_restock("SKU-1001"))
print(log_restock("SKU-2042"))I'd expect two separate lists — one with each SKU. But... the default [] is created when the function is defined, not when it's called?
Exactly right. Python creates the default list once at definition time. Every call that doesn't pass its own log shares that same object. The second call appends to the same list the first call used:
print(log_restock("SKU-1001")) # ['SKU-1001']
print(log_restock("SKU-2042")) # ['SKU-1001', 'SKU-2042'] ← same list!That's a silent accumulation bug. The function looks like it creates a fresh log each time, but it's actually sharing one across calls.
The fix: use None as the default and create the list inside the function body:
def log_restock(sku: str, log: list = None) -> list:
if log is None:
log = []
log.append(sku)
return logStrings, numbers, None, and tuples are safe as defaults — they're immutable, so sharing doesn't matter. Lists, dicts, and sets are not safe.
Wait — so None is safe because it's immutable, and if I use None as a sentinel I create the mutable object fresh inside the body every call?
That's the canonical pattern. None is the signal — "the caller didn't provide one, create a fresh one." This shows up everywhere in professional Python. Once you know to look for it, you see it in the standard library constantly.
So for the filter function — category=None is safe because I'm using None to mean "no category filter," not as a mutable default. I'm not appending to it or modifying it.
Right. None as a sentinel is different from a mutable container as a default. The sentinel is safe. The container is not. You just made that distinction correctly without me prompting it.
Okay. Let me think about filter_products. No filter means everything passes. With min_price=0.0 and max_price=float('inf'), every product's price is in that range. With category=None, the category check is skipped. Diane can call it with no optional args and get the full catalog back.
catalog = [...]
filter_products(catalog) # all products
filter_products(catalog, category="Hardware") # only Hardware
filter_products(catalog, max_price=25.0) # under $25
filter_products(catalog, max_price=25.0, category="Safety") # cheap Safety gearThat third call — you skipped min_price entirely and jumped to max_price by name. Python matches max_price=25.0 by the keyword, ignores min_price and leaves it at its default. That's exactly how keyword arguments work with defaults.
Write the function. I know what it needs to do.
Go. And when you're done — tomorrow we hit the case where you don't know how many arguments someone will pass at all. Not optional parameters with known names. A truly variable number of positional arguments, collected into a tuple. That's *args — the * on the receiving side of a function call.
Default arguments give a parameter a value when the caller doesn't supply one. They are written as param=default_value in the function signature.
Keyword arguments allow a caller to pass arguments by name rather than by position: f(x=1, z=3) skips y if y has a default. You can specify only the parameters you need to override.
def describe_product(
name: str,
category: str = "General",
in_stock: bool = True,
) -> str:
status = "in stock" if in_stock else "out of stock"
return f"{name} [{category}] — {status}"
describe_product("Widget-A") # uses both defaults
describe_product("Hard-Hat", category="Safety") # overrides one
describe_product("Clearance", in_stock=False) # overrides the other by nameOrdering rule: parameters without defaults must come before parameters with defaults. Python enforces this at definition time with a SyntaxError.
Pitfall 1: Mutable default arguments. The default value is created once at function definition, not once per call. A default list or dict accumulates state across all calls that use it. Fix: use None as the default and create the mutable object inside the function body when None is detected.
Pitfall 2: float('inf') for "no upper limit." Any finite price satisfies price <= float('inf'), so the upper-bound check effectively disappears when you want no limit. This pattern is cleaner than using None and checking explicitly.
Pitfall 3: Positional call overriding the wrong default. f(products, 10.0) fills min_price even if you meant to set max_price. When a function has multiple defaulted parameters, callers should use keyword syntax to be explicit: f(products, max_price=10.0).
Default arguments interact with Python's function object model: the defaults are stored on the function as func.__defaults__ (positional) and func.__kwdefaults__ (keyword-only). You can inspect them at runtime, which is how some decorator and introspection patterns work. Understanding that defaults live on the function object — not in the call frame — is the key to understanding the mutable default trap.