Learn Python in a Single Post: A Complete Python Tutorial from Basics to Async and Type Hints

Python’s reputation is “easy to learn, hard to get right.” The easy part is the syntax — readable, minimal, almost executable pseudocode. The hard part is that Python has a rich data model, several concurrency models, an optional type system, and a sprawling ecosystem, and most tutorials stop before reaching any of it.

This post teaches the whole language in one go, in five stages, with runnable snippets. The goal: by the end you understand the data model, generators, decorators, context managers, type hints, asyncio, the GIL, and the modern toolchain — the parts that separate “I can write Python” from “I write Python well.”

We target Python 3.12 with notes on 3.13 (free-threading, the JIT preview). Everything here runs on a current CPython.

The Roadmap

The five-stage path through the language. Each stage builds on the previous one.

Python Roadmap

  1. Fundamentals — variables, dynamic typing, collections, functions, scope
  2. Idiomatic Python — comprehensions, iterators, generators, context managers, exceptions
  3. OOP + Data Model — classes, dataclasses, dunder methods, protocols, ABCs
  4. Type Hints + APIstyping, generics, Protocol, Pydantic, FastAPI, mypy/pyright
  5. Concurrency + Ecosystem — asyncio, threads vs processes, the GIL, venv/uv/pytest/ruff

Stage 1 — Fundamentals

A program

print("Hello, Python!")

That is a complete program. No main, no imports, no boilerplate. Python runs top to bottom.

Variables and dynamic typing

x = 10            # int
x = "now a str"   # same name, new type - dynamic typing
PI = 3.14159      # ALL_CAPS convention for constants (not enforced)

# Everything is an object, including types and functions
type(x)           # <class 'str'>
isinstance(x, str)  # True

Python is dynamically and strongly typed: variables have no type (they’re names bound to objects), objects have a type that does not change, and implicit conversions between unrelated types don’t happen ("3" + 4 is a TypeError, not "34" or 7).

Numbers, strings, f-strings

a = 5; b = 2
a / b    # 2.5   true division
a // b   # 2     floor division
a % b    # 1     modulo
a ** b   # 25    power

name = "Ada"
f"Hello, {name}! {a + b}"      # f-string: expressions inline
"left".ljust(10).rstrip()      # string methods return new strings (immutable)

# Raw + multiline
path = r"C:\Users\ada"
poem = """first line
second line"""

Collections

nums = [1, 2, 3]               # list - mutable, ordered
nums.append(4); nums[0] = 0   # mutates in place
point = (1, 2)                 # tuple - immutable, ordered
d = {"a": 1, "b": 2}           # dict - keyed, insertion-ordered (3.7+)
s = {1, 2, 3}                  # set - unique, unordered

# Unpacking (works on any iterable)
first, *rest = [1, 2, 3, 4]   # first=1, rest=[2,3,4]
a, b = b, a                   # swap, no temp
for k, v in d.items(): ...    # dict iteration

Choose by need: list for ordered mutable, tuple for fixed records, dict for keyed lookup (O(1) average), set for membership and dedup.

Control flow

if x > 0: ...
elif x == 0: ...
else: ...

for item in iterable: ...
while cond: ...

# match (3.10+) - structural pattern matching
match point:
    case (0, 0): print("origin")
    case (x, 0): print(f"x-axis {x}")
    case (0, y): print(f"y-axis {y}")
    case (x, y): print(f"point {x},{y}")
    case _: print("not a point")

Functions, args, scope

def greet(name, greeting="Hello", *args, **kwargs):
    """greeting defaults to 'Hello'; *args collects positional, **kwargs keyword."""
    print(f"{greeting}, {name}!", args, kwargs)
greet("Ada")                   # Hello, Ada! () {}
greet("Ada", "Hi", "a", "b", x=1)  # Hi, Ada! ('a','b') {'x':1}

# Keyword-only arguments (after *)
def f(a, b, *, required): ...
# Positional-only (before /)
def g(a, b, /, c): ...

# Lambdas - small anonymous functions
sq = lambda x: x * x
sorted(items, key=lambda i: i.priority)

# Scope: LEGB - Local, Enclosing, Global, Built-in

Python uses LEGB lookup order. Functions don’t see outer variables they want to rebind unless declared global or nonlocal — a deliberate design choice that keeps function side effects explicit.

