Learn Python

Python is a dynamically typed, garbage-collected, multi-paradigm language created by Guido van Rossum and first released in 1991. It prizes readability above almost everything else: significant indentation instead of braces, a small set of orthogonal constructs, and a culture summarized in the Zen of Python ('readable counts'). The current feature release is Python 3.14, shipped on 7 October 2025, which made the free-threaded (no-GIL) build officially supported and added template strings, deferred annotations, and a tail-call interpreter. This track teaches Python from the ground up for the Eulingua reader who already knows another language such as Guji or Go: installing the interpreter and setting up tooling, the syntax and dynamic type system, functions and the many ways to pass arguments, the four core collection types, idiomatic error handling with exceptions and exception groups, concurrency with asyncio and threads, and the packaging ecosystem around pip, venv, and uv. Every example here was run against a real CPython interpreter, and every lesson links to the official documentation at docs.python.org.

Setup and the Python Toolchain

Install Python, run your first program, and set up virtual environments.

Setup and the Python Toolchain

Python is an interpreted language: you run .py source files directly, no separate compile step. The reference implementation is CPython, and the command that runs it is python3 (or just python on many systems). This lesson takes you from zero to a running program with a clean, isolated environment.

Installing Python

Download an installer for your platform from the official site, or use a package manager (brew install python, apt install python3, etc.). After installing, confirm the version:

$ python3 --version
Python 3.14.0

As of 7 October 2025 the current feature release is Python 3.14. Python ships a new feature release every October and supports each one for five years, so 3.14 receives bug fixes and security patches well into the 2030s. The previous series, 3.13, still gets maintenance releases (3.13.14 landed on 10 June 2026).

Your first program

Python needs no main function and no boilerplate. Create a file named hello.py:

print("Hello, Python!")

Run it:

$ python3 hello.py
Hello, Python!

You can also start an interactive REPL by running python3 with no arguments. The 3.14 REPL has color, multi-line editing, and syntax highlighting built in - excellent for experiments:

$ python3
>>> 2 ** 10
1024
>>> "ha" * 3
'hahaha'

The __main__ guard is a near-universal idiom. It lets a file act both as a runnable script and an importable module:

def main():
    print("Hello, Python!")

if __name__ == "__main__":
    main()

When you run the file directly, __name__ equals "__main__" and main() is called; when another module imports it, the guard is false and nothing runs.

Virtual environments: isolate every project

Never install project dependencies into the system Python. Instead create a virtual environment - a self-contained directory with its own copy of the interpreter and its own site-packages. The standard tool is venv, which ships with Python:

$ python3 -m venv .venv
$ source .venv/bin/activate      # Windows: .venv\Scripts\activate
(.venv) $ python -m pip install requests
(.venv) $ deactivate

While the environment is active, python and pip refer to the ones inside .venv. Deactivating restores your shell. Add .venv/ to .gitignore; it is rebuildable from your dependency list.

uv: the fast modern alternative

In 2026 many teams reach for uv, a Rust-based tool from Astral that replaces pip, venv, pip-tools, and pyenv with one binary. It is dramatically faster - creating an environment that python -m venv takes about a second to build finishes in roughly ten milliseconds. A typical uv workflow:

$ uv init myapp && cd myapp
$ uv add requests
$ uv run main.py

uv reads and writes the standard pyproject.toml, so it interoperates with the rest of the ecosystem. We cover packaging in depth in the final lesson; for now, python -m venv is always available and is the right thing to learn first.

The commands you will use daily

Command What it does
python3 script.py Run a script
python3 -m module Run a module as a script (e.g. -m venv, -m pip)
python3 -c 'code' Run a one-liner
python -m pip install pkg Install a package into the active environment
python -m pytest Run tests (after installing pytest)

Note the python -m pip form rather than a bare pip - it guarantees you are using the pip that belongs to the interpreter you think you are using.

Where to go next

The official tutorial is thorough and beginner-friendly, and the setup guide covers installation on every platform.

With the interpreter working and an environment activated, you are ready to learn the language itself, starting with its syntax and types.

