Skip to content
aviral gupta

// B3.3 · ~30 min · Beginner

Positional-only, keyword-only, *args and **kwargs

After this lesson you can control how each argument may be passed, write functions that accept any number of arguments, and unpack a list or dictionary straight into a call.

Lesson 3 of 5 in B3 Functions

You will be able to

  • Mark parameters positional-only with / and keyword-only with *, and predict which calls work
  • Collect extra arguments with *args, a tuple, and **kwargs, a dictionary
  • Unpack a list into positional arguments with * and a dictionary into keyword arguments with **
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B3.2: what does this print?

    def label(text, prefix="*", suffix="*"):
        return prefix + text + suffix
    
    print(label("hi", suffix="!"))
  2. Predict · Activity 2 of 7

    Predict before you read on: the parameter has a * in front of it. What does this print?

    def total(*numbers):
        result = 0
        for n in numbers:
            result += n
        return result
    
    print(total(1, 2, 3), total())
  3. Practice · Activity 3 of 7

    Fill in the marker so that port and timeout can only be passed by keyword, and connect("example.org", 8080) is refused.

    def connect(host, ____, port=80, timeout=10):
        print(host, port, timeout)
    def connect(host, , port=80, timeout=10):
  4. Practice · Activity 4 of 7

    What does this print?

    def tag(name, **attributes):
        print(name, attributes)
    
    tag("img", src="cat.png", alt="A cat")
  5. Practice · Activity 5 of 7

    Match each parameter to how it receives its value.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. A parameter with a default comes after *rest. What does this print?

    def f(a, b, *rest, c=0):
        print(a, b, rest, c)
    
    f(1, 2, 3, 4, 5)
  7. Apply · Activity 7 of 7

    Mini-task. Write average(*numbers, digits=1) that returns the mean of any number of values, rounded to digits places, and 0.0 when there are none. Try average(2, 3, 5), average(2, 3, 5, digits=3), average(), and a list unpacked with *.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A small logger

log takes a level, any number of message words and any number of name=value details, and prints them as one line. level is positional-only, so a detail may itself be called level, as the third call shows. The last call unpacks a list with * and a dictionary with **. str() turns a number into text so it can be joined.

main.py

def log(level, /, *messages, **context):
    """Print one log line: the level, the messages, then name=value pairs."""
    line = "[" + level + "]"
    for message in messages:
        line = line + " " + message
    for name in context:
        line = line + " " + name + "=" + str(context[name])
    print(line)


log("INFO", "server", "started")
log("ERROR", "upload failed", user="ada", size=12)
log("WARN", level="high")

parts = ["cache", "cleared"]
details = {"items": 42, "seconds": 0.8}
log("INFO", *parts, **details)

Run it with

python main.py

Output

[INFO] server started
[ERROR] upload failed user=ada size=12
[WARN] level=high
[INFO] cache cleared items=42 seconds=0.8
  • messages is a tuple of every word after the level, and context a dictionary of the name=value details.
  • The details come out in the order they were written in the call.
  • log("WARN", level="high") works because the first level is positional-only, so the keyword goes into context.
  • *parts and **details make the last call the same as log("INFO", "cache", "cleared", items=42, seconds=0.8).
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Add up any amount

Rewrite total in main.py so that it accepts any number of values and a keyword-only start: total() is 0, total(1, 2, 3) is 6, and total(1, 2, start=10) is 13. A list unpacked with * must work too: total(*[4, 5]) is 9. Use a for loop to add them.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Put a * in front of numbers, so it collects every positional argument into a tuple.

  2. Hint 2

    start now comes after *numbers, which makes it keyword-only by itself.

  3. Hint 3

    Start with result = start, add each n in numbers in a loop, and return result.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def total(*numbers, start=0):
    """Return start plus all the numbers."""
    result = start
    for n in numbers:
        result += n
    return result


print(total(1, 2, 3))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def total(numbers, start=0):
    """Return start plus all the numbers."""
    return start


# Try: print(total(1, 2, 3))

test_main.py

from main import total


def test_no_values():
    """total() is 0"""
    got = total()
    assert got == 0, f"total() returned {got!r}, expected 0"


def test_three_values():
    """total(1, 2, 3) is 6"""
    got = total(1, 2, 3)
    assert got == 6, f"total(1, 2, 3) returned {got!r}, expected 6"


def test_start_by_keyword():
    """total(1, 2, start=10) is 13"""
    got = total(1, 2, start=10)
    assert got == 13, f"total(1, 2, start=10) returned {got!r}, expected 13"


def test_unpacked_list():
    """total(*[4, 5]) is 9"""
    got = total(*[4, 5])
    assert got == 9, f"total(*[4, 5]) returned {got!r}, expected 9"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

An HTML tag builder

