*args and **kwargs in Python Explained — With Real-World Patterns
Most Python tutorials show you args and kwargs as a syntax curiosity — a footnote after the 'real' stuff. That's backwards. These two features are why Python's standard library, every major framework, and every well-designed API you've ever touched actually works. Django's class-based views use kwargs to pass URL parameters. Python's built-in print() uses args so you can pass it one string or twenty. Decorators — the feature that powers Flask routes, pytest fixtures, and logging middleware — are almost impossible to write correctly without them.
Why Fixed-Argument Functions Break in the Real World
When you define a function like def add(a, b), you're making a promise: this function always takes exactly two things. That's fine for a calculator. It's not fine for the real world, where requirements shift.
Picture a logging utility. On day one you log a message and a level. On day two someone needs to attach a user ID. Day three, a request ID. If your function signature is rigid, you're back editing it every time. You'd end up with def log(message, level, user_id=None, request_id=None, session_id=None) — a parameter list that grows forever.
args and kwargs exist to solve exactly this. They're not shortcuts or hacks — they're a deliberate design pattern that says 'this function is designed to receive a variable number of inputs.' Understanding when to reach for them (and when not* to) is what separates intermediate from advanced Python.
# THE PROBLEM: a rigid function that can't grow cleanly def log_rigid(message, level, user_id=None, request_id=None): # Adding new optional fields means changing the signature every time print(f'[{level}] {message} | user={user_id} | request={request_id}') # THE SOLUTION: a flexible function using *args and **kwargs def log_flexible(message, level, *extra_values, **context): # *extra_values captures any extra positional args as a tuple # **context captures any named metadata as a dictionary extras = ', '.join(str(v) for v in extra_values) # join any extra positional data meta = ' | '.join(f'{k}={v}' for k, v in context.items()) # format key-value metadata output = f'[{level}] {message}' if extras: output += f' | extra: {extras}' if meta: output += f' | {meta}' print(output) # Call with just the basics log_flexible('Server started', 'INFO') # Call with extra positional context log_flexible('Retrying connection', 'WARN', 'attempt_2', 'pool_B') # Call with named metadata — no signature change needed log_flexible('User login failed', 'ERROR', user_id=4821, request_id='req-9f3a') # Call with both extra positional and named metadata log_flexible('Payment processed', 'INFO', 'stripe', amount=99.99, currency='USD')
[WARN] Retrying connection | extra: attempt_2, pool_B
[ERROR] User login failed | user_id=4821 | request_id=req-9f3a
[INFO] Payment processed | extra: stripe | amount=99.99 | currency=USD
How *args and **kwargs Actually Work Under the Hood
The asterisks aren't magic keywords — they're unpacking operators. When Python sees args in a function definition, it's an instruction: 'collect all remaining positional arguments into a tuple and bind that tuple to the name args.' The name itself is just a convention. You could write toppings or *scores and it would work identically.
Similarly, **kwargs says: 'collect all remaining keyword arguments into a dictionary.' The double asterisk means 'key-value pairs,' not just 'things.'
This matters because a tuple and a dictionary behave differently. You iterate over args with a simple for loop. You iterate over *kwargs with .items() to get both the key and the value. You can also use len(), slicing, and unpacking on args just like any tuple. kwargs gives you .get(), .keys(), and the full dict API.
The order of parameters in a function signature is strict: regular positional params first, then args, then keyword-only params, then *kwargs. Breaking this order raises a SyntaxError immediately.
# Demonstrating what *args and **kwargs actually ARE at runtime def inspect_arguments(*measurements, label='Reading', **sensor_data): # *measurements is a plain Python TUPLE — not some special object print(f'Type of measurements: {type(measurements)}') print(f'Measurements received: {measurements}') print(f'Number of readings: {len(measurements)}') # **sensor_data is a plain Python DICT print(f'Type of sensor_data: {type(sensor_data)}') print(f'Sensor metadata: {sensor_data}') print(f'Label: {label}') print('---') # Iterate over args just like any tuple for i, reading in enumerate(measurements): print(f' Reading {i + 1}: {reading}') # Iterate over kwargs just like any dict for key, value in sensor_data.items(): print(f' Metadata — {key}: {value}') # 'label' is a keyword-only param sitting between *args and **kwargs inspect_arguments(23.4, 25.1, 22.8, label='Temperature', unit='Celsius', location='rack-3') print('\n--- Unpacking a list INTO *args at call time ---') # The * operator at CALL time unpacks an iterable into positional args temperature_readings = [19.0, 20.5, 21.3] metadata = {'unit': 'Celsius', 'sensor_id': 'T-07'} inspect_arguments(*temperature_readings, **metadata) # unpacking at call site
Measurements received: (23.4, 25.1, 22.8)
Number of readings: 3
Type of sensor_data: <class 'dict'>
Sensor metadata: {'unit': 'Celsius', 'location': 'rack-3'}
Label: Temperature
---
Reading 1: 23.4
Reading 2: 25.1
Reading 3: 22.8
Metadata — unit: Celsius
Metadata — location: rack-3
--- Unpacking a list INTO *args at call time ---
Type of measurements: <class 'tuple'>
Measurements received: (19.0, 20.5, 21.3)
Number of readings: 3
Type of sensor_data: <class 'dict'>
Sensor metadata: {'unit': 'Celsius', 'sensor_id': 'T-07'}
Label: Reading
---
Reading 1: 19.0
Reading 2: 20.5
Reading 3: 21.3
Metadata — unit: Celsius
Metadata — sensor_id: T-07
The Pattern That Powers Real Frameworks: Decorator Forwarding
Here's where args and *kwargs stop being a curiosity and become genuinely essential. Decorators — the @something syntax you see on Flask routes, Django views, and test functions — work by wrapping one function inside another. The wrapper needs to call the original function. But the wrapper doesn't know what arguments the original takes.
This is the single most important real-world use case: writing a wrapper function that forwards every argument to the inner function without caring what those arguments are.
Without args and *kwargs, you'd have to write a different decorator for every possible function signature. With them, you write one decorator that works universally. This is also the pattern behind Python's functools.wraps, unittest.mock.patch, and virtually every middleware system you'll encounter in production code.
Once you understand this pattern, reading framework source code stops feeling like magic and starts feeling like recognizable building blocks.
import time import functools # A universal timing decorator that works on ANY function # It doesn't care how many args the wrapped function takes def measure_execution_time(func): @functools.wraps(func) # preserves the original function's name and docstring def wrapper(*args, **kwargs): # capture EVERYTHING the caller passes start_time = time.perf_counter() # Forward ALL captured arguments to the real function, untouched result = func(*args, **kwargs) end_time = time.perf_counter() duration_ms = (end_time - start_time) * 1000 print(f'[TIMER] {func.__name__} completed in {duration_ms:.3f}ms') return result # always return the original result return wrapper @measure_execution_time def fetch_user_profile(user_id, include_preferences=False): """Simulates a database lookup for a user profile.""" time.sleep(0.05) # simulate I/O delay profile = {'user_id': user_id, 'name': 'Alex Rivera'} if include_preferences: profile['theme'] = 'dark' return profile @measure_execution_time def calculate_order_total(item_prices, discount_code=None, tax_rate=0.08): """Simulates a pricing calculation.""" subtotal = sum(item_prices) if discount_code == 'SAVE10': subtotal *= 0.90 # apply 10% discount return subtotal * (1 + tax_rate) # The SAME decorator works on both functions with completely different signatures profile = fetch_user_profile(user_id=1042, include_preferences=True) print(f'Profile: {profile}\n') total = calculate_order_total([29.99, 14.99, 49.99], discount_code='SAVE10', tax_rate=0.10) print(f'Order total: ${total:.2f}')
Profile: {'user_id': 1042, 'name': 'Alex Rivera', 'theme': 'dark'}
[TIMER] calculate_order_total completed in 0.003ms
Order total: $103.92
When NOT to Use *args and **kwargs
Using args and *kwargs everywhere is a code smell, not a sign of skill. If your function always takes exactly two user IDs to compare, define it that way. Explicit signatures are self-documenting — they tell the caller exactly what's expected. IDEs can autocomplete them. Type checkers can validate them.
**kwargs in particular can hide bugs. If you misspell a keyword argument, Python won't raise a TypeError — it'll just silently add the misspelled key to the dict and your logic will skip it. With explicit parameters, Python catches the typo immediately.
The right time to use them: when you're genuinely wrapping or forwarding calls (decorators, middleware), when you're building a function that is intentionally variadic by design (like a custom print or log function), or when you're writing a base class method that subclasses will extend with their own signatures. The wrong time: when you're just being lazy about writing out three parameters.
# WRONG: Using **kwargs when you know exactly what you need # The caller has no idea what keys are valid — bugs hide silently def create_user_bad(**kwargs): # If caller passes 'usrname' instead of 'username', we'll never know username = kwargs.get('username', 'anonymous') # typo in caller = silent bug email = kwargs.get('email', '') return {'username': username, 'email': email} # RIGHT: Explicit parameters when the interface is known and fixed def create_user_good(username, email, is_admin=False): # Caller KNOWS exactly what to pass. Typos raise TypeError immediately. return {'username': username, 'email': email, 'is_admin': is_admin} # RIGHT: **kwargs makes sense here — forwarding to a third-party library def create_database_connection(host, port, **driver_options): # We know host and port (our API), but driver_options belong to the DB driver # We don't want to maintain a mirror of the driver's full option set print(f'Connecting to {host}:{port} with options: {driver_options}') # In real code: return driver.connect(host=host, port=port, **driver_options) # Demonstrate the silent bug in the bad version result = create_user_bad(usrname='alex', email='alex@example.com') # typo: 'usrname' print(f'Bad result (typo went unnoticed): {result}') # username defaults to 'anonymous' # Explicit version catches the error immediately try: create_user_good(usrname='alex', email='alex@example.com') # same typo except TypeError as error: print(f'Good result (typo caught instantly): {error}') # Forwarding pattern — valid use of **kwargs create_database_connection('db.prod.internal', 5432, timeout=30, ssl=True, pool_size=10)
Good result (typo caught instantly): create_user_good() got an unexpected keyword argument 'usrname'
Connecting to db.prod.internal:5432 with options: {'timeout': 30, 'ssl': True, 'pool_size': 10}
| Feature / Aspect | *args | **kwargs |
|---|---|---|
| What it collects | Extra positional arguments | Extra keyword arguments |
| Python type at runtime | tuple | dict |
| Argument style at call site | func(1, 2, 3) | func(a=1, b=2, c=3) |
| How to iterate | for item in args | for key, value in kwargs.items() |
| Order preserved? | Yes — insertion order | Yes — insertion order (Python 3.7+) |
| Unpack into call | func(*my_list) | func(**my_dict) |
| Position in signature | After regular params | Always last |
| Primary use case | Variadic positional data (scores, prices) | Named config / metadata forwarding |
| Typo detection | N/A — positional, no names | Silent — typos become valid keys |
| Can be used alone? | Yes | Yes |
🎯 Key Takeaways
- args is a tuple at runtime — iterate it, slice it, len() it like any tuple. *kwargs is a dict — use .items(), .get(), and .keys() on it.
- The universal decorator pattern (wrapper(args, kwargs) → func(args, **kwargs)) is the single most important real-world use of these features. Master it.
- kwargs silently swallows typos — if your function's interface is fixed and known, use explicit named parameters instead. Save kwargs for genuine forwarding scenarios.
- The and * operators work in BOTH directions: in function definitions they collect arguments into tuple/dict; at the call site they unpack tuple/dict back into arguments. Same symbol, opposite direction.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Putting *kwargs before args in a function signature — causes a SyntaxError: 'invalid syntax' immediately — fix it by always following the order: regular params → args → keyword-only params → *kwargs. Python enforces this strictly.
- ✕Mistake 2: Mutating the kwargs dict inside the function and expecting it to affect the caller's dict — it won't, because **kwargs creates a new dict from the caller's keyword arguments. The caller's original variables are untouched. If you need to pass mutations back, return the modified dict explicitly.
- ✕Mistake 3: Passing a dict as a positional argument instead of unpacking it — calling
func({'key': 'value'})when you meanfunc(*{'key': 'value'})sends the whole dict as one positional arg into args, not as keyword args. The symptom isargs = ({'key': 'value'},)and kwargs is empty. Always use ** to unpack dicts into kwargs.
Interview Questions on This Topic
- QCan you walk me through how you'd write a decorator that logs the arguments and return value of any function it wraps, regardless of that function's signature?
- QWhat's the difference between defining a function with *args and calling a function with the * operator? They look similar — are they the same thing?
- QIf I call `my_func(**{'timeout': 30, 'retries': 3})` and my function signature is `def my_func(*args, **kwargs)`, what will args and kwargs contain? What if I accidentally pass a positional argument at the same time?
Frequently Asked Questions
Do I have to name them 'args' and 'kwargs'?
No — the names are pure convention. The operators are and . You can write def func(scores, **player_data) and it works identically. That said, stick to args and kwargs unless you have a specific reason not to — every Python developer recognises them instantly.
Can I use *args and **kwargs together with regular parameters?
Yes, but the order is strict: regular positional params come first, then args, then any keyword-only params (params defined after args), then *kwargs. Breaking this order raises a SyntaxError. Example: def func(name, age, scores, active=True, **metadata) is valid.
Why does modifying kwargs inside my function not change the original dict I passed in?
Because kwargs doesn't receive your dictionary — it receives a new dictionary built from the keyword arguments at the call site. The double-star operator unpacks key-value pairs; it doesn't pass a reference to your original dict. If you pass my_config, modifying kwargs inside the function leaves my_config unchanged.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.