Diane walked in with a new requirement this morning. The restock endpoint needs to handle batches — one call, any number of SKUs. Could be one product, could be fifty. What's your first instinct for how to accept a variable number of inputs?
Accept a list parameter. The caller builds the list, passes it in. That works, but the caller has to remember to wrap their products in brackets and it feels awkward for a single-item restock.
There's a cleaner way. Python has syntax for "collect all the positional arguments the caller passes into a single tuple." The * prefix on a parameter name does exactly that. Any positional argument after the required ones flows into it.
That's *args? I've seen it in function signatures but I always assumed it was some advanced feature I didn't need yet.
You need it now. And it's not magic — it's the unpacking operator you've been using all week, working on the receiving side of a function call instead of the assignment side. Each positional argument becomes one element:
def receive_shipment(*product_updates):
print(type(product_updates)) # <class 'tuple'>
for sku, qty in product_updates:
print(f"Receiving {qty} units of {sku}")
receive_shipment(("SKU-1001", 50), ("SKU-2042", 120), ("SKU-3300", 8))
# tuple of 3 tuplesA tuple of tuples. I called it with three (sku, qty) pairs as separate arguments, and Python bundled them all into one tuple called product_updates. So the loop unpacks each inner tuple in each iteration.
That's exactly right. And the reason the outer container is a tuple — not a list — is that *args is read-only by design. You received a snapshot of what the caller passed. Think of the dock worker with a shipping manifest: however many crates arrive, they all get logged. The manifest itself doesn't change after the truck is unloaded.
What about named routing information? Like, the warehouse destination isn't one of the products — it's metadata for the whole batch. Does that go somewhere different?
That's **kwargs — double-star collects keyword arguments into a dict. But here's where I want to show you something more targeted. For batch_restock, warehouse is a known named parameter with a default. We don't need **kwargs for that. We just put it after *product_updates in the signature:
def batch_restock(*product_updates: tuple, warehouse: str = "main") -> list[dict]:
results = []
for sku, quantity in product_updates:
results.append({"sku": sku, "quantity": quantity, "warehouse": warehouse})
return resultswarehouse comes after *product_updates in the signature. How does Python know which positional arguments belong to product_updates and when they stop?
Once Python sees a *args parameter, it collects every remaining positional argument into it. There's nothing left for warehouse to grab positionally. This means any parameter after *product_updates is automatically keyword-only — the caller must pass it by name.
batch_restock(("SKU-1001", 50), ("SKU-2042", 120), warehouse="east") # correct
batch_restock(("SKU-1001", 50), ("SKU-2042", 120), "east") # wrong — "east" goes into product_updates!So *args acts as a wall. Everything before it gets collected into the tuple. Anything after must be named. That means warehouse="east" at the call site isn't optional style — it's required.
And that requirement is actually good design. batch_restock(..., warehouse="east") tells you at a glance what that argument means. batch_restock(..., "east") would be a mystery.
Okay. I'm following the mechanics. But when do I reach for **kwargs instead of just listing named parameters with defaults? They seem to solve similar problems.
Different problems. Named parameters with defaults are for known, bounded options — you know exactly what the caller might want to specify. **kwargs is for open-ended input: when you're writing a pass-through function, a decorator, or a logging utility that needs to accept arbitrary named options it doesn't own. For batch_restock, warehouse is bounded — use a named parameter. If you needed to support arbitrary routing tags (priority, carrier, temperature_zone), that's when **kwargs earns its place.
So the decision is: do I know the names ahead of time? Named params. Am I forwarding options I don't own, or building something extensible? **kwargs.
That's the right frame. Let me show you what an empty batch call does:
batch_restock() # zero positional args
# product_updates = () — empty tuple
# loop runs zero times
# returns []No guard needed. Empty call returns empty list. The zero-argument case handles itself because an empty tuple iterates zero times.
That's the elegance of *args over accepting a list with None as a default — you don't need an if product_updates is None guard. The empty case is just the natural behavior.
One more thing. I've been using *product_updates: tuple with the type annotation — that's saying each element I receive should be a tuple, not that product_updates itself is annotated as tuple?
Correct. The annotation on *name applies to each individual argument, not the collected container. Your editor and type checker will flag it if a caller passes a string instead of a tuple. Python won't enforce it at runtime.
Good. I'd rather the type checker catch that than Diane's inventory report.
Diane's inventory report is not a debugging tool. Tomorrow we cover the flip side: / and * as separators in the signature — not collecting arguments, but restricting how they can be called. You can force some parameters to always be positional and others to always be keyword. The loading dock rules: this dock is for pallets only, that dock is for parcels only.
*args in a function signature collects all extra positional arguments into a tuple. **kwargs collects all extra keyword arguments into a dict. The names args and kwargs are convention — you can name them anything, but these are universal.
def receive_batch(*items: tuple, warehouse: str = "main") -> list[dict]:
return [{"sku": s, "qty": q, "warehouse": warehouse} for s, q in items]
receive_batch(("SKU-1", 50), ("SKU-2", 10)) # 2 products, main warehouse
receive_batch(("SKU-1", 50), warehouse="east") # 1 product, east warehouse
receive_batch() # empty batch — returns []Key behaviors:
*args always produces a tuple — even when called with zero arguments (empty tuple).*args is automatically keyword-only — the caller must name it.**kwargs always produces a dict — empty dict when no keyword extras are passed.*name: T applies to each individual item, not the tuple itself.Pitfall 1: Passing a positional after *args. f(*items, "east") is a SyntaxError — positional arguments cannot follow a starred expression at the call site. Use f(*items, warehouse="east") instead.
Pitfall 2: Confusing **kwargs with named defaults. Named parameters with defaults (warehouse="main") are for known, bounded options you own. **kwargs is for open-ended pass-through or extensible APIs where you don't know the options at write time. Mixing them by always using **kwargs instead of named params produces functions that are hard to document and easy to misuse.
Pitfall 3: Assuming *args preserves source type. Regardless of whether arguments were passed from a list, tuple, or generator, *args always collects into a tuple. You cannot append to it inside the function.
The * operator works symmetrically at the call site — f(*my_list) spreads a list as separate positional arguments. f(**my_dict) spreads a dict as keyword arguments. This is the inverse of *args/**kwargs in signatures. Both directions of the */** operators are covered in Day 14.
Sign up to write and run code in this lesson.
Diane walked in with a new requirement this morning. The restock endpoint needs to handle batches — one call, any number of SKUs. Could be one product, could be fifty. What's your first instinct for how to accept a variable number of inputs?
Accept a list parameter. The caller builds the list, passes it in. That works, but the caller has to remember to wrap their products in brackets and it feels awkward for a single-item restock.
There's a cleaner way. Python has syntax for "collect all the positional arguments the caller passes into a single tuple." The * prefix on a parameter name does exactly that. Any positional argument after the required ones flows into it.
That's *args? I've seen it in function signatures but I always assumed it was some advanced feature I didn't need yet.
You need it now. And it's not magic — it's the unpacking operator you've been using all week, working on the receiving side of a function call instead of the assignment side. Each positional argument becomes one element:
def receive_shipment(*product_updates):
print(type(product_updates)) # <class 'tuple'>
for sku, qty in product_updates:
print(f"Receiving {qty} units of {sku}")
receive_shipment(("SKU-1001", 50), ("SKU-2042", 120), ("SKU-3300", 8))
# tuple of 3 tuplesA tuple of tuples. I called it with three (sku, qty) pairs as separate arguments, and Python bundled them all into one tuple called product_updates. So the loop unpacks each inner tuple in each iteration.
That's exactly right. And the reason the outer container is a tuple — not a list — is that *args is read-only by design. You received a snapshot of what the caller passed. Think of the dock worker with a shipping manifest: however many crates arrive, they all get logged. The manifest itself doesn't change after the truck is unloaded.
What about named routing information? Like, the warehouse destination isn't one of the products — it's metadata for the whole batch. Does that go somewhere different?
That's **kwargs — double-star collects keyword arguments into a dict. But here's where I want to show you something more targeted. For batch_restock, warehouse is a known named parameter with a default. We don't need **kwargs for that. We just put it after *product_updates in the signature:
def batch_restock(*product_updates: tuple, warehouse: str = "main") -> list[dict]:
results = []
for sku, quantity in product_updates:
results.append({"sku": sku, "quantity": quantity, "warehouse": warehouse})
return resultswarehouse comes after *product_updates in the signature. How does Python know which positional arguments belong to product_updates and when they stop?
Once Python sees a *args parameter, it collects every remaining positional argument into it. There's nothing left for warehouse to grab positionally. This means any parameter after *product_updates is automatically keyword-only — the caller must pass it by name.
batch_restock(("SKU-1001", 50), ("SKU-2042", 120), warehouse="east") # correct
batch_restock(("SKU-1001", 50), ("SKU-2042", 120), "east") # wrong — "east" goes into product_updates!So *args acts as a wall. Everything before it gets collected into the tuple. Anything after must be named. That means warehouse="east" at the call site isn't optional style — it's required.
And that requirement is actually good design. batch_restock(..., warehouse="east") tells you at a glance what that argument means. batch_restock(..., "east") would be a mystery.
Okay. I'm following the mechanics. But when do I reach for **kwargs instead of just listing named parameters with defaults? They seem to solve similar problems.
Different problems. Named parameters with defaults are for known, bounded options — you know exactly what the caller might want to specify. **kwargs is for open-ended input: when you're writing a pass-through function, a decorator, or a logging utility that needs to accept arbitrary named options it doesn't own. For batch_restock, warehouse is bounded — use a named parameter. If you needed to support arbitrary routing tags (priority, carrier, temperature_zone), that's when **kwargs earns its place.
So the decision is: do I know the names ahead of time? Named params. Am I forwarding options I don't own, or building something extensible? **kwargs.
That's the right frame. Let me show you what an empty batch call does:
batch_restock() # zero positional args
# product_updates = () — empty tuple
# loop runs zero times
# returns []No guard needed. Empty call returns empty list. The zero-argument case handles itself because an empty tuple iterates zero times.
That's the elegance of *args over accepting a list with None as a default — you don't need an if product_updates is None guard. The empty case is just the natural behavior.
One more thing. I've been using *product_updates: tuple with the type annotation — that's saying each element I receive should be a tuple, not that product_updates itself is annotated as tuple?
Correct. The annotation on *name applies to each individual argument, not the collected container. Your editor and type checker will flag it if a caller passes a string instead of a tuple. Python won't enforce it at runtime.
Good. I'd rather the type checker catch that than Diane's inventory report.
Diane's inventory report is not a debugging tool. Tomorrow we cover the flip side: / and * as separators in the signature — not collecting arguments, but restricting how they can be called. You can force some parameters to always be positional and others to always be keyword. The loading dock rules: this dock is for pallets only, that dock is for parcels only.
*args in a function signature collects all extra positional arguments into a tuple. **kwargs collects all extra keyword arguments into a dict. The names args and kwargs are convention — you can name them anything, but these are universal.
def receive_batch(*items: tuple, warehouse: str = "main") -> list[dict]:
return [{"sku": s, "qty": q, "warehouse": warehouse} for s, q in items]
receive_batch(("SKU-1", 50), ("SKU-2", 10)) # 2 products, main warehouse
receive_batch(("SKU-1", 50), warehouse="east") # 1 product, east warehouse
receive_batch() # empty batch — returns []Key behaviors:
*args always produces a tuple — even when called with zero arguments (empty tuple).*args is automatically keyword-only — the caller must name it.**kwargs always produces a dict — empty dict when no keyword extras are passed.*name: T applies to each individual item, not the tuple itself.Pitfall 1: Passing a positional after *args. f(*items, "east") is a SyntaxError — positional arguments cannot follow a starred expression at the call site. Use f(*items, warehouse="east") instead.
Pitfall 2: Confusing **kwargs with named defaults. Named parameters with defaults (warehouse="main") are for known, bounded options you own. **kwargs is for open-ended pass-through or extensible APIs where you don't know the options at write time. Mixing them by always using **kwargs instead of named params produces functions that are hard to document and easy to misuse.
Pitfall 3: Assuming *args preserves source type. Regardless of whether arguments were passed from a list, tuple, or generator, *args always collects into a tuple. You cannot append to it inside the function.
The * operator works symmetrically at the call site — f(*my_list) spreads a list as separate positional arguments. f(**my_dict) spreads a dict as keyword arguments. This is the inverse of *args/**kwargs in signatures. Both directions of the */** operators are covered in Day 14.