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.
- Fundamentals — variables, dynamic typing, collections, functions, scope
- Idiomatic Python — comprehensions, iterators, generators, context managers, exceptions
- OOP + Data Model — classes, dataclasses, dunder methods, protocols, ABCs
- Type Hints + APIs —
typing, generics,Protocol, Pydantic, FastAPI, mypy/pyright - 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 EAFP — Easier 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:
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 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
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.uvis ~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
- Install Python 3.12+ and create a
venv(or useuv venv) for every project. - Learn the data model — when you reach for a class, reach for dunder methods.
- Use comprehensions and generators before reaching for
map/filteror manual loops. - Always
withfor resources; always try/except for “may fail” operations. - Add type hints to public functions; run
mypy --strictorpyrightin CI. - Validate at boundaries with Pydantic; build APIs with FastAPI.
- Pick the right concurrency model — asyncio for I/O, multiprocessing for CPU, threading only when you must mix blocking calls.
- Run
ruff+pytest+mypyin CI and viapre-commit.
Common Pitfalls
- Mutable default arguments —
def f(x=[])shares one list across all calls. Usex=Nonethenx = []inside, orfield(default_factory=list)in dataclasses. isvs==—ischecks identity,==checks equality. Use==for values; reserveisforNone, 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. asyncwithoutawait— anasync defwith noawaitruns synchronously and adds event-loop overhead for nothing.- Blocking calls in async code —
time.sleep,requests.get, sync file I/O in an async function freezes the loop. Use async equivalents orrun_in_executor. - Shallow copies —
copy.copyshares nested objects;copy.deepcopyrecurses. Watch for shared mutable state. == None— useis None;==can be overridden and behave unexpectedly.
What to Learn Next
- The Tutorial — docs.python.org/3/tutorial the official, thorough walkthrough.
- Fluent Python by Luciano Ramalho — the canonical “write Python well” book; the data model chapter alone is worth it.
- Effective Python by Brett Slatkin — 90 specific habits.
- PEP 8 — peps.python.org/pep-0008 style; ruff enforces it.
- typing docs — docs.python.org/3/library/typing for
Protocol,ParamSpec, generics. - asyncio docs — docs.python.org/3/library/asyncio and the FastAPI tutorial for async web.
- Real Python — realpython.com for topic deep-dives.
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:
- Official docs: https://docs.python.org/3/
- PyPI: https://pypi.org/
- uv: https://github.com/astral-sh/uv
- ruff: https://github.com/astral-sh/ruff
- FastAPI: https://fastapi.tiangolo.com/
- Pydantic: https://docs.pydantic.dev/
Enjoyed this post? Never miss out on future posts by following us