These ten concepts appear in almost every Python project. If you know all of them, you can read and write real Python code. If you are missing one, that is the one that trips you up on every project.

1. Variables and Data Types

Python infers types automatically. No declaration needed.

name: str    = "Alex"
age: int     = 25
score: float = 9.5
active: bool = True
nothing      = None

print(type(name))  # <class 'str'>

The four built-in collection types:

numbers = [1, 2, 3]                    # list  — ordered, mutable
point   = (10, 20)                     # tuple — ordered, immutable
tags    = {"python", "dev"}            # set   — unique items
user    = {"name": "Alex", "age": 25}  # dict  — key/value pairs

Use type() to check the type of any variable at runtime. Use type hints for documentation and IDE support.

2. Functions

Define functions with def. Use return to send a value back. If you skip return, the function returns None.

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

print(greet())        # Hello, World!
print(greet("Alex"))  # Hello, Alex!

*args and **kwargs make functions flexible:

def total(*args: int) -> int:
    return sum(args)

def profile(**kwargs: str) -> dict:
    return dict(kwargs)

total(1, 2, 3)                          # 6
profile(name="Alex", city="Berlin")    # {'name': 'Alex', 'city': 'Berlin'}

Functions are first-class objects. You can store them in variables, pass them to other functions, or return them.

3. Classes and OOP

A class is a blueprint for creating objects. __init__ runs when you create one.

class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

    def speak(self) -> str:
        return f"{self.name} makes a sound"

class Dog(Animal):
    def speak(self) -> str:
        return f"{self.name} barks"

dog = Dog("Rex")
print(dog.speak())  # Rex barks

Use @property for computed attributes:

@property
def name_upper(self) -> str:
    return self.name.upper()

Use super() to call the parent class method from an override.

4. List Comprehensions

Create lists, dicts, and sets in one line.

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

evens   = [x for x in numbers if x % 2 == 0]   # [2, 4, 6]
doubled = [x * 2 for x in numbers]              # [2, 4, 6, 8, 10, 12]

# Dict comprehension
squares = {x: x**2 for x in range(6)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

# Generator expression — lazy, saves memory
gen = (x * 2 for x in range(1_000_000))
print(next(gen))  # 0

Comprehensions are faster than for loops and more readable for simple transformations. Keep nested comprehensions shallow — two levels is usually the limit before readability suffers.

5. Decorators

A decorator wraps a function to add behavior without changing the function’s code.

import functools, time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time() - start:.3f}s")
        return result
    return wrapper

@timer
def slow_task():
    time.sleep(0.1)

slow_task()  # slow_task took 0.100s

Always use @functools.wraps(func) inside decorators. Without it, the wrapped function loses its __name__ and __doc__.

You can stack decorators. They apply from bottom to top:

@timer
@functools.lru_cache(maxsize=128)
def fib(n: int) -> int:
    return n if n < 2 else fib(n - 1) + fib(n - 2)

6. Generators

A generator produces values one at a time using yield. It pauses after each yield and resumes when you ask for the next value.

def count_up(limit: int):
    n = 0
    while n < limit:
        yield n
        n += 1

for num in count_up(3):
    print(num)  # 0, 1, 2

Generators are memory-efficient. A list of one million numbers takes ~8 MB. A generator of the same numbers takes almost nothing because only one value exists in memory at a time.

def integers():
    n = 0
    while True:
        yield n
        n += 1

# Chain generators into a pipeline
def evens(src):
    return (x for x in src if x % 2 == 0)

7. Error Handling

Use try / except / else / finally:

try:
    result = 10 / 0
except ZeroDivisionError as e:
    print(f"Error: {e}")
else:
    print(f"Result: {result}")  # runs only if no exception
finally:
    print("Always runs")        # cleanup goes here

Create custom exceptions by inheriting from Exception:

class InsufficientFunds(Exception):
    def __init__(self, amount: float) -> None:
        super().__init__(f"Need {amount:.2f} more")

def withdraw(balance: float, amount: float) -> float:
    if amount > balance:
        raise InsufficientFunds(amount - balance)
    return balance - amount

Never swallow exceptions silently. Always log or re-raise them.

8. Type Hints

Type hints do not enforce types at runtime. Tools like mypy and your IDE use them to catch bugs before you run the code.

from typing import Optional, Union, TypedDict
from collections.abc import Callable

def find_user(id: int) -> Optional[str]:
    return None

# Python 3.10+: use | instead of Union
def parse(value: str | int) -> str:
    return str(value)

class User(TypedDict):
    name: str
    age: int

Handler = Callable[[str, int], bool]

Run mypy script.py to check types. Add it to your CI pipeline to catch type errors automatically.

9. Modules and Packages

A module is a Python file. A package is a directory with an __init__.py.

# math_utils.py
__all__ = ["add", "subtract"]  # public API

def add(a: int, b: int) -> int:
    return a + b

def subtract(a: int, b: int) -> int:
    return a - b
# main.py
import math_utils
from math_utils import add

print(add(3, 4))  # 7

Package layout:

myapp/
├── __init__.py
└── utils/
    ├── __init__.py
    └── strings.py
from myapp.utils.strings import format_name

Use __all__ to define the public API. Avoid circular imports — they cause hard-to-debug errors.

10. Async / Await

Async lets your code do other work while waiting for I/O (network, files, databases).

import asyncio

async def fetch(url: str) -> str:
    await asyncio.sleep(1)  # simulate network call
    return f"data from {url}"

async def main() -> None:
    # Run both concurrently — not sequentially
    results = await asyncio.gather(
        fetch("api.example.com/users"),
        fetch("api.example.com/posts"),
    )
    for r in results:
        print(r)

asyncio.run(main())
# Finishes in ~1s, not ~2s

asyncio.gather() runs coroutines concurrently. One task pauses at await, and another resumes. This is cooperative, not parallel — only one task runs at a time.

For CPU-heavy work, use threading or multiprocessing instead.