Write make_tag(name, /, *children, **attributes) that returns an HTML element as a string. The children are joined without spaces between the tags, and each attribute becomes key="value" in the opening tag, in the order given. make_tag("a", "Home", href="/") returns <a href="/">Home</a>. Because name is positional-only, make_tag("p", name="x") gives <p name="x"></p>.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Start from the signature in the prompt: name, then /, then *children and **attributes.

  2. Hint 2

    Build the opening tag in a loop: for key in attributes: add a space, the key, =" , attributes[key] and a closing ".

  3. Hint 3

    Add every child to a content string, then return opening + ">" + content + "</" + name + ">".

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def make_tag(name, /, *children, **attributes):
    """Return an HTML element as a string."""
    opening = "<" + name
    for key in attributes:
        opening = opening + " " + key + '="' + attributes[key] + '"'
    content = ""
    for child in children:
        content = content + child
    return opening + ">" + content + "</" + name + ">"


print(make_tag("a", "Home", href="/"))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def make_tag(name):
    """Return an HTML element as a string."""
    return "<" + name + "></" + name + ">"


print(make_tag("p"))

test_main.py

from main import make_tag


def test_empty_tag():
    """make_tag("br") has no content"""
    got = make_tag("br")
    assert got == "<br></br>", f"make_tag('br') returned {got!r}, expected '<br></br>'"


def test_children():
    """Children are joined inside the tag"""
    got = make_tag("p", "Hi", " there")
    assert got == "<p>Hi there</p>", f"make_tag('p', 'Hi', ' there') returned {got!r}, expected '<p>Hi there</p>'"


def test_one_attribute():
    """make_tag("a", "Home", href="/") has an href"""
    got = make_tag("a", "Home", href="/")
    assert got == '<a href="/">Home</a>', f"make_tag('a', 'Home', href='/') returned {got!r}, expected '<a href=\"/\">Home</a>'"


def test_attribute_order():
    """Attributes keep the order of the call"""
    got = make_tag("img", src="c.png", alt="cat")
    assert got == '<img src="c.png" alt="cat"></img>', f"make_tag('img', src='c.png', alt='cat') returned {got!r}"


def test_name_as_attribute():
    """name is positional-only, so name="x" is an attribute"""
    got = make_tag("p", name="x")
    assert got == '<p name="x"></p>', f"make_tag('p', name='x') returned {got!r}, expected '<p name=\"x\"></p>'"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Naming a positional-only parameter

def square(n, /):
    return n * n

print(square(n=4))

What Python prints

TypeError: square() got some positional-only arguments passed as keyword arguments: 'n'

Why, and the fix

The / after n makes it positional-only, so the call may not name it. Write square(4). If callers should be allowed to write n=4, remove the /.

Passing a keyword-only parameter by position

def connect(host, *, port=80):
    print(host, port)

connect("example.org", 8080)

What Python prints

TypeError: connect() takes 1 positional argument but 2 were given

Why, and the fix

Everything after the bare * is keyword-only, so connect accepts one positional argument, host. Name the other one: connect("example.org", port=8080). The message counts only the positional arguments.

Passing a list where separate arguments are needed

def area(width, height):
    return width * height

size = [3, 4]
print(area(size))

What Python prints

TypeError: area() missing 1 required positional argument: 'height'

Why, and the fix

area(size) passes the whole list as width, and height gets nothing. Unpack it with a star: area(*size) is the same as area(3, 4).

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

/ and * decide how arguments may be passed

In def f(a, /, b, *, c):, a comes before / and is positional-only: f(a=1, …) is refused. b can be passed either way. c comes after * and is keyword-only: it must be written c=3. Use * when a name makes the call clearer, such as connect(host, port=8080). Use / when the name has no meaning to the caller. Built-ins do both: sorted(iterable, /, *, key=None, reverse=False) takes the list by position, and key and reverse only by name.

*args and **kwargs collect the rest

A parameter written *args collects every extra positional argument into a tuple, a fixed sequence written in round brackets, such as (1, 2, 3). It is empty when there are none, and any parameter after it is keyword-only. A parameter written **kwargs collects every extra keyword argument into a dictionary: names mapped to values, such as {'src': 'cat.png'}. kwargs["src"] reads one value, and for name in kwargs: visits the names in the order of the call. Tuples and dictionaries get their own lessons in module B4; here these two uses are all you need. The names args and kwargs are only a convention.

* and ** in a call unpack

The same symbols work the other way round in a call. If the arguments are already in a list or tuple, area(*size) passes its items as separate positional arguments, so size = [3, 4] becomes area(3, 4). A dictionary with ** becomes keyword arguments: greet(**{"name": "Bo"}) is greet(name="Bo"). Without the *, area(size) passes the whole list as one argument, and height is missing.

Sources

Last reviewed September 29, 2026