Mastering Function Arguments
From basic positional args to advanced packing, unpacking, and API design patterns.
1. The Basics: Positional vs Keyword
In Python, you can pass arguments to a function in two ways. Understanding the difference is crucial for reading modern codebases.
- Positional Arguments: The order matters. The 1st value goes to the 1st parameter.
- Keyword Arguments: The name matters. Order is ignored (if names are explicit).
def create_profile(name, age, city):
print(f"{name} is {age} years old from {city}")
# 1. Positional (Order matters)
create_profile("Alice", 30, "New York")
# 2. Keyword (Order doesn't matter)
create_profile(age=30, city="New York", name="Alice")
# 3. Mixed (Positional MUST come first!)
create_profile("Alice", city="London", age=25)2. Theory: How Python Passes Arguments
The Golden Rule: Python is neither "Pass by Value" nor "Pass by Reference". It is "Pass by Object Reference" (or Pass by Assignment).
This is a major source of confusion for C++ or Java developers.
- Immutable Objects (int, str): Act like "By Value". If you change them inside the function, the outsider doesn't see it (because you actually created a new object).
- Mutable Objects (list, dict): Act like "By Reference". If you Modify them (append, update), the outsider sees it.
def modify_them(n, l):
n = 100 # Rebinds local 'n' to a new integer. Outer 'n' is unchanged.
l.append(100) # Modifies the actual list object. Outer 'l' IS changed.
a = 10
b = [1, 2]
modify_them(a, b)
print(a) # 10 (Unchanged)
print(b) # [1, 2, 100] (Changed!)3. CRITICAL PITFALL: Mutable Default Arguments
Stop. Read this carefully. This is the #1 bug in Python interviews and junior codebases.
Beginners often write functions like `def add_item(item, list=[])`. They expect a new empty list every time the function is called.They are wrong.
In Python, default argument values are evaluated only once, when the function is defined, not when it is called. If that value is mutable (like a list or dict), the same object is shared across all calls.
The Bug in Action
# ⌠BAD: The list is created once at definition time
def add_student(name, roster=[]):
roster.append(name)
return roster
# Usage
class_a = add_student("Alice")
print(class_a) # ['Alice'] (Looks fine...)
class_b = add_student("Bob")
print(class_b)
# EXPECTED: ['Bob']
# ACTUAL: ['Alice', 'Bob'] 😱
# Why? 'roster' is the SAME list object in memory for both calls!The Fix: The "None" Sentinel
To fix this, default to `None` (which is immutable) and create the new list inside the function body.
# ✅ GOOD: New list created at runtime
def add_student(name, roster=None):
if roster is None:
roster = [] # New list created NOW
roster.append(name)
return roster
print(add_student("Alice")) # ['Alice']
print(add_student("Bob")) # ['Bob'] (Correct!)2. Flexible Arguments: *args and **kwargs
Sometimes you don't know how many arguments a user will pass. Python handles this with Packing operators.
Positional Packing (*args)
The `*` operator (splat) collects all extra positional arguments into a tuple. Note: The name `args` is convention, but you could call it `*bananas`. (Please don't).
def sum_all(*numbers):
print(type(numbers)) # <class 'tuple'>
total = 0
for n in numbers:
total += n
return total
print(sum_all(1, 2, 3)) # 6
print(sum_all(10, 20, 30, 40)) # 100Keyword Packing (**kwargs)
The `**` operator (double splat) collects all extra keyword arguments into a dictionary.
def build_html_tag(tag, **attributes):
# 'tag' is a normal argument.
# 'attributes' is a dictionary of the rest.
attr_str = ""
for key, value in attributes.items():
attr_str += f' {key}="{value}"'
return f"<{tag}{attr_str}></{tag}>"
print(build_html_tag("a", href="google.com", target="_blank"))
# Output: <a href="google.com" target="_blank"></a>
print(build_html_tag("div", id="main", className="container"))
# Output: <div id="main" className="container"></div>3. Unpacking: The Inverse Operation
We just saw how to pack arguments inside a function definition. But you can also use `*` and `**` when calling a function to unpack collections.
def register_user(name, email, age):
print(f"User: {name}, Email: {email}, Age: {age}")
user_data_list = ["Alice", "alice@example.com", 25]
# ⌠Tedious
# register_user(user_data_list[0], user_data_list[1], user_data_list[2])
# ✅ Efficient (Unpacking)
register_user(*user_data_list)
# Python expands this to: register_user("Alice", "alice@example.com", 25)
user_data_dict = {
"age": 30,
"email": "bob@example.com",
"name": "Bob"
}
# ✅ Keyword Unpacking (Keys must match argument names!)
register_user(**user_data_dict)Deep Dive: Memory & Performance
You might think `*args` is just syntactic sugar, but it has real memory implications. When you call a function with `func(1, 2, 3)` and it accepts `*args`, Python must:
- Allocate a new tuple object in memory.
- Copy the pointers of `1`, `2`, and `3` into that tuple.
- Pass that tuple to the function frame.
For 3 items, this is negligible. But if you unpack a list of 1 million items (`func(*huge_list)`), Python has to iterate the entire list and build a new tuple copy.Pro Tip: If performance is critical and data is huge, pass the list directly (`func(my_list)`) instead of unpacking it.
Language Comparison
How does Python compare to others?
- Java (`String... args`): Similar. Creates an Array behind the scenes. Statically typed.
- JavaScript (`...args`): Creates a genuine Array (mutable). Python's `args` are always immutable Tuples.
- C: Uses `va_list` macros (very manual and dangerous). Python handles safety for you.
The "Wrapper" Pattern: Forwarding Arguments
The most common use of `*args` and `**kwargs` isn't to use the data yourself—it's to pass it to someone else. This is called Argument Forwarding.
Imagine you are writing a subclass. You want to override `__init__` to add one property, but keep the rest the same. Do you copy-paste the parent's arguments? No!
class Parent:
def __init__(self, name, age, city="Unknown"):
self.name = name
self.age = age
self.city = city
class Child(Parent):
def __init__(self, school, *args, **kwargs):
# 1. Capture our specific argument
self.school = school
# 2. Forward EVERYTHING else to Parent
# We don't care if Parent changes its signature later!
super().__init__(*args, **kwargs)
# Usage
kid = Child("Elementary", "Alice", 10, city="New York")
print(kid.school) # Elementary
print(kid.name) # Alice
print(kid.city) # New YorkGold Standard: This makes your code "future-proof". If `Parent` adds a new argument tomorrow, `Child` keeps working automatically.
4. Modern Python: Enforcing Argument Types
As libraries grow, maintaining backward compatibility is hard. Python 3.8 introduced special syntax to force how users pass arguments. This is done using the / and * separators.
Positional-Only Arguments (The `/`)
Arguments before the `/` cannot be passed by keyword. They must be positional. This is useful for functions where the argument name is meaningless (like `len(obj)` or `pow(x, y)`).
def fast_math(x, y, /):
return x + y
print(fast_math(10, 20)) # ✅ Works
# print(fast_math(x=10, y=20))
# ⌠TypeError: fast_math() got some positional-only arguments passed as keyword arguments: 'x', 'y'Keyword-Only Arguments (The `*`)
Arguments after the `*` must be passed by keyword. They cannot be positional. This is excellent for configurations or dangerous flags (like `force=True`) that you don't want users to set accidentally.
def delete_user(user_id, *, force=False):
if force:
print(f"User {user_id} deleted forever!")
else:
print(f"User {user_id} moved to trash.")
# delete_user(123, True)
# ⌠TypeError: delete_user() takes 1 positional argument but 2 were given
delete_user(123, force=True) # ✅ WorksThe Grand Unified Theory
You can combine them all (but please don't unless you're writing a library):
def monster(a, b, /, c, d, *, e, f):
print(a, b, c, d, e, f)
# a, b: Positional ONLY
# c, d: Either
# e, f: Keyword ONLY5. Type Hinting Flexible Arguments
In modern Python (3.10+), we use standard typing to hint `*args` and `**kwargs`.
from typing import Any
# For *args, specify the type of a SINGLE item
def mean(*values: float) -> float:
return sum(values) / len(values)
# For **kwargs, specify the type of the VALUES
def build_config(**settings: str) -> dict[str, str]:
return settings
# Note: We don't type hint the tuple/dict container itself,
# just the contents!Real World Project: The Validated Configurator
A common pattern in Data Science and Web Development is passing a dynamic configuration dictionary. Let's build a class that takes generic `**kwargs` but validates them strictly.
class DatabaseConnection:
# We use **options to accept any driver-specific settings
def __init__(self, host="localhost", port=5432, **options):
self.host = host
self.port = port
self.options = options # Store the rest specific to the driver
self.validate()
def validate(self):
# Enforce that certain "extra" options exists
if "timeout" not in self.options:
print("âš ï¸ access: Defaulting timeout to 30s")
self.options["timeout"] = 30
def connect(self):
print(f"🔌 Connecting to {self.host}:{self.port}")
for k, v in self.options.items():
print(f" -> Setting {k} = {v}")
# Usage
# 1. Standard
db = DatabaseConnection()
db.connect()
# 2. Custom with Extra Options
pg_db = DatabaseConnection(
host="192.168.1.5",
ssl_mode="require", # Captured in **options
timeout=60 # Captured in **options
)
pg_db.connect()Pattern: This "Arguments Tunnel" pattern allows your class to support future features without changing the method signature. The `**options` dict just catches everything new.
Under the Hood: Argument Binding
When you call a function, Python's interpreter goes through a strict "Binding" phase to map your values to variables. Understanding this order helps you debug `TypeError`.
- Positional: Slots are filled left-to-right.
- Keywords: Named arguments look for empty slots. If a slot is already filled by positional, ERROR.
- Defaults: Any empty slots remaining get their default values.
- *args: Any extra positional values go here (if `*args` exists).
- **kwargs: Any extra keyword values go here (if `**kwargs` exists).
This complexity is why "Keyword-Only" arguments are so valuable—they skip the ambiguous "Positional" phase entirely.
6. Debugging Common `TypeError`s
Argument errors are the most common runtime crashes. Here is how to decode them.
1. "positional argument follows keyword argument"
# ⌠Bad
# func(a=10, 20)
# SyntaxError: positional argument follows keyword argumentFix: Once you start using keywords (`a=10`), every argument after that must also be a keyword.
2. "multiple values for argument 'x'"
def add(x, y): return x + y
# ⌠Bad
# add(10, x=20)
# TypeError: add() got multiple values for argument 'x'Why? Python assigns the first positional (`10`) to `x`. Then it sees `x=20` and tries to assign to `x` again.Fix: Don't mix positional and keyword for the same variable.
3. "unexpected keyword argument"
def user(name): pass
# ⌠Bad
# user(name="Bob", age=30)
# TypeError: user() got an unexpected keyword argument 'age'Fix: The function didn't expect `age`. Add `**kwargs` to the definition if you want to accept extra junk.
7. Refactoring Case Study: From Messy to Clean
Let's refactor a messy API client function that suffers from "Argument Creep".
Before: The "Positional" Hell
# ⌠Hard to read, hard to extend
def fetch_data(url, method, timeout, headers, retry, cache):
pass
# Usage
fetch_data("api.com", "GET", 30, None, True, False)
# What is 30? What is True? What is False?After: The "Keyword" Heaven
# ✅ Self-documenting and safe
def fetch_data(url, *, method="GET", timeout=30, headers=None, retry=True, cache=False):
pass
# Usage
fetch_data("api.com", timeout=60, retry=False)
# We skipped arguments we didn't care about!
# We know exactly what '60' and 'False' mean.Victory: By forcing keywords (using `*`), we made the call site readable and allowed defaults to handle 80% of the cases.
6. Introspection: Looking at the Gears
Everything in Python is an object, including the function's argument definitions. You can inspect these directly to debug weird behavior.
Accessing Defaults Directly
Remember the "Mutable Default" bug? We can actually see the mistake stored in memory.
def bad_func(x=[]):
pass
print(bad_func.__defaults__)
# ([],) -> The list exists here!
bad_func() # Modify it...
print(bad_func.__defaults__)
# (['modified!'],) -> We can see the pollution!The `inspect` Module
For professional tools, avoid accessing `__dunder__` attributes. Use the standard `inspect` module.
import inspect
def process(a, b=2, *args, **kwargs):
pass
sig = inspect.signature(process)
print(sig) # (a, b=2, *args, **kwargs)
for name, param in sig.parameters.items():
print(f"{name}: {param.kind} (Default: {param.default})")Pro Tip: Argument Binding
The most powerful feature of `inspect` is bind(). It simulates a function call without actually calling the function. This is how frameworks like Django or FastAPI validate your inputs before running your view.
def View(request, user_id):
pass
sig = inspect.signature(View)
# Simulate a call:
try:
# We forgot 'user_id'!
bound = sig.bind(request={'url': '/home'})
except TypeError as e:
print(f"Validation Error: {e}")
# Output: Validation Error: missing a required argument: 'user_id'
# Correct call simulation:
bound = sig.bind({'url': '/home'}, 123)
print(bound.arguments)
# OrderedDict([('request', {'url': '/home'}), ('user_id', 123)])7. Anti-Patterns: When Flexibility Bites
Just because you can accept `**kwargs` doesn't mean you should.
The "Mystery Meat" API
Imagine you inherit this code:
def connect(**params):
# What goes in here?
# Host? IP? Username? User_Name?
passThis is unreadable. The user has to read the source code to know what to pass.Best Practice: Explicit is better than implicit. Only use `**kwargs` if you are truly forwarding data to something else (like a database driver or parent class).
8. Extended Iterable Unpacking
Unpacking isn't just for function calls. It works in assignment too! You can grab the "head" and "tail" of a list elegantly.
data = [1, 2, 3, 4, 5, 6]
# Grab first, last, and middle
first, *middle, last = data
print(first) # 1
print(middle) # [2, 3, 4, 5]
print(last) # 6
# Grab first two
a, b, *rest = data
print(rest) # [3, 4, 5, 6]Summary: Best Practices Checklist
- Standard First: Always put standard usage first. Complex features (`*args`) should be opt-in.
- Explicit > Implicit: Use Keyword-Only arguments for boolean flags (`force=True`).
- Avoid Mutables: Never use empty lists/dicts as defaults. Use `None`.
- Document: If you use `**kwargs`, write a docstring listing exactly what keys are expected.
9. Interview Prep: The Ordering Rule
Question: Why does `*args` always come before `**kwargs`?
Answer: Because Positional arguments are rigidly ordered, while Keyword arguments are flexible. If you tried `def func(**kwargs, *args)`, Python wouldn't know when the "positional" part starts because keywords can appear anywhere in the call.
# The Law of Order:
# 1. Standard Positional (a, b)
# 2. Standard Default (c=10)
# 3. Positional Packing (*args)
# 4. Keyword-Only (*, d, e)
# 5. Keyword Packing (**kwargs)