OcxlyDev · Tutorial

Python, leveled up: an intermediate tutorial

You know variables, loops, and functions. This is the tutorial that takes you from “code that works” to code that a Python programmer would recognise as their own — idiomatic, expressive, and built from the language's real strengths.

OcxlyDev Published 17 July 2026 ~22 min read Intermediate Sources linked throughout

There's a moment in learning Python where you can make anything work, but your code still doesn't look like the code you read in good libraries. You're writing for i in range(len(items)) where a Python programmer writes for item in items; you're catching bugs the language could have caught for you; you're rebuilding tools the standard library already ships. This tutorial closes that gap. It assumes you're comfortable with the basics (if not, start with our beginner tutorial) and focuses on the handful of features that separate working Python from fluent Python.

As before: type the examples, don't just read them. The difference between knowing a feature and using it fluently is entirely in your fingers.

01Think in idioms first

Before any big feature, internalise a few idioms — they'll reshape how you write everything else. When you loop and need the index, use enumerate(); to walk two lists together, use zip(); to pull values out of a sequence, use unpacking.1

python — the idiomatic way
names = ["Ada", "Grace", "Linus"]
scores = [91, 88, 95]

# index + value together — no range(len(...))
for i, name in enumerate(names, start=1):
    print(f"{i}. {name}")

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

# unpacking (and swapping without a temp variable)
a, b = 1, 2
a, b = b, a            # now a=2, b=1
first, *rest = [10, 20, 30, 40]   # first=10, rest=[20,30,40]

Two more habits worth forming. First, lean on truthiness: empty containers, 0, None, and "" are all “falsy,” so write if items: rather than if len(items) > 0:. Second, prefer “easier to ask forgiveness than permission” (EAFP) — try the operation and handle the exception, rather than checking every precondition first. It's the grain the language is cut along.2

02Comprehensions

A comprehension builds a list, dict, or set in one readable expression — the workhorse of everyday Python.3 Once it clicks, you'll reach for it constantly.

python
nums = [1, 2, 3, 4, 5, 6]

squares = [n * n for n in nums]              # [1, 4, 9, 16, 25, 36]
evens   = [n for n in nums if n % 2 == 0]     # filter with a trailing if

# dict comprehension: name -> length
lengths = {name: len(name) for name in ["Ada", "Grace"]}

# set comprehension: unique first letters
initials = {w[0] for w in ["apple", "apricot", "banana"]}   # {'a', 'b'}
Restraint is a skill too. If a comprehension needs two nested fors and an if, it's usually clearer as a plain loop. Comprehensions are for simple transformations; readability still wins.

03Functions, properly

You can define functions; now use their full range. Parameters can have defaults, be passed by keyword, and collect extras with *args (positional) and **kwargs (keyword).4

python
def greet(name, greeting="Hello", *, excited=False):
    # everything after * is keyword-only — call it as excited=True
    mark = "!" if excited else "."
    return f"{greeting}, {name}{mark}"

print(greet("Ada"))                         # Hello, Ada.
print(greet("Ada", "Hi", excited=True))     # Hi, Ada!

def total(*args, **kwargs):
    print(args)      # a tuple of positional args
    print(kwargs)    # a dict of keyword args

total(1, 2, 3, mode="sum")          # (1, 2, 3)  {'mode': 'sum'}
Classic trap: never use a mutable default like def f(items=[]) — that list is created once and shared across every call. Use def f(items=None) and set items = items or [] inside.

For a throwaway one-liner, a lambda is an anonymous function — most useful as the key to sorted():

python
people = [("Ada", 36), ("Grace", 85), ("Linus", 54)]
by_age = sorted(people, key=lambda p: p[1])   # sort by the age element

04Generators: work you don't do until you need it

A generator is a function that produces values lazily, one at a time, using yield instead of return. It doesn't build the whole result in memory — it hands you the next value only when asked, which lets you process data far bigger than RAM.5

python
def countdown(n):
    while n > 0:
        yield n       # pause here, hand back n, resume on the next request
        n -= 1

for x in countdown(3):
    print(x)          # 3, 2, 1 — computed on demand

# reading a huge file line by line, never loading it all:
def non_empty_lines(path):
    with open(path) as f:
        for line in f:
            if line.strip():
                yield line.rstrip()

