8 Python Concepts Everyone Should Know

This article was automatically translated to English using AI.

Python has this charming reputation of being an easy, almost inviting language. It’s the language that welcomes you in flip-flops, with brewed coffee and a print("Hello World") that works without any fuss.

The problem starts when that simplicity turns into a trap. Because Python lets you write bad code with an almost criminal elegance. It doesn’t complain. It doesn’t throw a chair at you (good memories, hugs, Jorginho!). It just accepts, executes, and lets the technical debt grow subtly in the corner of the room, like a Gremlin fed after midnight.

There’s an important difference between knowing how to write Python and knowing how to think in Python.

The first produces scripts. The second produces real software.

Below are some concepts that mark this transition. They are not “tricks”. They are not syntactical fluff. They are fundamental mechanisms for writing clearer, more idiomatic code that is less like an Excel sheet possessed by malevolent entities.

Keep Calm!


1. List Comprehensions and Generator Expressions

Everyone starts in Python by writing loops. And that’s fine. The loop is honest. Hard-working. Gets up early, packs into crowded public transport, and does its job well.

But Python has more expressive ways to transform collections, especially when you want to generate a new list from another.

The most traditional way would be something like this:

numbers = range(10)
squared_numbers = []

for number in numbers:
    if number % 2 == 0:
        squared_numbers.append(number ** 2)

print(squared_numbers)

Output:

[0, 4, 16, 36, 64]

It works. Nobody’s calling the code police. (Or are they?)

But in Python, this type of transformation can be written more directly using list comprehension:

numbers = range(10)

squared_numbers = [number ** 2 for number in numbers if number % 2 == 0]

print(squared_numbers)

Output:

[0, 4, 16, 36, 64]

The logic is the same:
“for each number in numbers, if it’s even, square it.”

The difference is that the intention appears with much less noise.

Now, here comes the interesting part: you don’t always need to create the entire list in memory. Sometimes you just want to iterate over the values. In that case, enter the generator expression:

numbers = range(1_000_000)

squared_numbers = (number ** 2 for number in numbers if number % 2 == 0)

print(next(squared_numbers))
print(next(squared_numbers))
print(next(squared_numbers))

Output:

0
4
16

The visual difference is small:

[number ** 2 for number in numbers]

generates a list.

(number ** 2 for number in numbers)

generates a generator.

The practical difference can be enormous.

The list computes everything at once and stores everything in memory. The generator computes on-demand, one item at a time. For small collections, it doesn’t matter. For large volumes of data, this difference can be the thin line between “worked beautifully” and “why did my laptop turn into an airplane turbine?”

Use list comprehension when you need the list ready.
Use generator expression when you just need to iterate over the values.

It’s one of those small decisions that separate casual code from code with some sense.


2. Decorators

Decorators are one of those concepts that make Python seem like magic to beginners. When you start using them, you’ll think it is magic indeed.

The idea, however, is simple: a decorator allows you to modify or extend the behavior of a function without directly touching its body.

Imagine you want to measure the execution time of some functions. The clunky, but functional way would be to repeat the same measuring block in each one:

import time

def process_data():
    start_time = time.time()

    result = sum(range(1_000_000))

    end_time = time.time()
    print(f"process_data took {end_time - start_time:.4f} seconds")

    return result

It works, but it’s repetitive. And repetition in code is like infiltration in a wall: at first, it just seems like a little stain, later you’re “refactoring” the entire apartment.

With decorators, we can extract that behavior:

import time
from functools import wraps

