Defining a Function with Multiple Arguments in Python: Complete Parameter Types and Function Signature Guide

Defining a function with multiple arguments in python

Somewhere around my second year of writing Python professionally, I realized I’d been using maybe 30% of what function signatures could actually express. I knew positional arguments and basic defaults, but positional-only parameters, keyword-only parameters, and the interplay between *args and **kwargs were things I’d copy-pasted without fully understanding. Once I sat down and actually mapped out every parameter type Python supports, writing flexible, self-documenting function signatures became one of my favorite parts of the language. This guide is that map.

The Building Blocks: Positional and Keyword Arguments

The most basic multi-argument function looks like this:

def describe_person(name, age, city):
    print(f"{name} is {age} years old and lives in {city}.")

describe_person("Ali", 28, "Lahore")
describe_person(name="Sara", age=25, city="Karachi")
describe_person("Bilal", city="Islamabad", age=30)

All three calls work identically. Positional arguments are matched by order; keyword arguments are matched by name and can appear in any order once you use the name=value form. You can mix the two, but positional arguments must come before keyword arguments in the call.

Default Argument Values

Adding defaults makes some arguments optional:

def describe_person(name, age, city="Unknown"):
    print(f"{name} is {age} years old and lives in {city}.")

describe_person("Ali", 28)
describe_person("Sara", 25, "Karachi")

A rule I internalized early: parameters with default values must come after parameters without defaults in the function definition — def f(a, b=1, c) is a SyntaxError.

*args: Variable Number of Positional Arguments

When I don’t know in advance how many positional arguments a caller might pass, *args collects them into a tuple:

def total(*numbers):
    return sum(numbers)

print(total(1, 2, 3))       # 6
print(total(4, 5, 6, 7, 8)) # 30
print(total())               # 0

Inside the function, numbers is a plain tuple, so all tuple operations apply — iteration, indexing, unpacking.

**kwargs: Variable Number of Keyword Arguments

**kwargs collects arbitrary keyword arguments into a dictionary:

def build_profile(**details):
    for key, value in details.items():
        print(f"{key}: {value}")

build_profile(name="Ali", age=28, city="Lahore")

Output:

name: Ali
age: 28
city: Lahore

I use **kwargs constantly for functions that need to accept flexible, optional configuration without cluttering the signature with a dozen named parameters.

Combining Everything: The Canonical Parameter Order

Python enforces a strict order when combining these parameter types:

def func(pos_only, /, pos_or_kw, *args, kw_only, **kwargs):
    pass

Reading left to right:

  1. Positional-only parameters (before /)
  2. Positional-or-keyword parameters (the default kind)
  3. *args for extra positional arguments
  4. Keyword-only parameters (after *args or after a bare *)
  5. **kwargs for extra keyword arguments

Here’s a full example combining all five:

def register_user(user_id, /, name, *tags, role="member", **extra_info):
    print(f"ID: {user_id}, Name: {name}, Tags: {tags}, Role: {role}, Extra: {extra_info}")

register_user(101, "Ali", "python", "backend", role="admin", team="Platform")

Output:

ID: 101, Name: Ali, Tags: ('python', 'backend'), Role: admin, Extra: {'team': 'Platform'}

Breaking this down: user_id can only be passed positionally (because of /), name can be passed either way, "python" and "backend" fall into *tags since they’re extra positional arguments, role must be passed by keyword (because it comes after *tags), and team="Platform" falls into **extra_info since it’s not a named parameter.

Positional-Only Parameters (/)

Introduced formally as syntax in Python 3.8 via PEP 570, the / marker forces everything before it to be positional-only — callers cannot use the parameter name as a keyword.

def divide(a, b, /):
    return a / b

print(divide(10, 2))       # works
# print(divide(a=10, b=2))  # TypeError: divide() got some positional-only arguments passed as keyword arguments

I use this mainly when writing library-style functions where I want the freedom to rename parameters later without breaking callers who might otherwise depend on the keyword name — many built-in functions like len() and abs() already work this way internally.

Keyword-Only Parameters (*)

A bare * in the signature (without a name) forces everything after it to be keyword-only:

def create_user(name, *, email, is_admin=False):
    print(f"{name}, {email}, admin={is_admin}")

create_user("Ali", email="ali@example.com")
# create_user("Ali", "ali@example.com")  # TypeError: missing 1 required keyword-only argument: 'email'

I reach for this whenever a function has several boolean or configuration-style parameters where positional calls would be ambiguous or error-prone — forcing keywords makes call sites self-documenting.

def resize_image(path, *, width, height, keep_aspect_ratio=True):
    ...

resize_image("photo.jpg", width=800, height=600)

Without keyword-only enforcement, resize_image("photo.jpg", 800, 600) is ambiguous to a reader without checking the function definition; with it, the call site is unambiguous.

