*args and **kwargs - Variable-Length Arguments in Python
Reading time: ~18 minutes | Level: Foundation → Engineering
Here is a behavior that surprises most developers the first time they trace through it:
def show(*args, **kwargs):
print(type(args)) # <class 'tuple'>
print(type(kwargs)) # <class 'dict'>
print(args)
print(kwargs)
show(1, 2, 3, name="Alice", role="admin")
# <class 'tuple'>
# <class 'dict'>
# (1, 2, 3)
# {'name': 'Alice', 'role': 'admin'}
# Now flip it - unpack at the call site:
numbers = [10, 20, 30]
config = {"sep": ", ", "end": "\n---\n"}
print(*numbers, **config)
# 10, 20, 30
# ---
The * and ** operators do opposite things depending on context:
- Inside a function definition, they pack incoming arguments into a tuple or dict.
- Inside a function call, they unpack a sequence or mapping into individual arguments.
Most tutorials stop there. This page goes further - into CPython internals, the full 5-parameter type ordering, forwarding patterns used in production code, and the anti-patterns that signal you have skipped API design.
What You Will Learn
- What
*and**do in definitions vs calls - packing vs unpacking - How
*argsand**kwargsappear in__code__.co_varnamesandco_flags - The 5 parameter types Python supports, in their required order
- How to combine
*argswith explicit positional parameters - The forwarding pattern: passing
*argsand**kwargsthrough to another function - How to unpack sequences and mappings at call sites
- Real-world patterns: logging wrappers, retry decorators, mock stubs, API adapters
- Anti-patterns: using
**kwargsto avoid thinking about interface design
Prerequisites
- Python 3.8+ installed
- Comfortable defining and calling functions (
def, positional args, return) - Basic understanding of tuples and dicts
Mental Model: Packing vs Unpacking
The * and ** symbols are overloaded. Their meaning flips depending on where they appear.
| Context | Syntax | Effect |
|---|---|---|
| PACKING - function definition | def func(*args, **kwargs) | *args collects extra positional args into a tuple; **kwargs collects extra keyword args into a dict |
| UNPACKING - function call | func(*my_list, **my_dict) | *my_list spreads list items as positional args; **my_dict spreads dict items as keyword args |
Part 1 - *args: Collecting Positional Arguments
Basic Usage
def total(*args):
"""Sum any number of positional arguments."""
return sum(args)
print(total(1, 2, 3)) # 6
print(total(10, 20)) # 30
print(total()) # 0
print(total(1, 2, 3, 4, 5)) # 15
Inside the function body, args is a plain tuple - immutable, iterable, indexable.
def inspect_args(*args):
print(f"type : {type(args)}")
print(f"value: {args}")
print(f"len : {len(args)}")
if args:
print(f"first: {args[0]}")
print(f"last : {args[-1]}")
inspect_args(10, "hello", True, [1, 2])
# type : <class 'tuple'>
# value: (10, 'hello', True, [1, 2])
# len : 4
# first: 10
# last : [1, 2]
Combining *args with Explicit Parameters
Explicit parameters consume arguments first; remaining ones go into args:
def log(level, *messages):
"""Log one or more messages at a given level."""
for msg in messages:
print(f"[{level.upper()}] {msg}")
log("info", "Server started")
# [INFO] Server started
log("warning", "Disk usage high", "CPU at 90%", "Memory at 85%")
# [WARNING] Disk usage high
# [WARNING] CPU at 90%
# [WARNING] Memory at 85%
log("debug")
# (no output - messages is an empty tuple)
:::note Named by Convention, Not by Keyword
The name args is purely conventional. Python only cares about the *. You could write def func(*items) or def func(*values) and it works identically. Use *args in generic functions; use a descriptive name like *numbers or *files when the type is specific.
:::
Part 2 - **kwargs: Collecting Keyword Arguments
Basic Usage
def create_user(**kwargs):
"""Create a user from arbitrary keyword arguments."""
print(f"type : {type(kwargs)}")
print(f"value: {kwargs}")
for key, value in kwargs.items():
print(f" {key} = {value!r}")
create_user(name="Alice", role="admin", active=True)
# type : <class 'dict'>
# value: {'name': 'Alice', 'role': 'admin', 'active': True}
# name = 'Alice'
# role = 'admin'
# active = True
kwargs is a plain dict - mutable, ordered (CPython 3.7+), iterable.
Combining Explicit Parameters with **kwargs
def render_tag(tag, **attrs):
"""Render an HTML tag with arbitrary attributes."""
attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return f"<{tag} {attr_str}>" if attr_str else f"<{tag}>"
print(render_tag("a", href="/home", id="home-link"))
# <a href="/home" id="home-link">
print(render_tag("br"))
# <br>
print(render_tag("input", type="text", placeholder="Enter name", required="true"))
# <input type="text" placeholder="Enter name" required="true">
:::tip Underscores for Reserved Words
Python keywords like class and for cannot be used as keyword argument names. A common convention is to append an underscore: class_, for_, id_. Some libraries strip the trailing underscore when processing the dict. Example: render_tag("div", class_="container").
:::
Part 3 - CPython Internals: co_flags and co_varnames
When Python compiles a function with *args or **kwargs, it sets flags in the code object's co_flags bitmask.
import dis
def no_variadic(a, b):
return a + b
def with_args(a, *args):
return a
def with_kwargs(a, **kwargs):
return a
def with_both(*args, **kwargs):
return args, kwargs
# CO_VARARGS = 0x04 (function uses *args)
# CO_VARKEYWORDS = 0x08 (function uses **kwargs)
print(hex(no_variadic.__code__.co_flags & 0x0C)) # 0x0 - neither
print(hex(with_args.__code__.co_flags & 0x0C)) # 0x4 - CO_VARARGS
print(hex(with_kwargs.__code__.co_flags & 0x0C)) # 0x8 - CO_VARKEYWORDS
print(hex(with_both.__code__.co_flags & 0x0C)) # 0xc - both
The co_varnames tuple always lists variables in this order:
positional params → *args name → keyword-only params → **kwargs name → local variables.
def func(a, b, *args, kw_only=False, **kwargs):
local_var = 42
return local_var
print(func.__code__.co_varnames)
# ('a', 'b', 'args', 'kw_only', 'kwargs', 'local_var')
And how many args are positional vs keyword-only:
print(func.__code__.co_argcount) # 2 - (a, b)
print(func.__code__.co_kwonlyargcount) # 1 - (kw_only)
# *args and **kwargs are not counted in either - they are separate
The disassembly of packing itself is done by the CPython call machinery in ceval.c - not by bytecode inside the function. By the time the first instruction of your function runs, args is already a fully-formed tuple and kwargs is already a dict.
def demo(*args, **kwargs):
return args, kwargs
dis.dis(demo)
# 2 0 LOAD_FAST 0 (args)
# 2 LOAD_FAST 1 (kwargs)
# 4 BUILD_TUPLE 2
# 6 RETURN_VALUE
Part 4 - The 5 Parameter Types in Order
Python 3 supports five distinct parameter types. They must appear in this exact order:
def func(pos_only, /, pos_or_kw, *args, kw_only, **kwargs):
# ──────── ───────── ──── ──────── ────────
# 1 2 3 4 5
| # | Parameter type | Syntax position | Behavior |
|---|---|---|---|
| 1 | Positional-only | Before / (Python 3.8+) | Cannot be passed by keyword |
| 2 | Positional-or-keyword | Standard parameters | Can be passed either way |
| 3 | *args (var-positional) | *args | Collects extra positional args into a tuple |
| 4 | Keyword-only | After * | Must be passed by name |
| 5 | **kwargs (var-keyword) | **kwargs | Collects extra keyword args into a dict |
Type 1: Positional-Only (/)
def point(x, y, /):
"""x and y can only be passed positionally."""
return (x, y)
print(point(1, 2)) # (1, 2) OK
# point(x=1, y=2) # TypeError - cannot use keyword syntax
Used in many built-in functions: len(obj) - you cannot write len(obj=mylist).
Type 2: Positional-or-Keyword (standard)
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
greet("Alice") # positional
greet("Alice", "Hi") # positional
greet(name="Alice", greeting="Hi") # keyword
greet("Alice", greeting="Hi") # mixed
Type 3: *args - Variable Positional
As covered above - captures all remaining positional arguments as a tuple.
Type 4: Keyword-Only (after * or *args)
Parameters after *args (or a bare *) can only be passed by keyword:
def connect(host, port, *, timeout=30, retries=3):
"""timeout and retries are keyword-only."""
print(f"Connecting to {host}:{port} (timeout={timeout}, retries={retries})")
connect("db.example.com", 5432)
# Connecting to db.example.com:5432 (timeout=30, retries=3)
connect("db.example.com", 5432, timeout=10)
# Connecting to db.example.com:5432 (timeout=10, retries=3)
# connect("db.example.com", 5432, 10)
# TypeError: connect() takes 2 positional arguments but 3 were given
This pattern forces callers to be explicit. connect(..., timeout=10) is self-documenting; connect(..., 10) is ambiguous.
Type 5: **kwargs - Variable Keyword
As covered above - captures all remaining keyword arguments as a dict.
Full Combination Example
def full_example(
pos_only, /, # type 1: positional-only
pos_or_kw, # type 2: positional-or-keyword
*args, # type 3: var-positional
kw_only, # type 4: keyword-only (required)
optional_kw=42, # type 4: keyword-only (with default)
**kwargs # type 5: var-keyword
):
print(f"pos_only = {pos_only}")
print(f"pos_or_kw = {pos_or_kw}")
print(f"args = {args}")
print(f"kw_only = {kw_only}")
print(f"optional_kw = {optional_kw}")
print(f"kwargs = {kwargs}")
full_example(1, 2, 3, 4, 5, kw_only="required", extra="value")
# pos_only = 1
# pos_or_kw = 2
# args = (3, 4, 5)
# kw_only = required
# optional_kw = 42
# kwargs = {'extra': 'value'}
Part 5 - Unpacking at Call Sites
* Unpacking a Sequence
def add(a, b, c):
return a + b + c
numbers = [10, 20, 30]
print(add(*numbers)) # 60 - equivalent to add(10, 20, 30)
# Works with any iterable
print(add(*range(1, 4))) # 6
print(add(*(1, 2, 3))) # 6
# Mix positional and unpacked
print(add(1, *[2, 3])) # 6
** Unpacking a Dict
def create_server(host, port, debug=False):
print(f"Starting server at {host}:{port} (debug={debug})")
config = {"host": "localhost", "port": 8080, "debug": True}
create_server(**config)
# Starting server at localhost:8080 (debug=True)
# Config-driven function calls - switch between environments
PRODUCTION = {"host": "0.0.0.0", "port": 443, "debug": False}
DEVELOPMENT = {"host": "127.0.0.1", "port": 8080, "debug": True}
env = "development"
settings = DEVELOPMENT if env == "development" else PRODUCTION
create_server(**settings)
# Starting server at 127.0.0.1:8080 (debug=True)
Multiple Unpacking (Python 3.5+ - PEP 448)
# Merge sequences
first = [1, 2, 3]
second = [4, 5, 6]
combined = [*first, *second]
print(combined) # [1, 2, 3, 4, 5, 6]
# Merge dicts - later keys win
base = {"a": 1, "b": 2}
override = {"b": 99, "c": 3}
merged = {**base, **override}
print(merged) # {'a': 1, 'b': 99, 'c': 3}
:::tip Dict Merging Pattern
{**base, **override} is the idiomatic way to merge dicts with precedence. In Python 3.9+, you can also use base | override. Both produce a new dict; neither mutates the originals. In function calls, duplicate keyword arguments always raise TypeError - you cannot spread two dicts that share a key into the same call.
:::
Part 6 - The Forwarding Pattern
The most critical production use of *args and **kwargs is perfect forwarding - passing all arguments through a wrapper to another function without knowing what those arguments are.
Basic Forwarding
def log_call(func, *args, **kwargs):
"""Call func with all given args/kwargs, logging before and after."""
print(f"CALL: {func.__name__}(args={args}, kwargs={kwargs})")
result = func(*args, **kwargs)
print(f" -> returned {result!r}")
return result
def add(a, b, c=0):
return a + b + c
log_call(add, 1, 2)
# CALL: add(args=(1, 2), kwargs={})
# -> returned 3
log_call(add, 1, 2, c=10)
# CALL: add(args=(1, 2), kwargs={'c': 10})
# -> returned 13
Decorator Forwarding - The Standard Pattern
This is the backbone of every Python decorator that wraps a function:
import functools
import time
def timed(func):
"""Decorator: measure and print how long func takes to execute."""
@functools.wraps(func)
def wrapper(*args, **kwargs): # capture everything the caller passes
start = time.perf_counter()
result = func(*args, **kwargs) # forward everything to the original
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timed
def fetch_data(url, *, timeout=30, retries=3):
time.sleep(0.01) # simulate network call
return f"data from {url}"
result = fetch_data("https://api.example.com/users", timeout=10)
# fetch_data took 0.0101s
print(result)
# data from https://api.example.com/users
Without *args, **kwargs, the wrapper would need to know every parameter of every function it wraps - making it useless as a general-purpose decorator.
Retry Decorator
import functools
import time
import random
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
"""Retry a function up to max_attempts times on specified exceptions."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.1, exceptions=(ConnectionError,))
def connect_to_db(host, port, *, database):
if random.random() < 0.6: # simulate 60% failure rate
raise ConnectionError(f"Cannot reach {host}:{port}")
return f"Connected to {database} at {host}:{port}"
try:
result = connect_to_db("db.local", 5432, database="production")
print(result)
except ConnectionError as e:
print(f"All retries exhausted: {e}")
The wrapper inside retry forwards *args and **kwargs to func without knowing or caring what parameters connect_to_db expects. The retry decorator is completely reusable across any function signature.
Part 7 - API Adapter Pattern
When integrating two APIs with different signatures, *args and **kwargs bridge them cleanly:
# Legacy API: positional-heavy, old naming conventions
def legacy_send_email(to_addr, from_addr, subject, body, priority):
print(f"Legacy: '{subject}' to {to_addr} (priority={priority})")
# New API: keyword-only, modern naming
def new_send_email(*, recipient, sender, subject, content, importance=5):
print(f"New: '{subject}' to {recipient} (importance={importance})")
# Adapter: translate new-style kwargs to legacy positional call
def send_email_adapter(**kwargs):
"""Accept new-style kwargs, call legacy function."""
legacy_send_email(
to_addr = kwargs["recipient"],
from_addr = kwargs["sender"],
subject = kwargs["subject"],
body = kwargs["content"],
priority = kwargs.get("importance", 5),
)
send_email_adapter(
subject="Welcome",
content="Welcome to the platform.",
)
# Legacy: 'Welcome' to [email protected] (priority=5)
Part 8 - Mock and Stub Functions
In testing, *args and **kwargs make it trivial to write stubs that accept any interface:
def make_stub(return_value=None, side_effect=None):
"""Create a stub function that records all calls made to it."""
calls = []
def stub(*args, **kwargs):
calls.append({"args": args, "kwargs": kwargs})
if side_effect is not None:
raise side_effect
return return_value
stub.calls = calls
return stub
# In a test
send_email = make_stub(return_value={"status": "sent"})
# Simulate calling code under test
print(send_email.calls[0])
# {'args': ('[email protected]',), 'kwargs': {'subject': 'Test'}}
print(len(send_email.calls))
# 2
Part 9 - Anti-Patterns
Anti-Pattern 1: Using **kwargs to Avoid Thinking About API Design
# BAD: what parameters does this actually accept?
def create_report(**kwargs):
title = kwargs.get("title", "Untitled")
rows = kwargs.get("rows", [])
fmt = kwargs.get("format", "pdf")
author = kwargs.get("author", "")
# ... 10 more .get() calls
This API is opaque. Callers cannot use IDE tab-completion. There is no type checking at the call site. Typos in parameter names silently produce None instead of a TypeError.
# GOOD: explicit parameters, real defaults, real type hints
def create_report(
title: str = "Untitled",
rows: list[dict] | None = None,
format: str = "pdf",
*,
author: str = "",
page_size: str = "A4",
) -> bytes:
rows = rows or []
...
Anti-Pattern 2: Silently Swallowing **kwargs
# BAD: kwargs is accepted but never forwarded or validated
def setup(verbose=False, **kwargs):
configure_system(verbose=verbose)
# kwargs is ignored - caller typos raise no error
setup(verbose=True, debgu=True) # typo "debgu" silently ignored
If your function accepts **kwargs but does not forward or inspect it, remove **kwargs entirely.
Anti-Pattern 3: Mutating kwargs to Add Hidden Arguments
# BAD: mutating kwargs to inject values looks like magic
def process(**kwargs):
kwargs["injected"] = "secret"
forward(**kwargs)
# BETTER: be explicit
def process(**kwargs):
forward(**kwargs, injected="secret") # visible at the call site
:::warning kwargs Is a Fresh Dict Per Call
The dict you receive as **kwargs is a new dict created for each call - not a reference to any structure the caller has. Mutating it does not affect the caller. But it can confuse readers who expect kwargs to represent only what the caller passed.
:::
Interview Questions
Q1: What is the difference between *args in a definition vs *seq in a call?
Answer: In a function definition, *args is the packing operator - it collects all extra positional arguments into a single tuple. In a function call, *seq is the unpacking operator - it spreads the items of an iterable as individual positional arguments. Same symbol, opposite operations. def f(*args) packs; f(*my_list) unpacks.
Q2: What does CPython record in co_flags when a function uses *args or **kwargs?
Answer: CPython uses a bitmask in the code object's co_flags. CO_VARARGS = 0x04 is set when the function has *args; CO_VARKEYWORDS = 0x08 is set when it has **kwargs. Both flags set means co_flags & 0x0C == 0x0C. These flags tell the interpreter how to build the function's local namespace at call time - the actual packing happens in CPython's call machinery, not inside the function body's bytecode.
Q3: What is the required order of the 5 Python parameter types?
Answer: The required order is:
- Positional-only parameters (before
/) - Python 3.8+ - Positional-or-keyword parameters - standard params
*args(var-positional) - or bare*to start keyword-only without capturing- Keyword-only parameters (after
*or*args) **kwargs(var-keyword) - must always come last
Any deviation raises SyntaxError. For example, **kwargs before *args is illegal, and keyword-only parameters cannot appear before *args.
Q4: How does argument forwarding work in decorators, and why is it necessary?
Answer: A decorator wraps a function in a new wrapper. For the wrapper to pass all arguments through without knowing the wrapped function's signature, the wrapper must be defined as def wrapper(*args, **kwargs). This captures every positional and keyword argument the caller passes. Then func(*args, **kwargs) unpacks and forwards them. Without this, every decorator would need to replicate the exact signature of every function it wraps - making generic decorators impossible.
Q5: What happens when a caller passes a keyword argument that is already in a **-unpacked dict?
Answer: It raises TypeError. For example:
def func(**kwargs): pass
d = {"name": "Alice"}
func(**d, name="Bob")
# TypeError: func() got multiple values for keyword argument 'name'
The interpreter does not allow two sources to supply the same keyword. This also means that {**base, **override} in a dict literal works fine (later keys win), but func(**base, **override) with shared keys is always a TypeError.
Q6: When would you use a bare * instead of *args in a function signature?
Answer: A bare * (no name after it) marks the start of keyword-only parameters without capturing any extra positional arguments. def f(a, b, *, timeout=30) forces callers to pass timeout by name, and raises TypeError if they pass more than two positional arguments. With *args, extra positional arguments would be silently captured into args. Use bare * when you want to enforce keyword-only usage but do not need a catch-all for extra positional arguments.
Practice Challenges
Beginner: Flexible Sum
Write a function flexible_sum that accepts any number of positional numeric arguments and a keyword-only start argument (default 0). It returns the sum of all positional arguments plus start.
flexible_sum(1, 2, 3) # 6
flexible_sum(1, 2, 3, start=10) # 16
flexible_sum(start=100) # 100
Solution
def flexible_sum(*args, start=0):
"""Sum all positional arguments, then add start."""
return start + sum(args)
# Tests
print(flexible_sum(1, 2, 3)) # 6
print(flexible_sum(1, 2, 3, start=10)) # 16
print(flexible_sum(start=100)) # 100
print(flexible_sum()) # 0
# Works with unpacking too
numbers = [5, 10, 15]
print(flexible_sum(*numbers, start=5)) # 35
Key point: *args automatically makes everything after it keyword-only. start cannot be passed positionally - which is the correct behavior for an optional modifier.
Intermediate: General-Purpose Logging Decorator
Write a decorator log_calls that:
- Works on any function with any signature
- Prints the function name, args, and kwargs before calling
- Prints the return value after calling
- Preserves the original function's
__name__and__doc__
@log_calls
def create_user(username, email, *, role="viewer", active=True):
return {"username": username, "email": email, "role": role}
# CALL: create_user(args=('alice', '[email protected]'), kwargs={'role': 'admin'})
# RETURN: {'username': 'alice', 'email': '[email protected]', 'role': 'admin'}
Solution
import functools
def log_calls(func):
"""Decorator that logs every call and return value of func."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"CALL: {func.__name__}(args={args}, kwargs={kwargs})")
result = func(*args, **kwargs)
print(f"RETURN: {result!r}")
return result
return wrapper
@log_calls
def create_user(username, email, *, role="viewer", active=True):
"""Create a new user record."""
return {"username": username, "email": email, "role": role, "active": active}
@log_calls
def add(a, b, c=0):
"""Add two or three numbers."""
return a + b + c
# CALL: create_user(args=('alice', '[email protected]'), kwargs={'role': 'admin'})
# RETURN: {'username': 'alice', 'email': '[email protected]', 'role': 'admin', 'active': True}
add(1, 2, c=3)
# CALL: add(args=(1, 2), kwargs={'c': 3})
# RETURN: 6
# functools.wraps preserves these:
print(create_user.__name__) # create_user (not 'wrapper')
print(create_user.__doc__) # Create a new user record.
Key points: @functools.wraps(func) copies __name__, __doc__, and __annotations__ to the wrapper. The wrapper(*args, **kwargs) + func(*args, **kwargs) pattern is completely generic - it works on any function with any signature.
Advanced: Configuration-Driven Function Dispatcher
Build a Dispatcher class that:
- Registers functions under string names via
@dispatcher.register("name") - Calls registered functions by name:
dispatcher.call("name", *args, **kwargs) - Merges a base config dict with per-call overrides:
dispatcher.call_with_config("name", base_config, *args, **overrides) - Lists all registered names:
dispatcher.list()
Solution
import functools
class Dispatcher:
"""A registry mapping string names to callable functions."""
def __init__(self):
self._registry: dict[str, callable] = {}
def register(self, name: str):
"""Decorator: register a function under the given name."""
def decorator(func):
self._registry[name] = func
return func # return unchanged so function still works normally
return decorator
def call(self, name: str, *args, **kwargs):
"""Call the function registered under name with args and kwargs."""
if name not in self._registry:
raise KeyError(
f"No function registered under {name!r}. "
f"Available: {self.list()}"
)
return self._registry[name](*args, **kwargs)
def call_with_config(self, name: str, base_config: dict, *args, **overrides):
"""
Call name(*args, **merged) where merged = base_config | overrides.
Per-call overrides take precedence over base_config.
"""
merged = {**base_config, **overrides}
return self.call(name, *args, **merged)
def list(self) -> list[str]:
"""Return a list of all registered function names."""
return list(self._registry.keys())
# --- Demo ---
d = Dispatcher()
@d.register("greet")
def greet(name, *, greeting="Hello"):
return f"{greeting}, {name}!"
@d.register("compute")
def compute(a, b, *, operation="add"):
ops = {"add": a + b, "mul": a * b, "sub": a - b}
return ops[operation]
@d.register("format_record")
def format_record(record: dict, *, separator=", ", uppercase=False):
text = separator.join(f"{k}={v}" for k, v in record.items())
return text.upper() if uppercase else text
# Basic calls
print(d.call("greet", "Alice")) # Hello, Alice!
print(d.call("greet", "Alice", greeting="Hi")) # Hi, Alice!
print(d.call("compute", 10, 20, operation="mul")) # 200
# Config-driven calls - base_config provides defaults; overrides win
base = {"operation": "add"}
print(d.call_with_config("compute", base, 5, 3)) # 8
print(d.call_with_config("compute", base, 5, 3, operation="sub")) # 2
record_config = {"separator": " | ", "uppercase": False}
record = {"id": 1, "name": "Alice", "role": "admin"}
print(d.call_with_config("format_record", record_config, record))
# id=1 | name=Alice | role=admin
print(d.call_with_config("format_record", record_config, record, uppercase=True))
# ID=1 | NAME=ALICE | ROLE=ADMIN
print(d.list())
# ['greet', 'compute', 'format_record']
# Error handling
try:
d.call("missing")
except KeyError as e:
print(e)
# "No function registered under 'missing'. Available: ['greet', 'compute', 'format_record']"
Key design points:
registeris a decorator factory - returns a decorator that capturesnamein a closure.calluses*args, **kwargsto forward everything to the registered function.call_with_configuses*argsfor positional pass-through and**overridesfor keyword overrides.{**base_config, **overrides}merges them so per-call values win.- This is the pattern used by Flask's
@app.route(), Celery's@app.task(), and pytest's fixture registry.
Quick Reference
| Syntax | Location | Meaning |
|---|---|---|
def f(*args) | Definition | Pack extra positional args into tuple args |
def f(**kwargs) | Definition | Pack extra keyword args into dict kwargs |
f(*seq) | Call | Unpack sequence seq as positional arguments |
f(**mapping) | Call | Unpack mapping as keyword arguments |
def f(a, *args) | Definition | a takes first positional; rest go to args |
def f(*args, kw) | Definition | kw is keyword-only (must be passed by name) |
def f(a, /, b) | Definition | a is positional-only; b is positional-or-keyword |
def f(a, b, *) | Definition | Bare * - a, b positional; nothing after captured |
[*a, *b] | Literal | Merge two lists into one (PEP 448) |
{**a, **b} | Literal | Merge two dicts; later keys win |
co_flags & 0x04 | Bytecode | CO_VARARGS - function has *args |
co_flags & 0x08 | Bytecode | CO_VARKEYWORDS - function has **kwargs |
functools.wraps | Decorator | Copy __name__, __doc__ from wrapped to wrapper |
Parameter order in signatures (fixed rule):
positional-only / positional-or-keyword *args keyword-only **kwargs
1 2 3 4 5
Key Takeaways
*and**do opposite things: packing in definitions, unpacking in calls*argsis always a tuple;**kwargsis always a dict inside the function body- CPython encodes variadic parameters as bit flags in
co_flags(CO_VARARGS = 0x04,CO_VARKEYWORDS = 0x08) - Python has 5 parameter types that must appear in strict order; any deviation raises
SyntaxError - The
wrapper(*args, **kwargs)+func(*args, **kwargs)pattern is the foundation of every general-purpose Python decorator {**base_config, **overrides}is the idiomatic way to merge configuration dicts with per-call precedence- A bare
*enforces keyword-only arguments without accepting extra positional ones - Using
**kwargsto avoid designing a real API is an anti-pattern - explicit parameters are self-documenting and enable IDE tooling and type checking