def measure_time(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()

        result = func(*args, **kwargs)

        end_time = time.time()
        print(f"{func.__name__} took {end_time - start_time:.4f} seconds")

        return result

    return wrapper

Now we apply the decorator to the function:

@measure_time
def process_data():
    return sum(range(1_000_000))

process_data()

Approximate output:

process_data took 0.0102 seconds

The decorator “wraps” the original function and adds behavior before or after its execution.

Decorators are frequently seen in logging, authentication, caching, validation, web frameworks, and testing. When you use something like @app.route, @pytest.fixture, or @property, you’re already dealing with decorators - perhaps without even realizing it.

And that’s quite Python: first it gives you a pretty syntax, then you discover there was a little arcane ritual underneath.


3. Context Managers and the with Statement

External resources need to be opened and closed. Files, database connections, sockets, locks, HTTP sessions. The problem is that humans forget things. Especially closing things.

The manual way would be:

file = open("report.txt", "w")

try:
    file.write("Python is suspiciously elegant.")
finally:
    file.close()

This is correct, but way too verbose. And if you forget the finally, you might leave a file open, a connection dangling, or a resource locked.

The more idiomatic way is to use with:

with open("report.txt", "w") as file:
    file.write("Python is suspiciously elegant.")

The with statement ensures that the resource will be properly closed at the end of the block, even if an error occurs.

This pattern appears in many situations:

with open("input.txt", "r") as input_file:
    content = input_file.read()

It’s also common in connections, locks, and libraries that need to prepare and release resources.

The great beauty of the context manager is that it transforms operational responsibility into language structure. You stop depending on the programmer’s memory and start relying on a protocol.

And, let’s be honest, depending on the programmer’s memory is a bold plan. Almost a divine provocation. May Shiva protect us!


4. *args and **kwargs

In Python, functions can receive arguments in a very flexible way. Two important features for this are *args and **kwargs.

The *args captures extra positional arguments as a tuple.
The **kwargs captures extra named arguments as a dictionary.

Example:

def create_profile(name, *skills, **details):
    print(f"Name: {name}")
    print(f"Skills: {skills}")
    print(f"Details: {details}")

create_profile(
    "Alice",
    "Python",
    "Data Engineering",
    "Machine Learning",
    location="Berlin",
    seniority="Senior"
)

Output:

Name: Alice
Skills: ('Python', 'Data Engineering', 'Machine Learning')
Details: {'location': 'Berlin', 'seniority': 'Senior'}

The name parameter is mandatory.
The extra skills go into skills.
The extra named arguments go into details.

This is useful when you want to create flexible functions, especially internal APIs, wrappers, or functions that pass arguments to other functions.

A common example:

def log_event(event_name, **metadata):
    print(f"Event: {event_name}")

    for key, value in metadata.items():
        print(f"{key}: {value}")

log_event(
    "user_login",
    user_id=42,
    source="mobile",
    success=True
)

Output:

Event: user_login
user_id: 42
source: mobile
success: True

But here comes a warning: too much flexibility leads to chaos.

*args and **kwargs are powerful, but can make code less explicit if used indiscriminately. Not every function needs to turn into a bottomless pit of arguments. Sometimes, good named parameters are better than an “anything goes” function that no one understands later.

Python gives you the rope. It’s up to you to decide whether to build a bridge or hang your technical self.


5. Dunder Methods

“Dunder” comes from “double underscore”. These are methods like __init__, __len__, __str__, __repr__, __getitem__, __call__, and so on.

They allow objects created by you to behave like the language’s native objects.

For example:

class Dataset:
    def __init__(self, records):
        self.records = records

    def __len__(self):
        return len(self.records)

    def __str__(self):
        return f"Dataset with {len(self.records)} records"

Now we can use len() directly on our object:

sales_data = Dataset([
    {"product": "Notebook", "price": 5000},
    {"product": "Mouse", "price": 150},
    {"product": "Keyboard", "price": 300}
])

print(len(sales_data))
print(sales_data)

Output:

3
Dataset with 3 records

Without dunder methods, our object would just be any class. With them, it starts to interact better with the language.

We can go further:

class ShoppingCart:
    def __init__(self):
        self.items = []

    def __len__(self):
        return len(self.items)

    def __getitem__(self, index):
        return self.items[index]

    def add_item(self, item):
        self.items.append(item)

Usage:

cart = ShoppingCart()

cart.add_item("Notebook")
cart.add_item("Mouse")

print(len(cart))
print(cart[0])

Output:

2
Notebook

By implementing __getitem__, the cart now allows access with brackets, like a list.

This is the kind of thing that makes APIs feel more natural. The code becomes less artificial, less verbose, and more aligned with how Python expects objects to behave.

Dunder methods are basically contracts with the language. You implement certain special methods and Python begins to treat your object as something more integrated into the ecosystem.

It’s like gaining Python citizenship.


6. Membership Operator: in and not in

The in operator seems simple. And it is. But it’s one of those features that appears all the time in well-written Python code.

It checks if a value belongs to a collection.

allowed_roles = ["admin", "editor", "viewer"]

user_role = "editor"

if user_role in allowed_roles:
    print("Access granted")
else:
    print("Access denied")

Output:

Access granted

It also works with strings:

message = "Python makes simple things simple and weird things possible."

if "Python" in message:
    print("Python was mentioned")

Output:

Python was mentioned

And with dictionaries, in checks the keys:

user = {
    "name": "Alice",
    "email": "[email protected]",
    "active": True
}

if "email" in user:
    print(user["email"])

Output:

To negate the condition, we use not in:

blocked_users = {"bob", "mallory", "eve"}

current_user = "alice"

if current_user not in blocked_users:
    print("User is allowed")

Output:

User is allowed

An important detail: for small lists, in works well. But when you need to check for membership many times, especially in large volumes, prefer set.

Compare:

allowed_ids = [1001, 1002, 1003, 1004]
current_id = 1003

print(current_id in allowed_ids)

Works.

But for many lookups:

allowed_ids = {1001, 1002, 1003, 1004}
current_id = 1003

print(current_id in allowed_ids)

Using set tends to be more efficient for membership checking because the structure is optimized for that type of operation.

This concept may seem basic, but it changes how you express business rules. Instead of writing long, repetitive conditions:

status = "pending"

if status == "pending" or status == "processing" or status == "queued":
    print("Job is still running")

You write:

status = "pending"
running_statuses = {"pending", "processing", "queued"}

if status in running_statuses:
    print("Job is still running")

More readable. Easier to maintain. Less chance of someone adding or status == "banana" at 6:47 PM on a Friday.


7. F-string Formatting

Before f-strings, string formatting in Python was a bit tedious. Not absurdly so, but it had that energy of a bill overdue.

You could do it like this:

name = "Alice"
score = 94.5678

message = "User {} scored {:.2f} points".format(name, score)

print(message)

Output:

User Alice scored 94.57 points

It works. But since Python 3.6, f-strings have become the most pleasant and readable way to interpolate values:

name = "Alice"
score = 94.5678

message = f"User {name} scored {score:.2f} points"

print(message)

Output:

User Alice scored 94.57 points

The advantage is that the variable appears exactly where it will be used. The text flows more naturally.

You can also include expressions within the f-string:

price = 120
quantity = 3

message = f"Total price: {price * quantity}"

print(message)

Output:

Total price: 360

And format dates, numbers, and decimals:

from datetime import datetime

created_at = datetime(2026, 5, 19, 14, 30)
amount = 15342.789

print(f"Created at: {created_at:%d/%m/%Y %H:%M}")
print(f"Amount: {amount:,.2f}")

Output:

Created at: 19/05/2026 14:30
Amount: 15,342.79

There’s also an excellent resource for debugging:

user_id = 42
status = "active"

print(f"{user_id=}")
print(f"{status=}")

Output:

user_id=42
status='active'

This is incredibly useful when you want to print the variable and value without writing it twice.

F-strings are not just convenience. They make code more readable and reduce noise. And readability, in Python, isn’t just an aesthetic detail. It’s part of the philosophy.

Code is read many more times than it is written. Unfortunately, sometimes by you, six months later, without mate or coffee and hating the “past you”.


8. zip()

The zip() function is used to combine iterables into pairs, trios, or groups, item by item.

Imagine two lists:

names = ["Alice", "Bob", "Charlie"]
scores = [95, 82, 88]

You want to loop through name and score together. The ugly way would be to control the index manually:

for index in range(len(names)):
    print(f"{names[index]} scored {scores[index]}")

Works, but it smells like C wearing a Python badge.

With zip():

for name, score in zip(names, scores):
    print(f"{name} scored {score}")

Output:

Alice scored 95
Bob scored 82
Charlie scored 88

More direct, clearer, and less prone to error.

You can also use zip() to create dictionaries:

fields = ["name", "email", "active"]
values = ["Alice", "[email protected]", True]

user = dict(zip(fields, values))

print(user)

Output:

{'name': 'Alice', 'email': '[email protected]', 'active': True}

Another common use: looping through multiple related lists.

products = ["Notebook", "Mouse", "Keyboard"]
prices = [5000, 150, 300]
quantities = [2, 10, 5]

for product, price, quantity in zip(products, prices, quantities):
    total = price * quantity
    print(f"{product}: {total}")

Output:

Notebook: 10000
Mouse: 1500
Keyboard: 1500

One caution: zip() stops when the shortest iterable ends.

names = ["Alice", "Bob", "Charlie"]
scores = [95, 82]

for name, score in zip(names, scores):
    print(f"{name}: {score}")

Output:

Alice: 95
Bob: 82

Charlie got left out because there was no corresponding score.

This can be great or terrible, depending on your intent. Python won’t make a fuss. It will just follow the rule and leave you to discover later in the wrong report.

If you need to deal with lists of different sizes, you can use itertools.zip_longest:

from itertools import zip_longest

names = ["Alice", "Bob", "Charlie"]
scores = [95, 82]

for name, score in zip_longest(names, scores, fillvalue="N/A"):
    print(f"{name}: {score}")

Output:

Alice: 95
Bob: 82
Charlie: N/A

The zip() function is one of those small functions that change the way you write code. Once you start using it, many index-controlled loops start to feel like a relic of a more barbaric time.


Bonus: Fail Fast and Early Error Validation

There’s an important difference between code that tries to be “flexible” and code that just accepts any nonsense until it explodes ten lines later, at a point where no one understands the original cause.

The fail fast principle is simple: if there’s an invalid condition, stop early.

Don’t let the error travel through the system like a lost consultant in a meeting without an agenda (me in life?).

Imagine a function that calculates product discounts:

def apply_discount(price, discount_percentage):
    final_price = price - (price * discount_percentage / 100)
    return final_price

Looks innocent.

But what happens if someone passes a negative price?

print(apply_discount(-100, 10))

Output:

-90.0

Technically it worked. Morally, it opened a portal to the seventh circle of hell.

We also have another problem:

print(apply_discount(100, 150))

Output:

-50.0

Congrats, now the store pays the customer to take the product.

A better approach is to validate the conditions right at the start:

def apply_discount(price, discount_percentage):
    if price < 0:
        raise ValueError("price cannot be negative")

    if discount_percentage < 0 or discount_percentage > 100:
        raise ValueError("discount_percentage must be between 0 and 100")

    final_price = price - (price * discount_percentage / 100)
    return final_price

Now the error appears early, close to the cause:

print(apply_discount(100, 150))

Output:

ValueError: discount_percentage must be between 0 and 100

This is much better than letting an invalid value contaminate the rest of the flow and discovering the problem only later when it became a production bug with a suspense soundtrack.

Another example: a function that searches for a user by email.

def find_user_by_email(users, email):
    for user in users:
        if user["email"] == email:
            return user

    return None

This code works. But what if email comes empty?

users = [
    {"name": "Alice", "email": "[email protected]"},
    {"name": "Bob", "email": "[email protected]"}
]

user = find_user_by_email(users, "")
print(user)

Output:

None

The problem is that None can mean two different things:

  1. The user was truly not found.
  2. The input was invalid from the start.

These two situations are not equal.

A more explicit version:

def find_user_by_email(users, email):
    if not email:
        raise ValueError("email is required")

    for user in users:
        if user["email"] == email:
            return user

    return None

Now the code differentiates a valid search with no result from an invalid call right at the start.

This pattern also helps reduce indentation. Instead of writing deeply nested code:

def process_order(order):
    if order:
        if order["items"]:
            if order["customer_id"]:
                print("Processing order")

You can invert the conditions and exit early:

def process_order(order):
    if not order:
        raise ValueError("order is required")

    if not order["items"]:
        raise ValueError("order must have at least one item")

    if not order["customer_id"]:
        raise ValueError("customer_id is required")

    print("Processing order")

The second version is more straightforward. It protects the function right at the entry point and lets the “happy path” breathe.

This style often appears with names like fail fast, guard clauses, early return, and check error conditions early.

In the end, they’re all part of the same family of ideas: handle problematic cases first, close the flow when something is wrong, and let the main code breathe.

Good code isn’t the one that pretends nothing can go wrong. Good code is the one that knows exactly where it wants to break when something goes wrong.

And preferably breaks before it turns into an archaeological investigation in the log at midnight.


Simple Python Doesn’t Mean Simplistic Python

Python is a curious language. It allows you to start quickly, but rewards those who dive deeper.

You can write useful scripts knowing little. But to write cleaner, more expressive, and sustainable code, you need to understand the mechanisms that make Python, Python.

List comprehensions and generators help transform data with clarity and efficiency. Decorators allow reusable behaviors to be encapsulated. Context managers make resource usage safer. *args and **kwargs bring flexibility, when used responsibly. Dunder methods integrate your objects into the language’s protocols. in and not in make membership rules more readable. F-strings make string interpolation less of a hassle. zip() eliminates indexed loops that seemed innocent but were just waiting for an opportunity to become a bug. And fail fast reminds us that a good error is one that appears early, close to the cause, before it turns into a supernatural entity in the production environment.

None of this is “advanced” in the mystical sense of the word. These are fundamentals. Real fundamentals, the kind that separate code that just works from code that survives the next human needing to tinker with it.

And that human could be you.

And you, six months from now, deserve some mercy.