Python Mastery: Complete Beginner to Professional
HomeInsightsCoursesPython*args and **kwargs Deep Dive

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).
PYTHON
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.
PYTHON
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

PYTHON
# ❌ 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.

PYTHON
# ✅ 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).

PYTHON
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)) # 100

Keyword Packing (**kwargs)

The `**` operator (double splat) collects all extra keyword arguments into a dictionary.

PYTHON
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.

PYTHON
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:

  1. Allocate a new tuple object in memory.
  2. Copy the pointers of `1`, `2`, and `3` into that tuple.
  3. 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!

PYTHON
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 York

Gold 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)`).

PYTHON
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.

PYTHON
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) # ✅ Works

The Grand Unified Theory

You can combine them all (but please don't unless you're writing a library):

PYTHON
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 ONLY

5. Type Hinting Flexible Arguments

In modern Python (3.10+), we use standard typing to hint `*args` and `**kwargs`.

PYTHON
from typing import Any

# For *args, specify the type of a SINGLE item
def mean(*values: float) -&gt; float:
    return sum(values) / len(values)

# For **kwargs, specify the type of the VALUES
def build_config(**settings: str) -&gt; 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.

PYTHON
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"   -&gt; 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`.

  1. Positional: Slots are filled left-to-right.
  2. Keywords: Named arguments look for empty slots. If a slot is already filled by positional, ERROR.
  3. Defaults: Any empty slots remaining get their default values.
  4. *args: Any extra positional values go here (if `*args` exists).
  5. **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"

PYTHON
# ❌ Bad
# func(a=10, 20)
# SyntaxError: positional argument follows keyword argument

Fix: Once you start using keywords (`a=10`), every argument after that must also be a keyword.

2. "multiple values for argument 'x'"

PYTHON
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"

PYTHON
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

PYTHON
# ❌ 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

PYTHON
# ✅ 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.

PYTHON
def bad_func(x=[]):
    pass

print(bad_func.__defaults__) 
# ([],) -&gt; The list exists here!

bad_func() # Modify it...
print(bad_func.__defaults__) 
# (['modified!'],) -&gt; We can see the pollution!

The `inspect` Module

For professional tools, avoid accessing `__dunder__` attributes. Use the standard `inspect` module.

PYTHON
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.

PYTHON
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:

PYTHON
def connect(**params):
    # What goes in here?
    # Host? IP? Username? User_Name?
    pass

This 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.

PYTHON
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.

PYTHON
# 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)

What's Next?

We will explore how to unpack arguments and enforce strict API contracts.