There's also a compact form — the generator expression, like a comprehension with parentheses — perfect for feeding aggregates without a temporary list: sum(n * n for n in range(1_000_000)) computes the sum without ever materialising a million squares.

05Decorators

A decorator wraps a function to add behaviour — logging, timing, caching, access checks — without touching the function's own body. It's a function that takes a function and returns a new one, applied with the @ syntax.6

python
import functools, time

def timed(func):
    @functools.wraps(func)          # keep func's name/docstring on the wrapper
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
        return result
    return wrapper

@timed
def slow_sum(n):
    return sum(range(n))

slow_sum(10_000_000)      # slow_sum took 0.12s

You'll use decorators long before you write many: @property, @staticmethod, @functools.cache, and framework decorators like a web route are everywhere. The @functools.wraps line is the one detail beginners miss — without it, the wrapped function loses its real name and docstring.

06Classes, and when to skip them

You met dictionaries for grouping data; classes bundle data and the behaviour that acts on it.7 The __init__ method builds each instance; self is the instance; dunder (double-underscore) methods hook into language syntax.

python
class Account:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount
        return self.balance

    def __repr__(self):        # how it prints in the shell / logs
        return f"Account({self.owner!r}, {self.balance})"

acc = Account("Ada")
acc.deposit(100)
print(acc)                    # Account('Ada', 100)

For a class that's mostly a bag of typed fields, don't hand-write __init__ and __repr__ — use a dataclass, which generates them for you:8

python
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

    def distance_to(self, other):
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5

print(Point(0, 0).distance_to(Point(3, 4)))   # 5.0
print(Point(1, 2))                              # Point(x=1, y=2) — free __repr__

Use @property to expose a computed value as if it were an attribute, and inheritance for genuine “is-a” relationships.9 But resist over-engineering: if a function will do, write a function. Not everything needs to be a class.

07Context managers: the with statement

You've used with open(...) — that's a context manager, and its job is guaranteed cleanup: whatever happens inside the block, the file is closed on the way out, even if an exception is raised.10 Any time you acquire something that must be released (files, locks, network connections), reach for with.

Writing your own is easy with contextlib:11

python
from contextlib import contextmanager
import time

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield                     # the body of the with-block runs here
    finally:
        print(f"{label}: {time.perf_counter() - start:.3f}s")

with timer("work"):
    sum(range(10_000_000))
# work: 0.11s   (printed even if the block raised)

08Exceptions, for real this time

Beginners catch exceptions to stop crashes. Intermediate code uses them deliberately: catch the specific exception you expect, let unexpected ones propagate, and use the full try / except / else / finally shape.12

python
class ConfigError(Exception):          # define your own, subclassing Exception
    """Raised when configuration is invalid."""

def load_port(raw):
    try:
        port = int(raw)
    except ValueError:
        raise ConfigError(f"port must be a number, got {raw!r}") from None
    else:
        # runs only if no exception was raised in the try
        if not (1 <= port <= 65535):
            raise ConfigError(f"port out of range: {port}")
        return port

Two rules that separate the levels: never write a bare except: (it swallows even KeyboardInterrupt); and catch narrowlyexcept ValueError, not except Exception — so real bugs still surface loudly.

09The standard-library toolbox

Python's real superpower is how much comes in the box. Before installing anything or writing a helper, check whether the standard library already solved it — it usually has.

collections — better containers

python
from collections import Counter, defaultdict

Counter("mississippi").most_common(2)   # [('i', 4), ('s', 4)]

groups = defaultdict(list)               # missing keys default to []
for word in ["apple", "avocado", "banana"]:
    groups[word[0]].append(word)   # {'a': [...], 'b': [...]}

collections also gives you namedtuple and deque.13 Reach for itertools when you're combining or chunking iterables,14 pathlib for filesystem paths (it replaces fiddly os.path string-juggling),15 datetime for dates and times,16 and json to read and write JSON.17

python — pathlib & json
from pathlib import Path
import json

config = Path("settings.json")
if config.exists():
    data = json.loads(config.read_text())   # parse JSON → dict
    config.write_text(json.dumps(data, indent=2))   # write it back, pretty

10Type hints: let the tools catch your bugs