Syntax, Variables, and the Type System

Indentation, dynamic typing, the core scalar types, and control flow.

Syntax, Variables, and the Type System

Python's syntax is famous for using indentation to delimit blocks instead of braces. This is not optional style - it is the grammar. A consistent four-space indent groups statements; a misaligned line is a syntax error. The result is that all idiomatic Python looks structurally similar, much as gofmt enforces in Go.

Variables are names bound to objects

Python is dynamically typed: a variable is just a name pointing at an object, and you never declare a type. Reassign freely:

name = "Ada"
count = 0
pi = 3.14159
name = 42          # legal: name now points at an int
print(type(name))  # <class 'int'>

Everything is an object, including functions and classes. There is no separate notion of a primitive value.

Type hints: optional, static, ignored at runtime

Since Python 3.5 you can annotate names and signatures. These type hints are checked by external tools like mypy or pyright but have no effect at runtime - Python never enforces them:

name: str = "Ada"
age: int = 37

def area(w: float, h: float) -> float:
    return w * h

Hints are how modern Python codebases get IDE autocompletion and catch type errors before running. In Python 3.14 (PEP 649/749) annotations are evaluated lazily, which makes forward references work without quoting.

The core scalar types

  • int - arbitrary precision; 2 ** 200 is exact, no overflow.
  • float - 64-bit IEEE 754 double.
  • bool - True / False (a subtype of int).
  • str - immutable Unicode text.
  • bytes - immutable raw bytes.
  • None - the unique null/absent value.