Unpacking Arguments at the Call Site

Multiple-argument functions pair naturally with the unpacking operators * and ** at the call site:

def add(a, b, c):
    return a + b + c

values = [1, 2, 3]
print(add(*values))  # unpacks list into positional args -> 6

kwargs = {"a": 10, "b": 20, "c": 30}
print(add(**kwargs))  # unpacks dict into keyword args -> 60

I use this constantly when forwarding arguments through wrapper functions or decorators, and when calling functions dynamically based on data read from a config file or API response.

Internal Working: How Python Binds Arguments

When a function is called, CPython’s argument-binding logic (implemented in the interpreter’s call machinery) walks through positional arguments first, filling parameters left to right, then applies keyword arguments by matching names, then checks that all required parameters (without defaults) received a value, and finally routes any leftovers into *args/**kwargs if present — otherwise raising TypeError for unexpected arguments. You can inspect this process yourself using the inspect module:

import inspect

def sample(a, b=2, *args, c, **kwargs):
    pass

sig = inspect.signature(sample)
print(sig)
for name, param in sig.parameters.items():
    print(name, param.kind, param.default)

Output:

(a, b=2, *args, c, **kwargs)
a POSITIONAL_OR_KEYWORD <class 'inspect._empty'>
b POSITIONAL_OR_KEYWORD 2
args VAR_POSITIONAL <class 'inspect._empty'>
c KEYWORD_ONLY <class 'inspect._empty'>
kwargs VAR_KEYWORD <class 'inspect._empty'>

Seeing ParameterKind enumerated explicitly like this made the whole parameter-order rule feel far less arbitrary — it’s literally how the interpreter classifies and processes each parameter.

Real-World Use Cases

  • Configurable utility functions, where **kwargs passes through options to an underlying library call (a pattern used heavily in requests, matplotlib, and Django’s ORM).
  • API client wrappers, where keyword-only parameters make required options like api_key unambiguous.
  • Mathematical or aggregation functions, where *args allows a variable number of numeric inputs, similar to the built-in max() and min().
  • Builder-style constructors, combining defaults and keyword-only parameters for readable, self-documenting object creation.

Best Practices and Common Mistakes

  • Use keyword-only parameters for any boolean flags or easily-confused same-type parameters to prevent silent misordering bugs.
  • Don’t overuse **kwargs in public APIs — it hides the real accepted parameters from tools like help() and IDE autocomplete unless you document them carefully.
  • Remember *args and **kwargs are just tuple/dict; you can iterate, unpack, or validate their contents like any other tuple/dict.
  • Keep parameter counts manageable — if a function needs more than five or six arguments, consider grouping related ones into a dataclass or configuration object instead.
  • Always put parameters without defaults before parameters with defaults, and check this order carefully when refactoring an existing function signature.

Troubleshooting Tips

If you get SyntaxError: non-default argument follows default argument, reorder your parameters so defaulted ones come last.

If you get TypeError: func() got multiple values for argument 'x', you likely passed the same parameter both positionally and by keyword in the same call.

If keyword arguments aren’t being accepted where you expect, check whether the parameter is defined before a / (positional-only) — it cannot be passed by keyword at all in that case.

FAQs

What’s the difference between *args and positional-only parameters (/)? *args collects an arbitrary number of extra positional arguments into a tuple; positional-only parameters are specific, named parameters that simply cannot be passed by keyword.

Can a function have both *args and named keyword-only parameters? Yes — anything after *args in the signature is automatically keyword-only, no separate bare * is needed in that case.

Is there a limit to how many arguments a Python function can accept? Practically, no meaningful limit for everyday use, though CPython does have an internal limit on the number of arguments in a single call (in the hundreds), which is far beyond what any reasonably designed function should need.

Why would I use / for positional-only parameters instead of just documenting it? Enforcing it at the language level prevents callers from ever depending on a parameter name that you might want to rename later, which matters for maintaining backward compatibility in libraries.

Summary

Python’s function signatures support a rich set of tools for handling multiple arguments: plain positional and keyword parameters, defaults, positional-only and keyword-only markers, and the catch-all *args/**kwargs. The canonical order — positional-only, positional-or-keyword, *args, keyword-only, **kwargs — governs how they combine. Learning to use keyword-only parameters for clarity and / for API stability changed how deliberately I design function signatures, especially for code that other people (or future me) will call without rereading the implementation every time.

References

Total
0
Shares

Leave a Reply

Previous Post
Recursive Lambda using assigned variable in python

Recursive Lambda Using Assigned Variable in Python: Complete Anonymous Recursive Function Implementation Guide

Next Post
Defining functions with list arguments in python

Defining Functions with List Arguments in Python: Complete Mutable Parameter Handling and Best Practices

Related Posts