Python stays dynamically typed at runtime, but type hints (PEP 484) let you annotate what you expect — and a checker like mypy or your editor then flags mismatches before you ever run the code.18 On any codebase past a few hundred lines, they pay for themselves.

python
def average(numbers: list[float]) -> float:
    return sum(numbers) / len(numbers)

def find_user(uid: int) -> str | None:   # returns a str, or None if not found
    ...

The type hints don't change how the program runs — they're documentation the machine can check. Add them at your boundaries first (function signatures), and let them grow inward.

11Virtual environments & dependencies

The moment a project needs a third-party package, isolate it. A virtual environment gives each project its own private set of packages, so two projects can't fight over versions.19

terminal
python -m venv .venv          # create an isolated environment
source .venv/bin/activate     # macOS/Linux  (Windows: .venv\Scripts\activate)
pip install requests          # installs only into this project
pip freeze > requirements.txt  # record exact versions for others to reproduce

Commit requirements.txt (or a pyproject.toml), never the .venv/ folder. Anyone can then recreate your environment with pip install -r requirements.txt. Modern tools like uv and Poetry streamline this further, but they're all doing this same job underneath.

11.5Putting it together

Here's a small, realistic script that leans on several of the ideas above — a dataclass, type hints, a generator, a Counter, a comprehension, and pathlib — to summarise a log file:

python — log_summary.py
from collections import Counter
from dataclasses import dataclass
from pathlib import Path

@dataclass
class Summary:
    total: int
    by_level: dict[str, int]

def levels(path: Path):
    # generator: yields the level word from each non-empty line, lazily
    for line in path.read_text().splitlines():
        if line.strip():
            yield line.split()[0]      # e.g. "ERROR", "INFO"

def summarise(path: Path) -> Summary:
    counts = Counter(levels(path))
    return Summary(total=sum(counts.values()), by_level=dict(counts))

if __name__ == "__main__":
    s = summarise(Path("app.log"))
    print(f"{s.total} lines")
    for level, n in sorted(s.by_level.items()):
        print(f"  {level}: {n}")

Notice how little of this is “plumbing.” The dataclass removes boilerplate, the generator keeps memory flat, Counter does the tallying, and the type hints let an editor verify the shapes. That density — saying a lot with a little, clearly — is what fluent Python feels like. The if __name__ == "__main__": guard, by the way, means “only run this when the file is executed directly, not when it's imported” — the standard way to make a file both a script and an importable module.

12Where to go next

You now have the toolkit that most day-to-day Python is built from. A few honest directions from here:

Fluency isn't a finish line — it's the point where the language stops being in your way and starts amplifying you. From here, the fastest path is simply building things you care about, and reaching for the right tool each time you notice yourself doing something the hard way.

About this piece. An intermediate tutorial from OcxlyDev, and the sequel to our beginner Python tutorial. Every feature links to the official Python documentation or the relevant PEP; version details reflect Python 3 (current release 3.14 as of mid-2026), and the features here have been stable for years.

References

  1. Python documentation — Built-in Functions: enumerate() and zip()
  2. Python documentation — Glossary: EAFP ("easier to ask forgiveness than permission")
  3. Python documentation — Data Structures: List, dict and set comprehensions
  4. Python documentation — More on Defining Functions (defaults, keyword args, *args/**kwargs, lambda)
  5. Python documentation — Generators (yield) and generator expressions
  6. PEP 318 — Decorators for Functions and Methods
  7. Python documentation — Classes (__init__, self, methods, dunder methods)
  8. Python documentation — dataclasses (auto-generated __init__/__repr__)
  9. Python documentation — Built-in Functions: property()
  10. Python documentation — The with statement and context managers (data model)
  11. Python documentation — contextlib.contextmanager
  12. Python documentation — Errors and Exceptions (try/except/else/finally, custom exceptions, raise)
  13. Python documentation — collections (Counter, defaultdict, namedtuple, deque)
  14. Python documentation — itertools (functions for efficient looping)
  15. Python documentation — pathlib (object-oriented filesystem paths)
  16. Python documentation — datetime (dates and times)
  17. Python documentation — json (encode and decode JSON)
  18. PEP 484 — Type Hints (gradual typing)
  19. Python documentation — venv (creating virtual environments)
  20. PEP 20 — The Zen of Python ("import this")
  21. Python documentation — The Python Tutorial