Every Python developer hits the same wall eventually. You write a function, define your parameters, and then realize the number of inputs you need to handle keeps changing. That is what *args is for — and understanding how it actually works makes it one of the most useful tools in your toolkit.
This article covers the full picture: what *args is, how Python processes it internally, where the rules come from, how it interacts with keyword arguments, and what real-world applications look like across different domains.
What Python Is Actually Doing With *args
When you define a function with *args in its parameter list, you are telling Python to collect all remaining positional arguments into a single tuple and bind that tuple to the name args. The asterisk is the operator. The name args is conventional but entirely arbitrary — you could write *inputs, *values, or *whatever and the behavior would be identical.
Here is the simplest possible demonstration:
def show_me(*args):
print(type(args))
print(args)
show_me(1, 2, 3)
Output:
<class 'tuple'>
(1, 2, 3)
That is the core mechanism. Python sees the * prefix during function definition and knows: pack everything that comes in as a positional argument into a tuple. Inside the function body, args behaves exactly like any other tuple — you can iterate over it, index into it, pass it to other functions, check its length, and so on.
At the CPython level, a function defined with *args has the CO_VARARGS flag set on its code object. You can inspect this yourself: import dis; dis.dis(show_me) will show a RESUME opcode followed by LOAD_FAST referencing args — the same local variable slot any other parameter would use. The tuple construction happens at the call site, not inside the function. When CPython evaluates a call with extra positional arguments, it builds the varargs tuple from the remaining stack items before entering the function's frame. This is why args is already fully formed when your function body begins executing — there is no incremental accumulation.
The reason args is a tuple and not a list is intentional. Tuples are immutable, which signals intent: the collected arguments are for consumption, not modification. If you need to mutate the contents inside the function body, convert explicitly: args_list = list(args). The immutability also provides a minor performance advantage — tuples are cheaper to allocate than lists in CPython.
The Rules Around Parameter Order
Python enforces a strict ordering for function parameters. Understanding this ordering is essential before using *args in anything beyond a toy example.
The complete official order, as of Python 3.8 and later, is:
- Positional-only parameters (before a bare
/, if present) — introduced in Python 3.8 via PEP 570 - Standard positional-or-keyword parameters
*args(the variadic collector)- Keyword-only parameters (anything after
*argsor a bare*) **kwargs(if present)
In practice, positional-only parameters are rare in application code — they appear mostly in standard library functions and C extensions. For the purposes of this article, the ordering you will encounter almost every time is: required positionals, then *args, then keyword-only, then **kwargs.
The example from the Python documentation illustrates the middle of this ordering cleanly:
def write_multiple_items(file, separator, *args):
file.write(separator.join(args))
Here, file and separator are standard positional parameters. They must be provided before any variadic content. Everything after them gets captured by *args. When Python processes a call to this function, it satisfies file and separator first, then scoops up every remaining positional argument into the args tuple.
import io
output = io.StringIO()
write_multiple_items(output, ", ", "apple", "banana", "cherry", "date")
print(output.getvalue())
# apple, banana, cherry, date
Notice that file and separator are consumed before *args does anything. Python processes positional parameters left to right, and *args only activates once the named parameters are satisfied.
Keyword-Only Parameters: What Comes After *args
One of the less-discussed consequences of placing *args in a signature is what it does to any parameters that come after it. Once *args appears, every parameter listed to its right becomes keyword-only. Python will not accept them as positional arguments under any circumstances.
def format_data(*args, separator=", ", prefix=""):
result = separator.join(str(a) for a in args)
return prefix + result
print(format_data(1, 2, 3))
# 1, 2, 3
print(format_data(1, 2, 3, separator=" | ", prefix="Values: "))
# Values: 1 | 2 | 3
If you tried to pass separator as a positional argument here, Python would interpret it as another element of args rather than a named parameter. The keyword-only enforcement is not optional — it is a direct consequence of how the parameter ordering works.
This keyword-only enforcement is actually useful. It forces callers to be explicit about configuration options, which makes function calls easier to read at a distance.
*args Versus a List Parameter: When to Choose Which
A reasonable question is why you would use *args when you could simply require the caller to pass a list. Both approaches work, but they serve different ergonomic goals.
# List parameter approach
def total_list(numbers):
return sum(numbers)
total_list([10, 20, 30]) # caller must wrap in a list
# *args approach
def total(*numbers):
return sum(numbers)
total(10, 20, 30) # caller passes directly
The *args approach is more natural when the caller already has individual values. The list approach is more natural when the caller already has a collection. The right choice depends on how the function will actually be called.
Python also provides the unpacking operator (*) to bridge the two worlds. If you have a list and want to pass it to a *args function, unpack it at the call site:
values = [10, 20, 30]
total(*values) # unpacks the list into individual positional args
Real-World Application 1: Logging and Debugging Utilities
One of the clearest practical uses for *args is building utility functions that pass data through to other functions without knowing the full structure in advance. Logging wrappers are a perfect example.
import logging
import datetime
logging.basicConfig(level=logging.DEBUG)
def timestamped_log(level, *messages):
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
combined = " ".join(str(m) for m in messages)
full_message = f"[{timestamp}] {combined}"
if level == "info":
logging.info(full_message)
elif level == "warning":
logging.warning(full_message)
elif level == "error":
logging.error(full_message)
timestamped_log("info", "User", "john_doe", "logged in from", "192.168.1.5")
timestamped_log("error", "Connection failed:", "timeout after", 30, "seconds")
This pattern is common in production codebases. The function does not need to know how many message components it will receive — it handles one, ten, or none with the same logic.
Real-World Application 2: Mathematical Aggregation Functions
Scientific computing and data pipelines frequently require functions that operate on a variable number of inputs. Statistics utilities, custom aggregators, and metric calculators all benefit from *args.
def weighted_average(*pairs):
"""
Accept (value, weight) pairs as individual arguments.
Example: weighted_average(80, 0.3, 90, 0.5, 70, 0.2)
"""
if len(pairs) % 2 != 0:
raise ValueError("Arguments must be (value, weight) pairs")
total_weight = 0
weighted_sum = 0
for i in range(0, len(pairs), 2):
value = pairs[i]
weight = pairs[i + 1]
weighted_sum += value * weight
total_weight += weight
if total_weight == 0:
raise ValueError("Total weight cannot be zero")
return weighted_sum / total_weight
score = weighted_average(85, 0.4, 92, 0.4, 78, 0.2)
print(f"Weighted average: {score:.2f}")
# Weighted average: 86.40
The weighted_average(*pairs) interface shown above is a valid demonstration of *args, but it has a usability problem: nothing in the function signature communicates the required pairing convention. A caller who passes three arguments instead of four gets a ValueError at runtime with no guidance. In production, a cleaner interface would accept explicit tuples: weighted_average((85, 0.4), (92, 0.4), (78, 0.2)). The flat-pairs pattern is included here because it is a real pattern you will encounter and because it exercises *args in a non-trivial way, but evaluate it critically before adopting it in your own code.
def stats_summary(*values):
if not values:
return {}
return {
"count": len(values),
"sum": sum(values),
"mean": sum(values) / len(values),
"min": min(values),
"max": max(values),
"range": max(values) - min(values)
}
print(stats_summary(4, 7, 2, 9, 1, 5, 8, 3, 6))
# {'count': 9, 'sum': 45, 'mean': 5.0, 'min': 1, 'max': 9, 'range': 8}
Real-World Application 3: Function Pipelines and Transforms
Data processing pipelines often need to apply a variable number of transformations in sequence. *args makes the pipeline function itself completely generic — the caller controls how many steps run.
def apply_transforms(data, *transforms):
"""
Apply a sequence of transform functions to data.
Each transform receives the output of the previous one.
"""
result = data
for transform in transforms:
result = transform(result)
return result
# Define some transforms
def strip_whitespace(text):
return text.strip()
def to_uppercase(text):
return text.upper()
def remove_punctuation(text):
return "".join(c for c in text if c.isalnum() or c.isspace())
def collapse_spaces(text):
return " ".join(text.split())
raw = " Hello, World! This is a test. "
cleaned = apply_transforms(
raw,
strip_whitespace,
remove_punctuation,
collapse_spaces,
to_uppercase
)
print(cleaned)
# HELLO WORLD THIS IS A TEST
Because the transform list is variadic, the caller decides how many processing steps to apply. The pipeline function itself never changes.
Real-World Application 4: HTML and String Template Builders
Front-end tooling, email generators, and templating utilities frequently need to assemble dynamic content from variable numbers of components. *args handles this naturally.
def build_html_list(list_type, *items, css_class=""):
"""Build an HTML ordered or unordered list from any number of items."""
tag = "ol" if list_type == "ordered" else "ul"
class_attr = f' class="{css_class}"' if css_class else ""
item_html = "\n ".join(f"<li>{item}</li>" for item in items)
return f"<{tag}{class_attr}>\n {item_html}\n</{tag}>"
print(build_html_list("unordered", "Python", "JavaScript", "Rust", css_class="language-list"))
Output:
<ul class="language-list">
<li>Python</li>
<li>JavaScript</li>
<li>Rust</li>
</ul>
This is a pattern you will see in server-side rendering utilities, email template generators, and documentation builders. The function signature stays clean regardless of how many items get passed in.
Real-World Application 5: Database Query Builders
Dynamic SQL generation is another domain where arbitrary argument lists shine. Many ORM-style utilities need to construct queries from a variable number of conditions or fields.
def select_fields(table, *fields, where=None, limit=None):
"""
Build a basic SELECT query from variable field names.
For production use, always use parameterized queries.
"""
field_list = ", ".join(fields) if fields else "*"
query = f"SELECT {field_list} FROM {table}"
if where:
query += f" WHERE {where}"
if limit:
query += f" LIMIT {limit}"
return query + ";"
# Select all fields
print(select_fields("users"))
# SELECT * FROM users;
# Select specific fields
print(select_fields("users", "id", "username", "email"))
# SELECT id, username, email FROM users;
# With conditions
print(select_fields("orders", "order_id", "total", "status", where="status = 'pending'", limit=100))
# SELECT order_id, total, status FROM orders WHERE status = 'pending' LIMIT 100;
Never use string interpolation for actual SQL queries in production. This example demonstrates the *args pattern only. Always use parameterized queries with your database driver to prevent SQL injection.
Combining *args With **kwargs
In practice, *args and **kwargs often appear together. *args collects positional arguments into a tuple; **kwargs collects keyword arguments into a dictionary. Together they allow a function to accept absolutely anything.
def debug_call(func_name, *args, **kwargs):
print(f"Calling: {func_name}")
print(f" Positional args: {args}")
print(f" Keyword args: {kwargs}")
print()
debug_call("connect", "localhost", 5432, timeout=30, ssl=True)
Output:
Calling: connect
Positional args: ('localhost', 5432)
Keyword args: {'timeout': 30, 'ssl': True}
This pattern is widely used in decorator implementations, where a wrapper function needs to accept and forward any arguments the original function accepts:
import time
def time_it(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} completed in {elapsed:.4f}s")
return result
return wrapper
@time_it
def process_records(*records):
return [r.upper() for r in records]
process_records("alpha", "beta", "gamma", "delta")
The wrapper uses *args and **kwargs to pass through every argument it receives to the original function without knowing or caring what those arguments are.
Using functools.wraps in Production Decorators
There is one important detail the basic decorator pattern above leaves out. When you wrap a function with a closure, the wrapper replaces the original function's metadata — its __name__, __doc__, and signature all become those of the inner wrapper function. This breaks debugging, introspection, and documentation tooling.
The standard fix is functools.wraps:
import time
import functools
def time_it(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} completed in {elapsed:.4f}s")
return result
return wrapper
@time_it
def process_records(*records):
"""Process a variable number of string records."""
return [r.upper() for r in records]
print(process_records.__name__) # process_records (not 'wrapper')
print(process_records.__doc__) # Process a variable number of string records.
functools.wraps is a decorator that copies the wrapped function's __module__, __name__, __qualname__, __annotations__, and __doc__ onto the wrapper (these are defined as WRAPPER_ASSIGNMENTS in the module). It also updates — rather than replaces — the wrapper's __dict__ with entries from the original, and sets __wrapped__ so introspection tools can follow the chain back to the original. As of Python 3.12, __type_params__ is included in the copied attributes as well. Any decorator you write for production use should include it. The pattern of *args, **kwargs in the wrapper combined with @functools.wraps is so common in Python libraries that it has become idiomatic.
One practical consequence worth knowing: inspect.signature() specifically looks for the __wrapped__ attribute and follows it. This means that when you call inspect.signature(process_records) on the decorated function in the example above, it returns the signature of the original process_records — not the generic (*args, **kwargs) of the wrapper. IDEs, documentation generators, and type checkers that rely on inspect.signature will therefore show callers the correct signature automatically, with no extra work on your part.
Common Mistakes and How to Avoid Them
Expecting a list instead of a tuple. Inside the function, args is always a tuple. If you need list methods like append or sort, convert first: args_list = list(args).
Putting *args before required positional parameters. Python raises a SyntaxError if you try. The positional parameters must come first.
Forgetting that parameters after *args are keyword-only. If a caller tries to pass them positionally, Python captures them as part of args instead, which leads to confusing runtime errors rather than clean TypeError messages.
Calling str.join() on a mixed-type *args without conversion. A function like def log(*messages): return " ".join(messages) will raise a TypeError the moment a caller passes an integer or any non-string value. Because *args invites callers to pass values of any type, you should nearly always include a conversion: return " ".join(str(m) for m in messages). This is one of the most common silent bugs in *args-accepting functions.
Using *args when a named list parameter is clearer. If the caller will always have a collection to pass, requiring a list parameter is more readable than forcing them to unpack it with *. Choose the approach that matches the actual calling pattern.
Passing a generator directly when a tuple is needed. *args stores its contents eagerly as a tuple at call time. If you unpack a generator with * at the call site, the generator is fully consumed before the function body executes. This is usually intentional, but if the generator is infinite or expensive to exhaust, the call will hang or raise a MemoryError before your function runs a single line.
The Standalone * Separator
Python also allows a bare * with no name in a function signature. This is not a variadic collector — it is a marker that forces all subsequent parameters to be keyword-only, without capturing any arguments.
def create_user(username, email, *, role="viewer", active=True):
return {"username": username, "email": email, "role": role, "active": active}
create_user("jdoe", "[email protected]", role="admin") # works
create_user("jdoe", "[email protected]", "admin") # TypeError
This is useful when you want keyword enforcement without actually needing a variadic argument list. It is part of the same syntax family as *args but serves a different purpose.
Why This Feature Exists
Python's *args syntax reflects a core design philosophy: functions should be callable in the way that feels natural for the use case, not contorted to fit a rigid parameter structure. When the number of inputs is genuinely variable — not just unknown, but legitimately open-ended — forcing callers to wrap values in a container adds ceremony without value.
Variadic arguments have been part of Python since at least version 1.5. The Python 1.5 tutorial documented arbitrary argument lists under section 4.7, "More on Defining Functions," using the same tuple-based mechanism in use today. The fundamental mechanics have not changed; only the surrounding rules have been formalized over time.
The bigger evolution happened in Python 3.0. PEP 3102 — authored by Talin and titled "Keyword-Only Arguments" — formalized the behavior of any parameter appearing after *args in a function signature. Before PEP 3102, Python had no official, clean way to declare a parameter that could only be passed by name. PEP 3102 introduced two things simultaneously: the rule that all parameters following *args become keyword-only, and the bare * separator syntax that enforces keyword-only calling without capturing a variadic argument list at all.
"In particular, it enables the declaration of 'keyword-only' arguments: arguments that can only be supplied by keyword and which will never be automatically filled in by a positional argument." — PEP 3102, Abstract
A later refinement came in Python 3.8 with PEP 570 — "Python Positional-Only Parameters" — authored by Larry Hastings, Pablo Galindo Salgado, Mario Corchero, and Eric N. Vander Weele, with Guido van Rossum as BDFL-Delegate. This introduced the / separator, completing the symmetry: Python now has a way to require positional-only passing (/), a way to require keyword-only passing (*), and the variadic collector (*args) sitting between them. Together, these three markers give Python function signatures expressive power that matches or exceeds languages with far more complex type systems. Notably, the use of / as the separator was itself originally proposed by Guido van Rossum in a 2012 python-ideas thread.
"Finally, the least frequently used option is to specify that a function can be called with an arbitrary number of arguments. These arguments will be wrapped up in a tuple. Before the variable number of arguments, zero or more normal arguments may occur." — Python Documentation, section 4.9.4
Understanding *args properly means understanding why Python made this choice: function interfaces should match the natural shape of the data being passed. When that shape is variable, the interface should be too.
Putting It Together
Arbitrary argument lists are not an advanced feature reserved for library authors. They are a practical tool for any function where the number of inputs varies in a meaningful way. Once you recognize the pattern — any time you catch yourself writing logic to unpack a list the caller could have passed directly, or defining ten parameters when two and a variadic would do — *args is the right tool.
The mechanics are straightforward: the asterisk tells Python to pack remaining positional arguments into a tuple, named parameters before it are satisfied first, and anything after it must be passed as a keyword argument. Everything else — the decorator patterns, the pipeline architectures, the query builders — follows from those three rules.
Write a function that takes *args today. The first time you call it with one argument, then three, then seven, without changing the function at all, the value of this feature becomes immediately concrete.
Sources and Further Reading
Everything in this article can be verified against primary sources. The following links go directly to the official documentation and PEPs referenced throughout:
- Python Tutorial, section 4.9.4 — Arbitrary Argument Lists — the canonical introduction in the official Python docs. The quoted passage in this article is drawn directly from this section.
- PEP 3102 — Keyword-Only Arguments — authored by Talin ([email protected]); created April 22, 2006; implemented for Python 3.0. Introduced both keyword-only parameter syntax and the bare
*separator. - PEP 570 — Python Positional-Only Parameters — authored by Larry Hastings, Pablo Galindo Salgado, Mario Corchero, and Eric N. Vander Weele; created January 20, 2018; implemented for Python 3.8. Introduced the
/separator and completed the full parameter ordering specification. The/separator itself was proposed by Guido van Rossum in a python-ideas thread from March 2012. - functools.wraps — Python Standard Library Reference — documentation for the decorator that preserves wrapped function metadata. The
__wrapped__attribute was added in Python 3.2; its behavior was further refined in Python 3.4 (bpo-17482) to always refer to the wrapped function even when that function itself defines__wrapped__. The__type_params__attribute was added to the default copy set in Python 3.12. - Python Language Reference — Calls — the formal specification of how Python resolves arguments at call time, including the slot-filling algorithm described in the parameter order section of this article.
- Python 1.5 Tutorial — Arbitrary Argument Lists (section 4.7) — confirms that the tuple-based varargs mechanism has been part of Python since at least the 1.5 era, under the same section heading used in the modern tutorial.