Basics

Functions in Python: def, return, Arguments, *args and **kwargs, lambda and Type Hints

Updated: · Skillaria.Top

A function in Python is a named block of code declared with the word def, called by its name with brackets, and returns a result through return; without return a function gives back None. Arguments are passed by position or by name, *args collects extra positional values into a tuple, **kwargs collects named ones into a dictionary, and lambda creates a short anonymous function from a single expression.

A function in Python is a named piece of code that can be called as many times as you like with different input data. It is declared with the word def, takes values through parameters and gives back a result through return. Functions are the main way in Python to turn a long "wall" of script into a set of clear, testable steps.

How to declare a function: def in Python

A function declaration starts with def, followed by the name, parameters in brackets and a colon. The body is written with an indentation of four spaces — indentation in Python is not decoration but part of the syntax.

def greet(name):
    """Returns a greeting for the user."""
    return f"Hello, {name}!"

print(greet("Anna"))   # Hello, Anna!

A triple-quoted string right after the header is the docstring, the function's documentation. It is shown by help(greet) and by editor hints. By PEP 8 convention, function names are written in snake_case: add_expense, get_rate.

The line def greet(name): is called the function's signature: the name plus the list of parameters (and annotations, if any). From the signature a reader understands how to call the function without looking into its body.

What return does in Python

return ends the function and hands a value back to whoever called it. Code after an executed return does not run. If there is no return, or it is written without a value, the function returns None.

def area(w, h):
    w * h          # the result is computed and thrown away

print(area(3, 4))  # None

def area(w, h):
    return w * h

print(area(3, 4))  # 12

A forgotten return is one of the most common beginner mistakes: the function "works", but gives back None, and the error surfaces a line later. You can also return several values — in fact this is one tuple, which is convenient to unpack:

def min_max(numbers):
    return min(numbers), max(numbers)

low, high = min_max([4, 1, 9])
print(low, high)   # 1 9

An early return helps avoid extra nesting: first you handle the special cases, then you write the main logic.

def discount(price, is_member):
    if price <= 0:
        return 0
    if not is_member:
        return price
    return price * 0.9

Function arguments in Python: positional and keyword

Parameters are the names in the function declaration, and arguments are the values passed at the call. Arguments can be passed in order (positionally) or by name (as keyword arguments). Keyword arguments make a call self-documenting, and the order stops mattering.

def add_expense(title, amount, category="other"):
    return {"title": title, "amount": amount, "category": category}

add_expense("Coffee", 250)                          # positional
add_expense(amount=250, title="Coffee")             # by name
add_expense("Taxi", 600, category="transport")      # mixed

A parameter with a default value is optional. The rules: parameters with default values come after the required ones, and at the call positional arguments come before keyword ones. Otherwise Python raises a SyntaxError.

A trap: a mutable default value

A default value is evaluated once — at the moment the def line runs, not on each call. If it is a list or a dictionary, it will be shared by all calls.

def add_item(item, acc=[]):
    acc.append(item)
    return acc

print(add_item(1))  # [1]
print(add_item(2))  # [1, 2] — the list "remembers" the previous call

The correct approach is None as the default and creating a new object inside:

def add_item(item, acc=None):
    if acc is None:
        acc = []
    acc.append(item)
    return acc

*args and **kwargs: any number of arguments

An asterisk before a parameter collects all the extra positional arguments into a tuple (tuple), and two asterisks collect all the extra named arguments into a dictionary (dict). The names args and kwargs are just a convention — the asterisks are what matter.

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

print(total(100, 250, 40))   # 390

def make_user(name, **kwargs):
    user = {"name": name}
    user.update(kwargs)
    return user

print(make_user("Anna", city="Kazan", age=30))
# {'name': 'Anna', 'city': 'Kazan', 'age': 30}

The same asterisks work in the other direction — at the call they unpack a sequence or a dictionary into arguments:

values = [3, 4]
print(pow(*values))          # 81

opts = {"sep": " | ", "end": "\n"}
print("a", "b", **opts)      # a | b

Positional-only and keyword-only parameters

The slash / and the asterisk * in a parameter list work like barriers: everything before / can be passed only by position (the syntax appeared in Python 3.8, PEP 570), and everything after * only by name.

def convert(amount, /, *, rate, currency="USD"):
    return round(amount / rate, 2), currency

convert(100, rate=90.5)          # (1.1, 'USD')
convert(amount=100, rate=90.5)   # TypeError: amount can only be passed positionally
convert(100, 90.5)               # TypeError: rate can only be passed by name

Keyword-only parameters protect against confusion in calls such as convert(100, 90.5, "EUR"), where it is unclear what is what.

Scope: the LEGB rule

Python looks up a variable name along a chain: Local (inside the function) → Enclosing (in an outer function) → Global (at module level) → Built-in (built-in names such as len). Reading a global variable inside a function is allowed, but to assign it a new value you need the global declaration.

limit = 1000

def check(amount):
    return amount > limit    # reading a global — allowed

def raise_limit():
    global limit
    limit = 2000             # without global, a local variable would be created