Stage 2 — Idiomatic Python

This stage is where you stop writing Python that looks like translated C and start writing Python.

Comprehensions

[x * x for x in range(10) if x % 2 == 0]   # [0, 4, 16, 36, 64]
{k: len(k) for k in words}                  # dict comprehension
{c.lower() for c in text}                   # set comprehension
(x * x for x in range(10))                  # generator expression - lazy

Comprehensions are faster than a for loop with .append() and read as a single thought. Reach for them; but if the comprehension wraps, use a regular loop — readability beats cleverness.

Iterators and generators

An iterator is any object with __iter__ and __next__. A generator is a function that yields values lazily, one at a time, suspending between yields:

def count_up():
    i = 0
    while True:
        yield i
        i += 1

for n in count_up():           # infinite, but we only take what we use
    if n > 3: break
    print(n)                   # 0 1 2 3

# yield from delegates to a sub-iterator
def chained():
    yield from range(3)
    yield from range(3, 6)      # 0 1 2 3 4 5

Generators turn “compute the whole list” into “compute on demand.” This is how for line in open(file) reads a 10GB file without loading it into memory, and how range(10**9) allocates nothing.

Context managers

with open("file.txt") as f:    # __enter__ opens; __exit__ closes - even on error
    data = f.read()
# f is closed here, guaranteed

# Write your own with __enter__/__exit__, or with contextlib
from contextlib import contextmanager

@contextmanager
def timer():
    import time; t0 = time.perf_counter()
    try:
        yield
    finally:
        print(f"took {time.perf_counter() - t0:.3f}s")

with timer():
    do_work()

The with statement is Python’s RAII. Use it for anything that acquires and releases: files, locks, database connections, timers.

Exceptions: EAFP, not LBYL

Python style is EAFPEasier to Ask Forgiveness than Permission. Try the operation, catch the failure, instead of checking first (LBYL, Look Before You Leap). EAFP avoids race conditions and reads cleaner:

# EAFP (preferred)
try:
    value = d[key]
except KeyError:
    value = default

# LBYL (less Pythonic)
value = d[key] if key in d else default

# Full structure
try:
    ...
except ValueError as e:
    ...
except (KeyError, IndexError):
    ...
else:                       # runs only if no exception
    ...
finally:                    # always runs
    ...

# Raising
raise ValueError("bad") from original_error

Define your own exceptions by subclassing:

class AppError(Exception): pass
class RetryError(AppError): pass

Stage 3 — OOP and the Data Model

Classes and dataclasses

class Counter:
    def __init__(self, start=0):
        self.count = start
    def inc(self):
        self.count += 1
        return self
    def __repr__(self):
        return f"Counter({self.count})"

c = Counter(); c.inc(); print(c)   # Counter(1)

For data-holding classes, dataclasses write the boilerplate for you:

from dataclasses import dataclass, field

@dataclass
class Point:
    x: float
    y: float
    label: str = ""        # default
    tags: list = field(default_factory=list)   # mutable default -> factory

    def __post_init__(self):
        if self.label:
            self.tags.append(self.label)

p = Point(1, 2, "origin")  # __init__, __repr__, __eq__ generated

Use @dataclass(frozen=True, slots=True) for immutable, memory-compact records. slots=True (and 3.10+ __slots__) prevents adding arbitrary attributes and saves memory.

The data model: dunder methods

The Python data model is the heart of the language. Python syntax (len(x), a + b, for i in x, x == y, with x:) is sugar for dunder method calls. Implement the dunder and your type works with the syntax:

Python Data Model

class Vector:
    def __init__(self, x, y): self.x, self.y = x, y
    def __repr__(self): return f"Vector({self.x}, {self.y})"
    def __eq__(self, o): return (self.x, self.y) == (o.x, o.y)
    def __add__(self, o): return Vector(self.x + o.x, self.y + o.y)
    def __iter__(self): yield self.x; yield self.y
    def __len__(self): return 2
    def __getitem__(self, i): return (self.x, self.y)[i]
    def __call__(self, scale): return Vector(self.x * scale, self.y * scale)

v = Vector(1, 2)
v + Vector(3, 4)    # Vector(4, 6)  via __add__
for c in v: ...     # 1, 2         via __iter__
v(10)              # Vector(10, 20) via __call__