big = 2 ** 200
print(big)         # 1606938044258990275541962092341162602522202993782792835301376
print(10 / 3)      # 3.3333333333333335  (true division, always float)
print(10 // 3)     # 3   (floor division)
print(10 % 3)      # 1   (modulo)

Strings and f-strings

Strings are immutable and Unicode. The idiomatic way to build them is the f-string, a literal prefixed with f that interpolates expressions inside {}:

name, age = "Ada", 37
print(f"{name} is {age}")        # Ada is 37
print(f"pi is about {3.14159:.2f}")  # pi is about 3.14
print(f"{age = }")               # age = 37   (the = shows the expression too)

Python 3.14 also adds t-strings (PEP 750): a t"..." literal returns a Template object that keeps the literal and interpolated parts separate, which libraries use for safe HTML/SQL building. f-strings remain what you reach for in everyday code.

Truthiness

Every object is either truthy or falsy. Empty containers ("", [], {}, ()), 0, and None are falsy; almost everything else is truthy. This drives idioms like if items: to mean "if the list is non-empty."

Control flow

if / elif / else, for, and while cover the basics. A for loop iterates over any iterable, not an index:

for ch in "abc":
    print(ch)

for i, val in enumerate(["a", "b", "c"]):
    print(i, val)

while count < 3:
    count += 1

The walrus operator := assigns inside an expression, handy in conditions:

if (n := len(name)) > 2:
    print(f"name has {n} characters")

Pattern matching

Since Python 3.10, match / case does structural pattern matching - far more than a C switch. It can destructure sequences and capture parts:

def describe(cmd: str) -> str:
    match cmd.split():
        case ["go", direction]:
            return f"moving {direction}"
        case ["stop"]:
            return "stopping"
        case [action, *rest]:
            return f"{action} with {rest}"
        case _:
            return "unknown"

print(describe("go north"))  # moving north
print(describe("jump high")) # jump with ['high']

The case _ is the wildcard default. Patterns can also match by class and extract attributes.

Reference

The official tutorial chapters on control flow and on the data model explain the why behind these rules.

Next we package behavior into functions and learn Python's unusually flexible argument passing.

Functions, Arguments, and Closures

Defaults, *args/**kwargs, keyword-only params, lambdas, and decorators.

Functions, Arguments, and Closures

Functions are first-class objects in Python: you can assign them to variables, pass them as arguments, return them, and attach attributes. Python's calling convention is also one of the richest of any mainstream language - defaults, keyword arguments, variadic collection, and keyword-only parameters all compose.

Defining and calling

def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

greet("Ada")                 # 'Hello, Ada!'
greet("Ada", "Hi")           # 'Hi, Ada!'
greet(name="Ada", greeting="Hey")  # by keyword, any order

Parameters with a = have default values. Callers may pass arguments positionally or by keyword, which makes call sites self-documenting.

The mutable default trap

Default values are evaluated once, when the function is defined - not on each call. A mutable default like [] is therefore shared across calls and is a classic bug:

def bad(item, bucket=[]):     # DON'T: shared list
    bucket.append(item)
    return bucket

def good(item, bucket=None):  # DO: sentinel + fresh list
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

*args and **kwargs

A *args parameter collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict:

def report(*nums, **labels):
    return sum(nums), labels

report(1, 2, 3, scale="m")   # (6, {'scale': 'm'})

The same * and ** work at the call site to unpack a sequence or mapping into arguments:

values = [1, 2, 3]
report(*values)              # spreads into nums
opts = {"scale": "km"}
report(1, **opts)            # spreads into labels

Keyword-only and positional-only parameters

A bare * in the signature forces every parameter after it to be passed by keyword - useful for flags that should never be a mystery positional True:

def connect(host, *, timeout=30, secure=True):
    ...

connect("db", timeout=5)     # ok
# connect("db", 5)           # TypeError: timeout is keyword-only

Symmetrically, parameters before a / are positional-only.

Lambdas and closures

A lambda is a small anonymous function limited to a single expression. Functions also form closures, capturing variables from the enclosing scope:

def adder(n):
    return lambda x: x + n

add5 = adder(5)
print(add5(10))              # 15

The returned lambda remembers n. Lambdas shine as throwaway callbacks, e.g. sorted(words, key=lambda w: len(w)).

Decorators

Because functions are values, you can write a function that wraps another to add behavior. The @decorator syntax applies it:

import functools, time

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

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

work(1_000_000)

@timed is exactly work = timed(work). Decorators power much of the framework ecosystem - Flask routes, pytest fixtures, @dataclass, @property, and more.

Generators

A function that uses yield is a generator: it produces values lazily, one at a time, holding no more than the current one in memory:

def fib():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

import itertools
print(list(itertools.islice(fib(), 8)))  # [0, 1, 1, 2, 3, 5, 8, 13]

Generators let you iterate over infinite or very large sequences with constant memory.

Reference

Next: the four collection types you will reach for in nearly every program.

Collections: Lists, Tuples, Dicts, and Sets

The four built-in containers and the comprehensions that build them.

Collections: Lists, Tuples, Dicts, and Sets

Python gives you four general-purpose built-in containers. Knowing which to reach for - and the comprehension syntax that builds each one - is most of day-to-day Python.

Lists: ordered, mutable

A list is a growable, ordered sequence written with square brackets:

nums = [3, 1, 2]
nums.append(4)        # [3, 1, 2, 4]
nums.sort()           # [1, 2, 3, 4]  (in place)
nums[0] = 99          # mutable
print(nums[-1])       # 4   (negative indexes from the end)
print(nums[1:3])      # [2, 3]  (slicing)

Slicing seq[start:stop:step] works on any sequence and never raises for out-of-range bounds. nums[::-1] reverses a list.

Tuples: ordered, immutable

A tuple is a fixed sequence written with parentheses (or just commas). Because it cannot change, it can be a dict key and signals "these belong together":

point = (3, 4)
x, y = point          # unpacking
print(x, y)           # 3 4
first, *rest = [1, 2, 3, 4]
print(first, rest)    # 1 [2, 3, 4]

Multiple return values are really just a tuple: return a, b then lo, hi = f().

Dicts: key/value mappings

A dict maps keys to values and, since Python 3.7, preserves insertion order. Lookups, inserts, and deletes are average O(1):

ages = {"Ada": 37, "Bob": 24}
ages["Cleo"] = 50
print(ages["Ada"])          # 37
print(ages.get("Dan", 0))   # 0   (default avoids KeyError)

for name, age in ages.items():
    print(name, age)

Use .get(key, default) or in to avoid KeyError. Merge dicts with the | operator: defaults | overrides.

Sets: unordered unique elements

A set holds distinct, hashable values and supports fast membership tests and mathematical set algebra:

a = {1, 2, 3}
b = {2, 3, 4}
print(a & b)    # {2, 3}   intersection
print(a | b)    # {1, 2, 3, 4}  union
print(a - b)    # {1}      difference
print(2 in a)   # True

Deduplicate a list with list(set(items)) (order is not preserved).

Comprehensions

The single most Pythonic feature is the comprehension: a compact expression that builds a collection from an iterable, optionally filtering. There is one for each container:

squares = [n * n for n in range(5)]            # list  -> [0, 1, 4, 9, 16]
evens = [n for n in range(10) if n % 2 == 0]   # with a filter
lengths = {w: len(w) for w in ["a", "bb"]}     # dict  -> {'a': 1, 'bb': 2}
letters = {c for c in "banana"}                # set   -> {'b', 'a', 'n'}

Wrapping the same syntax in parentheses gives a lazy generator expression, which streams values without building the whole collection in memory:

total = sum(n * n for n in range(1_000_000))   # constant memory

Prefer a comprehension over a manual for loop with .append() when you are transforming or filtering - it is faster and reads as a single intention. Do not abuse them: if logic grows past a line or two, a plain loop is clearer.

Choosing a container

Need Use
Ordered, will change list
Fixed record / dict key tuple
Lookup by key dict
Uniqueness / set math set

For more, the collections module adds Counter, defaultdict, deque, and namedtuple, and dataclasses gives you typed record classes with almost no boilerplate.

Reference

Next: what happens when something goes wrong - Python's exception model.

Error Handling with Exceptions

try/except/else/finally, raising, custom exceptions, and exception groups.

Error Handling with Exceptions

Python reports errors by raising exceptions and handling them with try / except. Unlike Go's explicit error returns, Python's control flow jumps to the nearest matching handler. The cultural rule is EAFP - "it's Easier to Ask Forgiveness than Permission" - meaning you try the operation and catch the failure rather than checking preconditions up front.

try / except / else / finally

def parse(s):
    try:
        n = int(s)
    except ValueError as e:
        return f"bad input: {e}"
    else:
        return f"parsed {n}"     # runs only if no exception
    finally:
        print("done")            # always runs, error or not

parse("42")    # prints 'done', returns 'parsed 42'
parse("abc")   # prints 'done', returns 'bad input: ...'
  • except catches a specific exception type. The as e binds the exception object.
  • else runs only when the try block did not raise.
  • finally always runs - for cleanup like closing files, even if an exception propagates.

Catch the narrowest exception you can handle. A bare except: or except Exception: that swallows everything hides bugs.

Catching multiple types

List several types in a tuple. In Python 3.14 (PEP 758) you may drop the parentheses when there is no as clause:

try:
    risky()
except (ValueError, KeyError) as e:   # works on every version
    handle(e)

except ValueError, KeyError:          # 3.14+: parentheses optional
    handle_them()

Raising and re-raising

Use raise to signal an error, and a bare raise inside an except block to re-throw the current one after logging:

def withdraw(balance, amount):
    if amount > balance:
        raise ValueError(f"cannot withdraw {amount} from {balance}")
    return balance - amount

When you catch one error and raise another, chain them with from so the traceback shows both causes:

try:
    config = load()
except FileNotFoundError as e:
    raise RuntimeError("startup failed") from e

Custom exceptions

Define your own by subclassing Exception. A small hierarchy lets callers catch your whole library's errors with one except:

class AppError(Exception):
    """Base class for this application's errors."""

class NotFoundError(AppError):
    pass

try:
    raise NotFoundError("user 7")
except AppError as e:        # catches NotFoundError too
    print("handled:", e)

Context managers replace finally

The with statement guarantees cleanup without a manual finally. The most common use is files, which are closed automatically when the block exits, even on error:

with open("data.txt", encoding="utf-8") as f:
    text = f.read()
# f is closed here, no matter what

You can write your own context managers as a class with __enter__/__exit__, or far more easily with @contextlib.contextmanager around a generator.

Exception groups (3.11+)

Sometimes several errors happen at once - think a batch of parallel tasks where three fail. An ExceptionGroup bundles them, and the except* syntax handles each kind separately, even when both occur:

try:
    raise ExceptionGroup(
        "batch failed",
        [ValueError("v"), TypeError("t")],
    )
except* ValueError as eg:
    print("values:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
    print("types:", [str(e) for e in eg.exceptions])

Both except* blocks can run. This is the foundation of robust error handling in asyncio.TaskGroup, covered next.

Reference

Next: doing many things at once with asyncio, threads, and the free-threaded build.

Concurrency: asyncio, Threads, and Free Threading

async/await, TaskGroup, the GIL, threads vs processes, and no-GIL Python.

Concurrency: asyncio, Threads, and Free Threading

Python offers three distinct concurrency models, and choosing the right one depends entirely on whether your work is I/O-bound (waiting on the network or disk) or CPU-bound (crunching numbers). This lesson covers all three plus the headline change of Python 3.14: officially supported free-threaded builds.

The GIL, briefly

Historically CPython has a Global Interpreter Lock (GIL): only one thread executes Python bytecode at a time. This makes single-threaded code fast and C extensions simple, but it means threads cannot run pure-Python CPU work in parallel. The GIL shapes every concurrency decision below.

asyncio: cooperative concurrency for I/O

For I/O-bound work - web servers, scrapers, API clients - asyncio lets a single thread juggle thousands of tasks. You write async def coroutines and await points where the task yields control while waiting:

import asyncio

async def fetch(name, delay):
    await asyncio.sleep(delay)      # yields to other tasks while waiting
    return f"{name} done"

async def main():
    results = await asyncio.gather(
        fetch("a", 0.1),
        fetch("b", 0.1),
        fetch("c", 0.1),
    )
    print(results)                  # all three finish in ~0.1s, not 0.3s

asyncio.run(main())

asyncio.gather runs the coroutines concurrently. They overlap their waiting, so three 0.1-second sleeps complete in about 0.1 seconds total. Nothing runs truly in parallel - but while one task waits on I/O, others make progress.

TaskGroup: structured concurrency (3.11+)

The modern way to launch and await a set of tasks is asyncio.TaskGroup. It waits for every task in its block and, if any fail, cancels the rest and raises an ExceptionGroup (from the previous lesson):

async def main():
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(fetch("x", 0.05))
        t2 = tg.create_task(fetch("y", 0.05))
    print(t1.result(), t2.result())   # both guaranteed complete here

This "structured concurrency" guarantees no task is left dangling when the block exits.

Threads: for blocking I/O and C extensions

Threads suit blocking I/O in libraries that are not async-aware. The concurrent.futures.ThreadPoolExecutor is the friendly high-level API:

from concurrent.futures import ThreadPoolExecutor

def download(url):
    ...   # a blocking call

with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(download, urls))

Because of the GIL, threads do not speed up pure-Python CPU work in the classic build - they overlap only when blocked on I/O or inside C code that releases the GIL (as requests, numpy, and file I/O do).

Processes: for CPU-bound work

To use multiple cores for CPU-bound Python, use ProcessPoolExecutor. Each process has its own interpreter and its own GIL, so they run in genuine parallel - at the cost of pickling arguments and results across process boundaries:

from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor() as pool:
    results = list(pool.map(heavy_compute, chunks))

Free-threaded Python (PEP 703 / PEP 779)

Python 3.14, released 7 October 2025, makes the free-threaded build officially supported (PEP 779). This optional build removes the GIL entirely, replacing it with per-object locking and biased reference counting, so threads can run pure-Python CPU work on multiple cores at once - no separate processes needed.

The trade-offs as of 3.14: single-threaded code runs roughly 5–10% slower (down from 20–40% in the 3.13 preview), and C extensions must be recompiled against the new ABI. You opt in by installing the t variant and the interpreter reports it:

$ python3.14t -c "import sys; print(sys._is_gil_enabled())"
False

This is a multi-year transition; the GIL build remains the default. Python 3.14 also adds concurrent.interpreters (PEP 734), exposing multiple isolated interpreters in one process as another parallelism option.

Choosing a model

Workload Reach for
Many network/disk calls asyncio
Blocking I/O in non-async libs ThreadPoolExecutor
CPU-bound, classic build ProcessPoolExecutor
CPU-bound, parallel threads free-threaded build (3.14+)

Reference

Finally, let us look at how Python projects manage dependencies and ship to the world.

The Ecosystem: pip, pyproject, and PyPI

Dependencies, pyproject.toml, building and publishing, and the wider toolchain.

The Ecosystem: pip, pyproject, and PyPI

Python's reach comes from its enormous third-party ecosystem. The Python Package Index (PyPI) hosts hundreds of thousands of libraries, and pip installs them. This lesson covers how to manage dependencies, structure a project with pyproject.toml, and publish your own package - plus the tooling that keeps a codebase healthy.

Installing packages with pip

With a virtual environment active (lesson one), install from PyPI:

(.venv) $ python -m pip install requests rich
(.venv) $ python -m pip list

Pin and record your dependencies so others can reproduce the environment. The traditional file is requirements.txt:

(.venv) $ python -m pip freeze > requirements.txt
(.venv) $ python -m pip install -r requirements.txt   # on another machine

Always pin versions for applications; libraries usually specify looser ranges.

pyproject.toml: the standard project file

Modern Python projects are configured by a single pyproject.toml (standardized in PEP 518/621). It declares your package's metadata, dependencies, and build system in one place:

[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "requests>=2.32",
    "rich>=13.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

The [build-system] table names the build backend - hatchling, setuptools, flit, or pdm - that turns your source into a distributable artifact. Tools like pytest, ruff, and mypy also read their configuration from this same file under [tool.*] tables.

uv: the fast all-in-one workflow

The biggest tooling shift of recent years is uv, the Rust-based manager from Astral. It manages environments, resolves and locks dependencies into uv.lock, and runs your code, all reading the standard pyproject.toml:

$ uv init myapp && cd myapp
$ uv add requests          # adds to pyproject.toml and the lockfile
$ uv run python app.py     # runs in the managed environment
$ uv sync                  # recreate the env from the lockfile

uv is a drop-in replacement for pip, pip-tools, virtualenv, and pyenv combined, and is typically several times faster. The official Packaging User Guide now lists it among recommended tools, alongside the still-perfectly-valid pip plus venv. pip itself is not going away.

Building and publishing to PyPI

To share a library, build the distribution artifacts - a source distribution (sdist) and a wheel - then upload them. With the standard tools:

$ python -m build               # writes dist/*.whl and dist/*.tar.gz
$ python -m twine upload dist/* # publish to PyPI

With uv the same two steps are uv build and uv publish. PyPI now requires trusted publishing or API tokens rather than passwords, and publishing from CI via OpenID Connect is the recommended path for projects.

The wider toolchain

A healthy Python project leans on a few standard tools, all configurable from pyproject.toml:

Tool Job
ruff Extremely fast linter and formatter (replaces flake8, isort, black)
mypy / pyright Static type checking of your type hints
pytest The de-facto testing framework
tox / nox Run tests across multiple Python versions

A typical check sequence in CI looks like:

$ ruff format --check .
$ ruff check .
$ mypy src
$ pytest

The standard library is huge

Before reaching for PyPI, remember Python ships "batteries included": json, pathlib, datetime, subprocess, sqlite3, http.server, dataclasses, itertools, re, and dozens more are always available. Many tasks need no dependency at all.

from pathlib import Path
import json

data = json.loads(Path("config.json").read_text())

Reference

That completes the track. You can now set up Python, write idiomatic code across its type and collection systems, handle errors, run concurrent work, and ship a package - the same ground the Eulingua reader covers in tracks like Guji and Go. Keep the official docs at docs.python.org close; they are excellent.