In practice global is best avoided: a function that changes external state is hard to test. It is more reliable to accept the value as an argument and return the result.

Nested functions and closures

A function can be declared inside another function. A nested function sees the variables of the outer one even after the outer one has finished — this is a closure. To change such a variable, the nonlocal declaration is used.

def make_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

c = make_counter()
print(c(), c(), c())   # 1 2 3

Closures are the basis of decorators and function factories.

A function is also an object

In Python a function is an ordinary object: it can be put into a variable, passed to another function, returned from a function, stored in a dictionary. It has attributes: __name__, __doc__, __defaults__, __annotations__.

def double(x):
    return x * 2

f = double
print(f(5), f.__name__)                # 10 double
print(list(map(double, [1, 2, 3])))   # [2, 4, 6]

Lambda in Python: anonymous functions

A lambda function is a short function without a name, whose body consists of a single expression. The result of the expression is returned automatically; you must not write return.

square = lambda x: x ** 2
print(square(4))   # 16

expenses = [("Coffee", 250), ("Taxi", 600), ("Lunch", 450)]
expenses.sort(key=lambda e: e[1], reverse=True)
print(expenses)  # [('Taxi', 600), ('Lunch', 450), ('Coffee', 250)]

print(list(filter(lambda n: n % 2 == 0, range(10))))  # [0, 2, 4, 6, 8]

Lambda functions in Python are appropriate as a short argument for sorted, max, map, filter. PEP 8 does not recommend assigning a lambda to a variable, as square above does: if a function needs a name, write def — it will have a proper __name__ in the traceback and room for a docstring.

deflambda
Nameyesno (<lambda>)
Bodyany number of statementsone expression
returnexplicitimplicit
Docstring, annotationsyesno
Where it fitseverywherea short key or callback

Type annotations: hints, not checks

Annotations describe the expected types of parameters and the result. Python itself does not check them at run time: a function with x: int will happily accept a string. The checking is done by external tools — mypy, pyright, ty — and by the editor, which highlights errors before the code runs.

def add_expense(title: str, amount: float, category: str = "other") -> dict[str, object]:
    return {"title": title, "amount": amount, "category": category}

def area(w: float, h: float) -> float:
    w * h   # mypy: Missing return statement

You can run the check without installing anything with uvx mypy .. The example above shows the value of annotations: a forgotten return with -> float is caught by the analyser at once.

Since Python 3.12 (PEP 695), generic functions are written without TypeVar: the type parameter is given in square brackets, and a type alias is declared with the word type.

def first[T](xs: list[T]) -> T:
    return xs[0]

type Money = int | float

In Python 3.14 (PEP 649), annotations became lazy: they are evaluated on first access, so you can refer to a class declared further down without quotes, and from __future__ import annotations is no longer needed.

"Not passed" or "passed as None": the sentinel in 3.15

A default of None does not let you distinguish "the argument was not passed" from "None was passed deliberately". For years this was solved with a home-made placeholder:

_MISSING = object()

def get_rate(currency, default=_MISSING):
    rates = {"USD": 90.5}
    if currency in rates:
        return rates[currency]
    if default is _MISSING:
        raise KeyError(currency)
    return default

Python 3.15 added a built-in function sentinel (PEP 661): MISSING = sentinel("MISSING") creates a unique marker with a readable repr, which stays itself when copied, is serialised through pickle, and can be used in an annotation such as float | MISSING. On older versions the object() technique continues to work.

Common mistakes with functions

  • Forgotten return — the function returns None.
  • Forgotten brackets — print(greet) prints the function object, not the result of the call.
  • A mutable default value — one list shared across all calls.
  • Wrong number of arguments — TypeError: greet() missing 1 required positional argument: 'name'.
  • A name that clashes with a built-in — a function called list or sum shadows the built-in.

Summary

Functions in Python are declared with def, return a result with return, and accept arguments by position or by name. *args and **kwargs give flexibility, / and * give strictness of calls, lambda gives brevity where a one-off function is needed, and type annotations together with mypy catch errors before the code runs. In our Python course this topic is practised hands-on: the expense tracker is split into annotated functions and checked with mypy.

FAQ

How is a parameter different from an argument?

A parameter is a name in the function declaration; an argument is the specific value passed at the call. In def greet(name) the name is a parameter, and in greet("Anna") the string "Anna" is an argument.

Can I call a function before it is declared?

At module level — no: the def line must run before the call, otherwise you get a NameError. But inside another function you can call a function declared below, as long as the call happens after its declaration.

How many values can a function return?

Formally one, but that value can be a tuple: return a, b returns a tuple of two items, which is unpacked as x, y = f().

Are type annotations mandatory?

No, Python works without them. But in code that other people read, annotations make maintenance much easier and let mypy or pyright find errors before the code runs.

Can I write an if inside a lambda?

Only a conditional expression: lambda x: "even" if x % 2 == 0 else "odd". Statements, loops and assignments are not allowed inside a lambda.

Learn this topic on video

This topic is covered in depth with practice in the course “Python 3.15: от новичка до профи”.

Go to course