You don’t have to implement all dunders — only the ones that matter for how your type is used. A type that implements __iter__ works in for loops; one that implements __enter__/__exit__ works in with; one that implements __lt__ works with sorted. No inheritance required.

Protocols and duck typing

Python’s typing is structural (“duck typing”): if an object has the methods a function calls, it’s the right type, regardless of its class. This is formalized as protocols in the type system (Stage 4) and ABCs at runtime:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

class Square(Shape):
    def __init__(self, s): self.s = s
    def area(self) -> float: return self.s * self.s

Square(2).area()  # 4.0
# Shape()  # TypeError - can't instantiate abstract class

Prefer composition over deep inheritance. Python allows multiple inheritance via C3 linearization (MRO), but multi-level inheritance hierarchies are usually a sign you want composition + protocols instead.

Stage 4 — Type Hints and Modern APIs

Type hints are optional, non-enforced at runtime, and checked by external tools (mypy, pyright). They are documentation the compiler (well, the checker) can verify, and they make large codebases navigable.

Basic hints

def parse(s: str) -> int:
    return int(s)

items: list[int] = [1, 2, 3]
mapping: dict[str, list[int]] = {"a": [1, 2]}

from typing import Optional, Union
def f(x: Optional[int] = None) -> Union[int, str]: ...
# 3.10+ uses | syntax
def f(x: int | None = None) -> int | str: ...

Generics, Protocol, ParamSpec

from typing import TypeVar, Generic, Protocol, ParamSpec, Callable

T = TypeVar("T")

def first(xs: list[T]) -> T:          # generic: works for any T
    return xs[0]

class Sized(Protocol):                 # structural type
    def __len__(self) -> int: ...

def use(s: Sized) -> None: ...         # accepts anything with __len__

P = ParamSpec("P")
R = TypeVar("R")
def logged(fn: Callable[P, R]) -> Callable[P, R]: ...   # decorator typing

A Protocol is a structural type: anything with the right methods matches, no inheritance. ParamSpec lets you type decorators that preserve the wrapped function’s signature.

Pydantic and FastAPI

Pydantic validates data at boundaries and gives you typed models with serialization:

from pydantic import BaseModel

class User(BaseModel):
    id: int
    email: str
    tags: list[str] = []

u = User(id=1, email="a@b.com")        # validated + coerced
u.model_dump()                         # {'id': 1, 'email': 'a@b.com', 'tags': []}

FastAPI builds an HTTP API on top of Pydantic + type hints — the hints are the schema:

from fastapi import FastAPI

app = FastAPI()

@app.get("/users/{uid}")
def get_user(uid: int) -> User:
    return User(id=uid, email="x@y.com")

That uid: int gives you validation, a 422 on bad input, and an OpenAPI doc for free. This is the modern Python web stack: type hints + Pydantic + FastAPI.

mypy and pyright

pip install mypy
mypy --strict mypkg/             # check the whole package
# pyright (faster, VS Code): pip install pyright; pyright

Mark packages as typed with a py.typed marker file so downstream users get your types. Run in CI; --strict is aggressive but catches real bugs.

Stage 5 — Concurrency and the Toolchain

asyncio

asyncio is single-threaded, cooperative concurrency for I/O-bound work. An async def returns a coroutine; you await other coroutines; the event loop multiplexes many coroutines on one thread:

import asyncio

async def fetch(url: str) -> str:
    await asyncio.sleep(0.1)     # non-blocking - yields to loop
    return f"data from {url}"

async def main():
    results = await asyncio.gather(
        fetch("a"), fetch("b"), fetch("c"),    # concurrent
    )
    print(results)

asyncio.run(main())              # run the loop

.await suspends the coroutine and returns control to the loop, which runs other ready coroutines. You get concurrency without threads — thousands of connections on one OS thread is normal.

Async generators (async yield), async for, and async with compose naturally. The rule: don’t mix blocking calls into async code — a blocking call freezes the whole loop. Offload blocking work to a thread executor or use async-native libraries.

Threads, processes, and the GIL

Python Concurrency

