Python Features Worth Knowing

Table of Contents

1. Introduction

When you first start learning Python, functions seem fairly simple: pass a few arguments and get a result. But gradually, default values, arbitrary numbers of parameters, nested functions, and type annotations come into play. At this point, it becomes clear that Python functions involve far more mechanics than it might seem at first glance.

Most of the things covered in this article are not rare language tricks. On the contrary, they can be found in almost any sufficiently large Python project. That is why it is useful to understand not only the syntax but also what is happening behind it.

2. Mutable default arguments

Let's start with one of Python's most well-known features — mutable objects used as default function arguments.

Suppose we need a function that adds a given value to a list. At first glance, the following code looks perfectly reasonable:

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


print(add_item("Python"))
print(add_item("Java"))

You might expect the result to look something like this:

['Python']
['Java']

But in fact, the result will be:

['Python']
['Python', 'Java']

The reason is that a default argument value is not created every time the function is called. It is created once — when the function itself is defined. After that, subsequent calls continue to work with the same object.

A list is mutable, meaning it is an object that can be changed. When we call append(), the existing list is modified, and that change is preserved for the next function call.

Usually, it is better to write such a function using None:

def add_item(item, items=None):
    if items is None:
        items = []

    items.append(item)
    return items


print(add_item("Python"))
print(add_item("Java"))

Now, every time the function is called without the second argument, a new list is created inside the function:

['Python']
['Java']

The same applies to dictionaries, sets, and other mutable objects. For example, using config={} in function parameters can lead to exactly the same behavior.

At the same time, this Python behavior can sometimes be used intentionally. For example, it can be used to preserve some state between function calls. However, such code is usually not the most obvious to someone reading it later, so it is better to use more explicit solutions for storing state.

3. *args

Usually, the number of function arguments is known in advance:

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

But sometimes you do not know beforehand how many values will be passed. Python provides the *args construct for this purpose.

def add(*args):
    print(args)


add(1, 2, 3, 4)

As a result, we get:

(1, 2, 3, 4)

Inside the function, args is a regular tuple. Therefore, you can work with it just like with any other tuple.

For example, we can write a function that adds any number of values:

def add(*args):
    result = 0

    for number in args:
        result += number

    return result


print(add(1, 2))
print(add(1, 2, 3, 4, 5))

The name args itself is not a special Python keyword. What matters is the asterisk before the parameter name.

Technically, the function could also be written like this:

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

However, *args has become a standard convention and is familiar to most Python developers.

The asterisk is not only used when declaring a function. It can also be used to unpack a sequence when calling one.

def print_user(name, age):
    print(name, age)


user = ["Alex", 25]

print_user(*user)

This call is effectively equivalent to the following:

print_user("Alex", 25)

This kind of unpacking is convenient when the arguments are already stored inside a list or tuple.

4. **kwargs

While *args allows a function to accept an arbitrary number of positional arguments, **kwargs does something similar for keyword arguments.

def show_user(**kwargs):
    print(kwargs)


show_user(name="Alex", age=25, city="Madrid")

Inside the function, we get a regular dictionary:

{'name': 'Alex', 'age': 25, 'city': 'Madrid'}

Therefore, values can be accessed in the usual way:

def show_user(**kwargs):
    print(kwargs.get("name"))
    print(kwargs.get("age"))


show_user(name="Alex", age=25)

As with args, the name kwargs is a convention rather than a required part of the syntax. What matters are the two asterisks.

*args and **kwargs are often used together:

def example(*args, **kwargs):
    print("args:", args)
    print("kwargs:", kwargs)


example(1, 2, 3, language="Python", version=3)

We get:

args: (1, 2, 3)
kwargs: {'language': 'Python', 'version': 3}

Two asterisks can also be used to unpack dictionaries when calling a function.

def create_user(name, age):
    print(f"{name}: {age}")


user = {
    "name": "Alex",
    "age": 25
}

create_user(**user)

Python will use the dictionary keys as argument names and the corresponding values as the values of those arguments.

This feature is especially useful when working with configurations, decorators, libraries, and wrapper functions, where it may not be known in advance exactly which parameters will need to be passed along.

5. Scope and the LEGB rule

Another important topic in Python is variable scope. If several variables with the same name exist in a program, the interpreter needs to determine which one to use.

For this, the LEGB rule is commonly used:

  • L — Local
  • E — Enclosing
  • G — Global
  • B — Built-in

Python searches for a name in exactly this order.

Local

Local is the local scope of the current function.

def example():
    language = "Python"
    print(language)


example()

The language variable exists inside the function. If you try to access it from outside, Python will not find it.

Enclosing

Enclosing comes into play when one function is defined inside another.

def outer():
    language = "Python"

    def inner():
        print(language)

    inner()


outer()

There is no language variable inside the inner() function. Therefore, Python moves to the next level and looks for it in the scope of the outer() function.

If we need not only to read such a variable but also to modify it, the nonlocal keyword is used.

def counter():
    value = 0

    def increment():
        nonlocal value
        value += 1
        return value

    print(increment())
    print(increment())


