Function Arguments — Positional, Keyword, Default, *args and **kwargs

Python’s argument system is one of its most powerful features — and one of the most important to understand before working with FastAPI, which relies heavily on keyword arguments, default values, and **kwargs-style patterns for dependency injection and request parameter handling. Python gives you four kinds of arguments: positional (the order matters), keyword (name matters, not position), default (optional with a fallback value), and variadic (*args and **kwargs for any number of arguments). Understanding when and how to use each — and the strict ordering rules that govern them — will make your FastAPI route handlers clean and expressive.

Positional and Keyword Arguments

def create_post(title, body, published):
    return {"title": title, "body": body, "published": published}

# Positional — order matters
create_post("My Title", "Post body here", True)

# Keyword — name matters, order flexible
create_post(body="Post body here", title="My Title", published=True)

# Mixed — positional must come before keyword
create_post("My Title", published=True, body="Post body here")

# This fails — positional after keyword
create_post(title="My Title", "Post body here", True)   # SyntaxError!

# Forcing keyword-only arguments (after *)
def connect(host, port, *, timeout, retries):
    # timeout and retries MUST be passed as keywords
    pass

connect("localhost", 5432, timeout=30, retries=3)   # ✓
connect("localhost", 5432, 30, 3)   # TypeError — timeout must be keyword
Note: FastAPI uses keyword argument conventions extensively. When you define a route handler like def get_post(post_id: int, db: Session = Depends(get_db)):, FastAPI inspects the parameter names and types to determine what comes from the URL path, query string, request body, or dependency injection. Getting comfortable with named parameters and type hints now will make FastAPI’s “magic” feel completely natural when you reach Part 3.
Tip: Use keyword arguments when calling functions with more than two or three parameters — it makes code self-documenting. Compare create_user("Alice", "alice@example.com", True, "admin") with create_user(name="Alice", email="alice@example.com", is_active=True, role="admin"). The second version is immediately understandable without reading the function signature. This is especially important in test code where argument meaning must be obvious.
Warning: Never use a mutable object (list, dict, set) as a default parameter value. Default values are evaluated once at function definition time, not on each call. If you mutate a default list inside the function, every subsequent call that uses the default will see the modified list. Always use None as the default and create a new mutable object inside the function body when needed.

Default Parameter Values

def get_posts(page=1, limit=10, published=True):
    print(f"Page {page}, limit {limit}, published={published}")

get_posts()                        # Page 1, limit 10, published=True
get_posts(2)                       # Page 2, limit 10, published=True
get_posts(page=3, limit=20)        # Page 3, limit 20, published=True
get_posts(1, 5, False)             # Page 1, limit 5, published=False

# Default with None (safe for mutable defaults)
def create_tag(name: str, posts: list = None):
    if posts is None:
        posts = []          # fresh list each call ✓
    return {"name": name, "posts": posts}

# Parameters with defaults MUST come after those without defaults
def bad(a=1, b):    # SyntaxError: non-default argument follows default argument
    pass

def good(a, b=1):   # ✓ non-default before default
    pass

*args — Variable Positional Arguments

# *args collects extra positional arguments into a tuple
def add_all(*numbers):
    print(type(numbers))   # <class 'tuple'>
    return sum(numbers)

add_all(1, 2, 3)           # 6
add_all(1, 2, 3, 4, 5)    # 15
add_all()                  # 0 — empty tuple

# Mix regular and *args — regular params must come first
def log(level, *messages):
    for msg in messages:
        print(f"[{level}] {msg}")

log("INFO", "Server started", "Listening on port 8000")
# [INFO] Server started
# [INFO] Listening on port 8000

# Unpack a list/tuple with * when calling
numbers = [1, 2, 3, 4, 5]
print(add_all(*numbers))   # same as add_all(1, 2, 3, 4, 5)

**kwargs — Variable Keyword Arguments

# **kwargs collects extra keyword arguments into a dict
def create_user(**fields):
    print(type(fields))   # <class 'dict'>
    return fields

create_user(name="Alice", email="alice@example.com", role="admin")
# {"name": "Alice", "email": "alice@example.com", "role": "admin"}

# Mix regular params and **kwargs
def update_post(post_id: int, **updates):
    print(f"Updating post {post_id} with: {updates}")

update_post(42, title="New Title", published=True)
# Updating post 42 with: {"title": "New Title", "published": True}

# Unpack a dict with ** when calling
data = {"name": "Alice", "email": "alice@example.com"}
create_user(**data)   # same as create_user(name="Alice", email="alice@example.com")

# FastAPI pattern: forward kwargs to another function
def base_query(db, **filters):
    query = db.query(Post)
    for field, value in filters.items():
        query = query.filter(getattr(Post, field) == value)
    return query

Argument Ordering Rules

# The correct order for all argument types:
# def func(positional, *args, keyword_only, **kwargs):

def full_example(a, b, *args, keyword_only=True, **kwargs):
    print(f"a={a}, b={b}")
    print(f"args={args}")
    print(f"keyword_only={keyword_only}")
    print(f"kwargs={kwargs}")

full_example(1, 2, 3, 4, keyword_only=False, x=10, y=20)
# a=1, b=2
# args=(3, 4)
# keyword_only=False
# kwargs={"x": 10, "y": 20}

# Positional-only parameters (Python 3.8+) — use /
def strict_pos(a, b, /):
    # a and b can ONLY be passed positionally
    pass

strict_pos(1, 2)              # ✓
strict_pos(a=1, b=2)          # TypeError — must be positional

Common Mistakes

Mistake 1 — Mutable default argument

❌ Wrong — shared mutable default persists between calls:

def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags

print(add_tag("python"))    # ["python"]
print(add_tag("fastapi"))   # ["python", "fastapi"] — unexpected!

✅ Correct — use None and initialise inside:

def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags   # ✓ fresh list every call

Mistake 2 — Wrong argument order (positional after keyword)

❌ Wrong — positional argument after keyword argument:

create_post(title="My Post", "Body text", True)   # SyntaxError

✅ Correct — keyword arguments must come after positional:

create_post("My Post", "Body text", published=True)   # ✓

Mistake 3 — Default before non-default parameter

❌ Wrong — default parameter before non-default:

def connect(timeout=30, host):   # SyntaxError

✅ Correct — non-default parameters first:

def connect(host, timeout=30):   # ✓

Quick Reference

Type Syntax Receives
Positional def f(a, b) By position
Default def f(a, b=10) Optional, has fallback
Keyword-only def f(a, *, b) Must use keyword
Variadic positional def f(*args) Tuple of extra positional
Variadic keyword def f(**kwargs) Dict of extra keyword
Unpack list f(*my_list) Spread as positional args
Unpack dict f(**my_dict) Spread as keyword args

🧠 Test Yourself

What is the output of:
def f(a, b=2, *args, c=3):
    print(a, b, args, c)
f(10, 20, 30, 40, c=99)