Python has three concurrency models, and the right one depends on the work:

  • asyncio — I/O-bound, many waits, awaitable libraries. Single thread, cooperative.
  • threading — I/O-bound but with blocking calls you can’t await. Threads share the GIL, so they don’t parallelize CPU work, but they release the GIL during I/O so other threads run.
  • multiprocessing — CPU-bound work. Separate interpreters, no GIL, true parallelism, but serialization overhead for passing data.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

with ThreadPoolExecutor() as ex:
    results = list(ex.map(io_task, urls))        # I/O-bound

with ProcessPoolExecutor() as ex:
    results = list(ex.map(cpu_task, chunks))     # CPU-bound

The GIL and free-threading (3.13)

The Global Interpreter Lock (GIL) lets only one thread execute Python bytecode at a time. It makes threading simple and C-extension embedding safe, but it means threads can’t run CPU-bound Python in parallel — multiprocessing is the workaround.

Python 3.13 ships an experimental free-threaded build (python3.13t) that disables the GIL, allowing true thread-level parallelism for CPU work. It’s opt-in, behind a flag, and the ecosystem is still catching up — treat it as the future, not the present, and keep using multiprocessing for production CPU parallelism today.

The Toolchain

Python Toolchain

Modern Python packaging:

# Create an isolated virtual environment
python -m venv .venv
.venv\Scripts\activate          # Windows
source .venv/bin/activate        # macOS/Linux

# Or use uv (much faster, Rust-based)
uv venv
uv pip install -r requirements.txt

# Install dependencies
pip install requests pytest ruff mypy

# Lock dependencies (pin versions)
pip freeze > requirements.txt     # legacy
uv lock                           # modern, with uv.lock

# Run tests
pytest                            # discovers test_*.py, runs all
pytest -k "parse"                 # by name pattern
pytest --cov=mypkg                # coverage

# Lint + format (ruff replaces flake8 + black + isort)
ruff check .                      # lint
ruff format .                     # format

# Type check
mypy --strict mypkg/
pyright

A minimal pyproject.toml:

[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["requests", "pydantic"]

[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]

[tool.ruff]
line-length = 100

[tool.mypy]
strict = true

Essential tooling:

  • venv / uv venv — always work in an isolated environment; never pollute system Python.
  • pip / uv — package installation from PyPI. uv is ~10–100× faster.
  • pytest — the test runner; fixtures, parametrization, plugins.
  • ruff — lint and format in one tool, replacing flake8/black/isort. Run it in CI and on save.
  • mypy / pyright — type checking. Run in CI.
  • coverage.py — measure test coverage.
  • pre-commit — run ruff/mypy/pytest before every commit.

A Quick-Start Checklist

  1. Install Python 3.12+ and create a venv (or use uv venv) for every project.
  2. Learn the data model — when you reach for a class, reach for dunder methods.
  3. Use comprehensions and generators before reaching for map/filter or manual loops.
  4. Always with for resources; always try/except for “may fail” operations.
  5. Add type hints to public functions; run mypy --strict or pyright in CI.
  6. Validate at boundaries with Pydantic; build APIs with FastAPI.
  7. Pick the right concurrency model — asyncio for I/O, multiprocessing for CPU, threading only when you must mix blocking calls.
  8. Run ruff + pytest + mypy in CI and via pre-commit.

Common Pitfalls

  • Mutable default argumentsdef f(x=[]) shares one list across all calls. Use x=None then x = [] inside, or field(default_factory=list) in dataclasses.
  • is vs ==is checks identity, == checks equality. Use == for values; reserve is for None, singletons, and sentinel checks.
  • Iterating and mutating — modifying a list while iterating it skips elements. Iterate over a copy (for x in list(lst):) or build a new list.
  • async without await — an async def with no await runs synchronously and adds event-loop overhead for nothing.
  • Blocking calls in async codetime.sleep, requests.get, sync file I/O in an async function freezes the loop. Use async equivalents or run_in_executor.
  • Shallow copiescopy.copy shares nested objects; copy.deepcopy recurses. Watch for shared mutable state.
  • == None — use is None; == can be overridden and behave unexpectedly.

What to Learn Next

Python’s depth is in its data model and its ecosystem. The syntax is easy; the data model — dunders, protocols, generators — is what makes Python Python. Once those are reflexes, you stop fighting the language and start using it as designed.

Good luck — and run ruff.

Resources:

Watch PyShine on YouTube

Contents