counter()

Here, nonlocal tells Python to use the variable from the enclosing function rather than creating a new local one.

Global

Global is the scope of the current module.

language = "Python"


def show_language():
    print(language)


show_language()

The function does not find language locally or in an enclosing function, so it reaches the global scope.

To modify a global variable inside a function, Python provides the global keyword:

counter = 0


def increment():
    global counter
    counter += 1


increment()
print(counter)

After the call, the value of counter will be 1.

At the same time, global variables generally should not be overused. When different functions directly modify shared global state, it becomes more difficult to understand where the current value came from.

Built-in

The final level is Built-in. This is where Python's built-in names are located, such as print, len, sum, str, and many others.

numbers = [1, 2, 3]

print(len(numbers))

If you create your own variable with the same name, you can accidentally shadow the built-in function:

len = 10

numbers = [1, 2, 3]

print(len(numbers))

Now len contains a number rather than the built-in function, so attempting to call it will result in an error.

In the end, the rule is quite easy to remember: Local → Enclosing → Global → Built-in. Python moves from the nearest scope to the most general one and stops as soon as it finds the required name.

6. Lambda functions

Lambda allows you to create small functions without the usual declaration using def.

For example, a regular function:

def square(number):
    return number ** 2

Using lambda, it can be written like this:

square = lambda number: number ** 2

print(square(5))

The result will be the same in both cases:

25

The general syntax looks like this:

lambda arguments: expression

The main limitation is that only one expression can appear after the colon. Therefore, lambda is suitable for small operations rather than a full ten-line function.

It is especially convenient when a function is needed only once. For example, when sorting:

users = [
    {"name": "Alex", "age": 27},
    {"name": "Max", "age": 21},
    {"name": "Kate", "age": 24},
]

users.sort(key=lambda user: user["age"])

print(users)

Here, the function is needed only to tell the sort() method which value should be used to sort the elements.

Another common example is using it together with map() or filter():

numbers = [1, 2, 3, 4, 5]

squares = list(map(lambda number: number ** 2, numbers))

print(squares)

Result:

[1, 4, 9, 16, 25]

However, there is no need to shorten code at any cost. If a lambda becomes too long or starts to contain complex logic, a regular function defined with def will almost always be easier to read.

7. Type hints

Python is a dynamically typed language. We do not need to declare the type of every variable in advance, and the same variable can refer to objects of different types at different times.

value = 10
value = "Python"

This is convenient, but in large projects it can sometimes become difficult to understand what data a function expects to receive and what it is supposed to return. This is where type hints — type annotations — are used.

For example, a regular function might look like this:

def greet(name):
    return f"Hello, {name}"

Let's add type information:

def greet(name: str) -> str:
    return f"Hello, {name}"

Now the function declaration immediately makes it clear that the name argument is expected to be a string and that the function should return a string.

At the same time, Python itself generally does not prevent you from passing a value of another type just because of the annotation.

def greet(name: str) -> str:
    return f"Hello, {name}"


print(greet(123))

Annotations primarily provide additional information to developers and code analysis tools. IDEs and static analyzers can detect type mismatches before the program is even run.

Collections can also be annotated:

def get_names(users: list[str]) -> list[str]:
    return [user.upper() for user in users]

If a value can have multiple types, modern versions of Python allow you to use the | operator:

def find_user(user_id: int) -> str | None:
    if user_id == 1:
        return "Alex"

    return None

The str | None notation indicates that the function can return either a string or None.

For more complex cases, Python provides the typing module. For example, Callable can be used to describe functions passed as arguments.

from typing import Callable


def calculate(
    a: int,
    b: int,
    operation: Callable[[int, int], int]
) -> int:
    return operation(a, b)


result = calculate(10, 5, lambda a, b: a + b)

print(result)

Type hints are especially useful not because they make Python similar to statically typed languages, but because they act as a form of documentation directly inside the code. When a function has five arguments and returns a complex structure, annotations make it significantly easier to read.

8. Conclusion

At first glance, the features discussed above may seem almost unrelated. *args and **kwargs deal with passing arguments, LEGB deals with name lookup, lambda is used to create small functions, and type hints help describe expected data types.

But all of these mechanisms have one thing in common: they appear constantly in everyday Python code. Without understanding mutable default arguments, it is easy to end up with unexpected state between function calls. Without *args and **kwargs, it is harder to write flexible functions and wrappers. And understanding scopes helps explain where Python gets a variable's value from in the first place.

Lambda and type hints are more about making programs easier to write and read. The former provides a compact way to describe small operations, while the latter makes function interfaces significantly clearer.

These are far from the only Python features worth knowing. The language also has decorators, generators, context managers, comprehensions, unpacking, property, dataclasses, and many other interesting mechanisms. But those are topics for a separate follow-up.

Ian L. Dolganov
Ian L. Dolganov
Junior Python Developer

My academic interests focus on Python development, software engineering, backend development, and modern approaches to designing and developing reliable software systems.