more below ↓

Monads, in Python

One pattern for chaining steps that carry extra baggage:
missing values, errors, many answers, logs, config, and state.

Part 1 · Practice

Maybe, Result, List, Writer, Reader, State. All in plain Python you can run.

Part 2 · Laws

Three laws that make chaining safe to refactor, and how to test them.

Part 3 · Theory

Categories, functors, (T, η, μ), Kleisli arrows and adjunctions.

Teaching excerpts may use definitions introduced in this deck. The complete standalone lab is included below; its displayed output and checks were reproduced during this review.

Start with a concrete question

How can we compose successful steps while keeping missing values or errors explicit?

Your goal

Understand the data, the rule, and the required result. Then run the complete Python program and modify one thing at a time.

No companion presentation is required. Python syntax and any mathematical notation are explained as they are introduced.

Vocabulary you need before the algorithm

unit

Wrap a plain value in the chosen context.

map

Transform a value without changing its context.

bind

Run a context-returning next step and combine contexts.

Law

A statement about identity/association under specified semantics.

Work one example before writing code

parse_integer('4') → Ok(4) → reciprocal → Ok(.25). parse_integer('bee') → Err('not an integer'), so reciprocal is skipped.

Trace each step and name what changed. Ask which fact stayed true and what should happen on an empty, invalid, or boundary input.

Roadmap WHERE WE GO

  1. The problem: plumbing between steps
  2. Intuition: boxes on a conveyor belt
  3. Functors: map inside a box
  4. Monads: unit + bind
  5. The three monad laws
  6. Maybe — values that may be missing
  7. Proving the laws for Maybe
  8. Result — errors with a reason
  9. List — many possible answers
  10. Writer — values plus a log
  11. Reader — shared config
  12. State — threading state, purely
  13. Do-notation with generators
  14. Common mistakes
  15. Math: categories & functors
  16. Math: natural transformations
  17. Math: monad as (T, η, μ)
  18. Math: the Kleisli category
  19. Math: adjunctions make monads
  20. Applicative, and where monads show up in real Python
  21. Check yourself, summary, glossary

The Problem: Plumbing Between Steps MOTIVATION

Find a user's zip code. Every lookup can fail. The real logic is three lines. The rest is plumbing.

Without a pattern

Teaching excerpt · complete runnable lab available from “Code”
def zip_for(uid):
    user = USERS.get(uid)
    if user is None:
        return None
    aid = user.get("address_id")
    if aid is None:
        return None
    addr = ADDRS.get(aid)
    if addr is None:
        return None
    return addr.get("zip")

With a monad

Teaching excerpt · complete runnable lab available from “Code”
def zip_for(uid):
    return (get(USERS, uid)
            .bind(lambda u: get(u, "address_id"))
            .bind(lambda a: get(ADDRS, a))
            .bind(lambda addr: get(addr, "zip")))

The “stop if missing” rule now lives in one place: bind.

Same shape repeats for errors, logging, config, state, async, and parsing. A monad is that shared shape.

Intuition: Boxes on a Conveyor Belt BEFORE THE DEFINITIONS

Keep one picture in your head for the rest of the talk. The words box, map, and bind will all hang on it.

The picture

  • Each step of your program is a station on a belt. It takes a plain item and hands back an item in a box.
  • The box carries extra context: “maybe empty”, “failed, here is why”, “several items”, “plus a log line”.
  • Between stations stands a worker called bind. It opens the box, follows that box's rule, and feeds the item to the next station.
  • An empty box? The worker skips every later station. That is the whole Maybe monad.
Step shapeAfter one step
mapA → BF B
map with a boxing stepA → F BF (F B)
bindA → F BF B

See the nesting problem with plain lists

Teaching excerpt · complete runnable lab available from “Code”
nums = [1, 2, 3]
both = lambda x: [x, -x]      # a step that returns a box

print(list(map(both, nums)))  # map: boxes inside a box
print([y for x in nums for y in both(x)])  # bind: flat
Example output
[[1, -1], [2, -2], [3, -3]]
[1, -1, 2, -2, 3, -3]

map keeps the extra layer. After three steps you would have boxes in boxes in boxes. bind is map followed by flatten, so the shape stays F B no matter how long the chain.

Hold on to this: monad = a box type whose steps can be chained without nesting. The next slides make it exact.

Step 1: Functors — map Inside a Box FMAP

A functor is a box type F with a way to apply a plain function to what is inside.

Signature

fmap : (A → B) → F A → F B

Functor laws

fmap id = id

fmap (g ∘ f) = fmap g ∘ fmap f

Law 1: mapping “do nothing” does nothing. Law 2: two maps equal one map of the composed function.

You already use functors

Teaching excerpt · complete runnable lab available from “Code”
list(map(str.upper, ["a", "b"]))   # list functor
# ['A', 'B']

{k: v * 2 for k, v in d.items()}    # dict values

# Optional as a functor, by hand:
def fmap_opt(f, x):
    return None if x is None else f(x)

The limit: map cannot handle an f that itself returns a box. You get F(F B). Monads fix that.

Step 2: A Monad = unit + bind DEFINITION

A monad is a type constructor M with two operations:

unit : A → M A

bind : M A → (A → M B) → M B

  • unit (Haskell: return / pure) puts a plain value in the box.
  • bind (Haskell: >>=) opens the box, feeds the value to the next step, and flattens the result.
  • The box decides the rules. Stop early? Run many times? Add to a log?

Picture it

M a f : a → M b M b unwrap a flatten m.bind(f) : the box's rules run between steps

Minimal Python interface: a static unit(x) and a method bind(self, f). map comes for free: m.bind(lambda x: unit(f(x))).

The Three Monad Laws WHY CHAINS ARE SAFE

Write m >>= f for m.bind(f). Every lawful monad must satisfy:

LawEquationPlain meaning
Left identityunit(a) >>= f  =  f(a)Wrapping then binding adds nothing.
Right identitym >>= unit  =  mBinding to “just wrap it” changes nothing.
Associativity(m >>= f) >>= g  =  m >>= (λx. f(x) >>= g)Grouping of steps does not matter.

Test them like any property

Teaching excerpt · complete runnable lab available from “Code”
f = lambda x: Just(x + 1)
g = lambda x: Just(x * 10) if x < 100 else Nothing()
for a in [1, 99, 150]:
    m = Just(a)
    assert Maybe.unit(a).bind(f) == f(a)
    assert m.bind(Maybe.unit) == m
    assert m.bind(f).bind(g) == m.bind(lambda x: f(x).bind(g))
print("laws ok")

Why you care

  • Associativity lets you extract a helper from the middle of a chain. Behavior stays the same.
  • Identity laws let you add or drop a trivial wrap freely.
  • In Part 3 we will see these are exactly the category laws in disguise.

Maybe: Values That May Be Missing SHORT-CIRCUIT

Teaching excerpt · complete runnable lab available from “Code”
from dataclasses import dataclass

class Maybe:
    @staticmethod
    def unit(x): return Just(x)

@dataclass(frozen=True)
class Just(Maybe):
    value: object
    def map(self, f):  return Just(f(self.value))
    def bind(self, f): return f(self.value)

@dataclass(frozen=True)
class Nothing(Maybe):
    def map(self, f):  return self
    def bind(self, f): return self   # stop here
Teaching excerpt · complete runnable lab available from “Code”
USERS = {1: {"name": "Ada", "address_id": 10},
         2: {"name": "Bob"}}
ADDRS = {10: {"city": "London", "zip": "N1 9GU"}}

def get(d, k):
    return Just(d[k]) if k in d else Nothing()

def zip_for(uid):
    return (get(USERS, uid)
            .bind(lambda u: get(u, "address_id"))
            .bind(lambda a: get(ADDRS, a))
            .bind(lambda addr: get(addr, "zip")))

print(zip_for(1), zip_for(2), zip_for(9))
Example output
Just(value='N1 9GU') Nothing() Nothing()

Math view: Maybe A = 1 + A (a disjoint sum). unit is the right injection.

Proving the Laws for Maybe BY CASES

The laws slide tested three inputs. A proof covers every input. For Maybe it is short: a value is either Nothing() or Just(v), so check both cases.

1. Left identity

unit(a).bind(f)

= Just(a).bind(f)

= f(a)  ✓

Only one case: unit always builds a Just. The last step is the definition of Just.bind.

2. Right identity

Case m = Nothing():

Nothing().bind(unit) = Nothing()  ✓

Case m = Just(v):

Just(v).bind(unit) = unit(v) = Just(v)  ✓

3. Associativity

Case Nothing(): both sides skip every step and give Nothing(). ✓

Case Just(v):

LHS = Just(v).bind(f).bind(g) = f(v).bind(g)

RHS = Just(v).bind(λx. f(x).bind(g)) = f(v).bind(g)

Same expression, so equal. ✓

The pattern to reuse

Split on the shapes a value can take, then unfold bind and unit by their definitions until both sides match. The same method proves the laws for Result (Ok / Err). For List you use induction on the length of the list.

Result (Either): Errors With a Reason RAILWAY

Like Maybe, but the failure carries a message. Think of two rails: once on the error rail, you stay there.

Teaching excerpt · complete runnable lab available from “Code”
@dataclass(frozen=True)
class Ok:
    value: object
    def bind(self, f): return f(self.value)

@dataclass(frozen=True)
class Err:
    error: str
    def bind(self, f): return self

def parse_int(s):
    try: return Ok(int(s))
    except ValueError: return Err(f"not a number: {s!r}")
def positive(n):
    return Ok(n) if n > 0 else Err(f"must be > 0, got {n}")
def safe_recip(n):
    return Ok(1 / n)

for s in ["4", "-2", "abc"]:
    print(Ok(s).bind(parse_int).bind(positive).bind(safe_recip))
Example output
Ok(value=0.25)
Err(error='must be > 0, got -2')
Err(error="not a number: 'abc'")

Type

EitherE A = E + A

unit = inr     (inl e) >>= f = inl e

Ok rail Err rail parse positive recip

This is Python's try/except made into a value. You can store it, return it, and pass it around.

List: Many Possible Answers NONDETERMINISM

Each step may return 0, 1, or many results. bind tries every branch and concatenates.

Teaching excerpt · complete runnable lab available from “Code”
class ListM:
    def __init__(self, xs): self.xs = list(xs)
    @staticmethod
    def unit(x): return ListM([x])
    def bind(self, f):
        return ListM(y for x in self.xs for y in f(x).xs)

guard = lambda ok: ListM([None]) if ok else ListM([])

def triples(n):
    return ListM(range(1, n)).bind(lambda a:
           ListM(range(a, n)).bind(lambda b:
           ListM(range(b, n)).bind(lambda c:
           guard(a*a + b*b == c*c).bind(lambda _:
           ListM.unit((a, b, c))))))

print(triples(20).xs)
Example output
[(3, 4, 5), (5, 12, 13), (6, 8, 10), (8, 15, 17), (9, 12, 15)]

Surprise: you already knew this one

Teaching excerpt · complete runnable lab available from “Code”
[(a, b, c) for a in range(1, 20)
           for b in range(a, 20)
           for c in range(b, 20)
           if a*a + b*b == c*c]

A list comprehension is list-monad do-notation. Each for is a bind. The if is guard.

Math: unit(x) = [x], and join = concat. The list monad is the free monoid monad. More on that in the adjunction slide.

Writer: Values Plus a Log ACCUMULATE

Teaching excerpt · complete runnable lab available from “Code”
@dataclass(frozen=True)
class Writer:
    value: object
    log: tuple = ()
    @staticmethod
    def unit(x): return Writer(x)          # empty log
    def bind(self, f):
        w = f(self.value)
        return Writer(w.value, self.log + w.log)

def double(x): return Writer(x * 2, (f"doubled {x}",))
def inc(x):    return Writer(x + 1, (f"incremented {x}",))

print(Writer.unit(5).bind(double).bind(inc))
Example output
Writer(value=11, log=('doubled 5', 'incremented 10'))

Needs a monoid (W, ⋅, e) for the log:

WriterW A = A × W

unit(a) = (a, e)

(a, w) >>= f = let (b, w′) = f(a) in (b, w ⋅ w′)

Monoid laws (e is an identity, ⋅ is associative) give the monad laws directly. Tuples with +, ints with +, sets with | all work.

Use it for audit trails, cost counters, or collecting warnings without a global logger.

Reader: Shared Config Without Globals DEPENDENCY INJECTION

Teaching excerpt · complete runnable lab available from “Code”
from typing import Callable

@dataclass(frozen=True)
class Reader:
    run: Callable                    # env -> value
    @staticmethod
    def unit(x): return Reader(lambda env: x)
    def bind(self, f):
        return Reader(lambda env: f(self.run(env)).run(env))

ask = Reader(lambda env: env)        # read the config

def greeting(name):
    return ask.bind(lambda cfg:
           Reader.unit(f"{cfg['greet']}, {name}!"))

def page(name):
    return greeting(name).bind(lambda g:
           ask.bind(lambda cfg:
           Reader.unit(f"<{cfg['tag']}>{g}</{cfg['tag']}>")))

print(page("Ada").run({"greet": "Hello", "tag": "h1"}))
print(page("Ada").run({"greet": "Hola",  "tag": "p"}))
Example output
<h1>Hello, Ada!</h1>
<p>Hola, Ada!</p>

Type: a function from an environment E.

ReaderE A = E → A

unit(a) = λe. a

(m >>= f)(e) = f(m(e))(e)

Build the whole program first. Supply the config once, at the edge, with .run(env). No function in the middle takes cfg as a parameter.

Swap in a test config to unit-test the same program.

State: Threading State, Purely NO MUTATION

Teaching excerpt · complete runnable lab available from “Code”
@dataclass(frozen=True)
class State:
    run: Callable              # s -> (value, new_s)
    @staticmethod
    def unit(x): return State(lambda s: (x, s))
    def bind(self, f):
        def step(s):
            a, s2 = self.run(s)
            return f(a).run(s2)  # pass new state along
        return State(step)

get_s = State(lambda s: (s, s))
def put(n): return State(lambda s: (None, n))

def rand():                    # a tiny LCG
    return get_s.bind(lambda seed:
           put((1103515245 * seed + 12345) % 2**31).bind(lambda _:
           State.unit(seed % 100)))

three = rand().bind(lambda a: rand().bind(lambda b:
        rand().bind(lambda c: State.unit([a, b, c]))))
print(three.run(42))
print(three.run(42))           # same seed, same result
Example output
([42, 27, 64], 1000676753)
([42, 27, 64], 1000676753)

Type

StateS A = S → A × S

unit(a) = λs. (a, s)

(m >>= f)(s) = let (a, s′) = m(s) in f(a)(s′)

The program is a description. Nothing runs until you call .run(seed). That makes it replayable and easy to test.

State receives a state and returns a value plus a new state, and bind passes that new state to the next action. This is not the bind of a naive Reader/Writer composition; the stated product/exponential adjunction gives the actual state monad.

Do-Notation With Generators NO MORE LAMBDA STAIRS

One mutable generator cannot replay reusable Reader/State actions or clone branching List continuations. The verified example uses fresh pure descriptions instead.

Nested lambdas get ugly. Python generators can pause at each yield, so they can act like Haskell's do block.

Teaching excerpt · complete runnable lab available from “Code”
def do(unit):
    def deco(genfn):
        def run(*args):
            gen = genfn(*args)
            def step(value):
                try:
                    m = gen.send(value)     # next monadic value
                except StopIteration as done:
                    return unit(done.value) # 'return x' -> unit(x)
                return m.bind(step)         # continue after bind
            return step(None)
        return run
    return deco
Teaching excerpt · complete runnable lab available from “Code”
@do(Maybe.unit)
def zip_for(uid):
    user = yield get(USERS, uid)
    aid  = yield get(user, "address_id")
    addr = yield get(ADDRS, aid)
    return addr["zip"]

print(zip_for(1), zip_for(2))
Example output
Just(value='N1 9GU') Nothing()

A helper closing over one mutable generator can support one-shot linear Maybe/Result/Writer evaluation. Reusable deferred Reader/State runs and branching List need a fresh continuation strategy; the downloadable Reader example uses reusable pure bind functions.

Common Mistakes AND HOW TO SPOT THEM

These four trip up almost every student. Each one has a quick tell.

1. Using map where you need bind

Teaching excerpt · complete runnable lab available from “Code”
print(Just(4).map(lambda x: Just(x + 1)))
print(Just(4).bind(lambda x: Just(x + 1)))
Example output
Just(value=Just(value=5))
Just(value=5)

Tell: a box inside a box. If the step returns a box, use bind.

2. A bind step that forgets the box

Teaching excerpt · complete runnable lab available from “Code”
r = Just(4).bind(lambda x: x + 1)   # returns 5, not Just(5)
r.bind(lambda y: Just(y * 2))
Example output
AttributeError: 'int' object has no attribute 'bind'

Tell: the chain breaks one step later than the bug. Every step given to bind must return a box. Wrap plain results with unit, or use map.

3. “A monad is a container”

Maybe and List look like containers. Reader and State do not. Their “box” is a function waiting for a config or a state. Nothing is stored until you run it.

Better words: a monad is a context for a computation, with a rule for chaining steps in that context.

4. “Monads are about side effects”

Haskell uses a monad for I/O, so the two get mixed up. But Maybe, Result, List, Writer, Reader, and State are all pure. Same input, same output, nothing touched outside.

A monad models an effect as a plain value. That is why you can test it with ==.

Quick self-check

Before you write .bind(step), ask: does step return the same kind of box I started with? If yes, bind. If it returns a plain value, map.

Part 3 · The Math

Where the word “monad” comes from, and why the laws look the way they do.

1958–1965

Godement (“standard construction”), Huber, Kleisli, Eilenberg–Moore study monads in algebraic topology.

1989–1991

Eugenio Moggi: monads model computational effects in programming language semantics.

1992–1995

Philip Wadler brings them to functional programming. Haskell adopts them for I/O.

Categories OBJECTS + ARROWS

A category 𝒞 has:

  • objects A, B, C, …
  • arrows f : A → B, with a set Hom(A, B) for each pair
  • composition g ∘ f : A → C for f : A → B, g : B → C
  • an identity idA : A → A for each object

Laws

h ∘ (g ∘ f) = (h ∘ g) ∘ f

f ∘ idA = f = idB ∘ f

The one we care about: Py

  • Objects: Python types (int, str, User).
  • Arrows: pure functions between them.
  • Composition: lambda x: g(f(x)).
  • Identity: lambda x: x.
Teaching excerpt · complete runnable lab available from “Code”
compose = lambda g, f: lambda x: g(f(x))
identity = lambda x: x

Strictly, Haskell's category Hask needs care around non-termination. We treat pure, total Python functions as a sketch of Set.

Functors and Natural Transformations MAPS BETWEEN MAPS

A functor F : 𝒞 → 𝒟 maps each object A to F A, and each arrow f : A → B to F f : F A → F B, so that

F(idA) = idFA     F(g ∘ f) = F g ∘ F f

An endofunctor maps a category to itself. list, Maybe, Reader[E] are endofunctors on Py. fmap is the arrow part.

A natural transformation α : F ⇒ G is a family of arrows αA : F A → G A such that for every f : A → B:

G f ∘ αA = αB ∘ F f

F AG A F BG B αAαB F fG f

Python example: head_opt : list ⇒ Maybe. Mapping then taking the head equals taking the head then mapping. Naturality = “works the same for any element type”.

A Monad Is (T, η, μ) THE CATEGORICAL DEFINITION

A monad on 𝒞 is an endofunctor T : 𝒞 → 𝒞 with two natural transformations:

η : Id ⇒ T    (unit)

μ : T∘T ⇒ T    (join / multiplication)

Coherence laws

μ ∘ Tμ = μ ∘ μT    (associativity)

μ ∘ Tη = idT = μ ∘ ηT    (unit)

This is exactly the shape of a monoid (M, ⋅, e), with ⋅ replaced by μ and e by η. Hence the slogan: “a monad is a monoid in the category of endofunctors.”

associativity square T³T² T²T Tμμ μTμ unit triangles TT²T T ηTTη μ idid

Both paths through each diagram give the same arrow. For lists: flattening a list of lists of lists inside-first or outside-first gives the same list.

Programmer's Monad = Mathematician's Monad BIND ↔ JOIN

The two definitions carry the same information. Each can be built from the other.

From (η, μ) to bind

m >>= f  =  μ(T f (m))

Map f inside to get T(T B), then flatten.

From bind to (η, μ)

μ(mm) = mm >>= id     T f (m) = m >>= (η ∘ f)

Under these maps, the three bind laws hold iff the functor laws plus the square and triangles hold. That is a short, standard proof by rewriting.

Teaching excerpt · complete runnable lab available from “Code”
def join(mm):                 # mu
    return mm.bind(lambda m: m)

def fmap(f, m, unit):         # T f
    return m.bind(lambda x: unit(f(x)))

def bind_via_join(m, f, unit):
    return join(fmap(f, m, unit))

print(join(Just(Just(3))), join(Just(Nothing())))
Example output
Just(value=3) Nothing()
Monadημ
MaybeJustJust(Just x) ↦ Just x
List[x]concat
Writer(x, e)((x, w₁), w₂) ↦ (x, w₂⋅w₁)
Readerconst xλe. f(e)(e)

The Kleisli Category WHY THE LAWS ARE CATEGORY LAWS

Kleisli category 𝒞T of a monad T:

  • Objects: same as 𝒞.
  • Arrows A ⇝ B: ordinary arrows A → T B (“effectful functions”).
  • Identity: ηA : A → T A.
  • Composition (the “fish”):

(f >=> g)(x) = f(x) >>= g

Monad laws, restated

η >=> f = f    f >=> η = f

(f >=> g) >=> h = f >=> (g >=> h)

So what?

The three odd-looking laws are just identity + associativity for composing effectful functions. A monad is precisely what makes A → T B functions compose like normal ones.

Teaching excerpt · complete runnable lab available from “Code”
def kleisli(f, g):            # f >=> g
    return lambda x: f(x).bind(g)

pipeline = kleisli(kleisli(parse_int, positive), safe_recip)
print(pipeline("8"), pipeline("0"))
Example output
Ok(value=0.125) Err(error='must be > 0, got 0')

Moggi's insight: a program A → B with effects is a Kleisli arrow A → T B. Pick T to pick the effect.

Where Monads Come From: Adjunctions F ⊣ G

Adjunction F ⊣ G with F : 𝒞 → 𝒟, G : 𝒟 → 𝒞 means a natural bijection

Hom𝒟(F A, B) ≅ Hom𝒞(A, G B)

with unit η : Id ⇒ GF and counit ε : FG ⇒ Id.

Theorem (Huber 1961). Every adjunction gives a monad on 𝒞:

T = G F,    η,    μ = G ε F

Conversely (Kleisli, Eilenberg–Moore 1965), every monad arises from some adjunction.

AdjunctionMonad GF
Free monoid ⊣ forget (Set ↔ Mon)List
(– × S) ⊣ (S → –) (currying)State S → (– × S)
Free pointed set ⊣ forgetMaybe 1 + –

Algebras of a monad

An Eilenberg–Moore algebra is a : T A → A with a ∘ η = id and a ∘ T a = a ∘ μ. For List, algebras are exactly monoids. sum on list[int] is one.

Teaching excerpt · complete runnable lab available from “Code”
assert sum([sum(xs) for xs in [[1, 2], [3]]]) == sum([1, 2, 3])  # a.Ta = a.mu

Functor → Applicative → Monad POWER VS. ANALYSIS

The accumulating Validation applicative and fail-fast Result monad have different semantics. Fixed structure permits analysis; parallelism also requires suitable effects.

Each level adds power. Every monad is also an applicative, and every applicative is also a functor.

LevelOperationCan the next step depend on the previous result?Math
Functorfmap : (A→B) → F A → F BNo second step at allendofunctor
Applicativeap : F(A→B) → F A → F BApplicative structure fixes the computation graph before results are known. Parallel execution also requires suitable underlying effects and independence/commutativity; it is not automatic.lax monoidal functor
Monadbind : M A → (A → M B) → M BYes. The next step is chosen from the value.monoid in [𝒞, 𝒞]

Applicative example: validate all fields

Teaching excerpt · complete runnable lab available from “Code”
def validate_all(*results):
    errs = [r.error for r in results if isinstance(r, Err)]
    return Err("; ".join(errs)) if errs else Ok(tuple(r.value for r in results))

print(validate_all(parse_int("x"), parse_int("7"), positive(-1)))
Example output
Err(error="not a number: 'x'; must be > 0, got -1")

An error-accumulating Validation applicative differs from the fail-fast applicative induced by Result's monadic bind. Choose the intended semantics explicitly.

Rule of thumb

Use the weakest interface that works. Less power means more freedom for the runtime: parallel runs, static analysis, better error reports.

Monads Hiding in Everyday Python IN THE WILD

Python featureMonad it resemblesWhere the “bind” is
List / generator comprehensionsListeach nested for
async / awaitFuture / Task (continuation)x = await fut runs the rest once fut is done
ExceptionsEither (built into the language)each statement, implicitly
Optional[T] + early returnMaybethe if x is None: return None line
contextvars, dependency containersReaderimplicit lookup of the environment
Parser combinators (e.g. parsy)State + Either + List.bind / @generate

Libraries

returns (dry-python) gives typed Maybe, Result, IO, and RequiresContext with mypy support. parsy and pyparsing use monadic parsing.

When not to

Python has no type classes and no tail calls. Deep bind chains cost stack frames. Use the idea to shape APIs; do not force Haskell style on a team that does not want it.

Check Yourself ANSWER, THEN CLICK

Try each one on paper first. Uses Just, Nothing, and ListM from the earlier slides.

Q1

Teaching excerpt · complete runnable lab available from “Code”
Just(3).bind(lambda x: Nothing()).bind(lambda y: Just(y * 2))

What is the result?

Show answer
Example output
Nothing()

The second step returns Nothing(), so every later bind is skipped.

Q2

Teaching excerpt · complete runnable lab available from “Code”
ListM([1, 2]).bind(lambda x: ListM([x, x * 10])).xs

What is the result?

Show answer
Example output
[1, 10, 2, 20]

Each input gives two outputs, and bind concatenates them in order.

Q3

You pull three steps out of the middle of a long bind chain into a helper. Which law says the result will not change?

Show answer

Associativity. Grouping the steps differently gives the same answer.

Q4

Why is map alone not enough to chain steps that can fail?

Show answer

A failing step returns a box, so map gives a box in a box, F(F B). You need join to flatten it. bind = map then join.

Q5

What does μ (join) do for Maybe on each of the three inputs Just(Just(x)), Just(Nothing()), Nothing()?

Show answer

Just(x), Nothing(), Nothing(). Only a full box inside a full box survives.

Q6

Given f : A → M B and g : B → M C, write their Kleisli composition in Python. What is its identity?

Show answer

lambda x: f(x).bind(g). The identity is unit.

Summary TAKE-AWAYS

Remember

  • A monad is a box M with unit and bind.
  • bind holds the plumbing: stop, branch, log, read, or pass state.
  • Three laws: two identities and associativity.
  • Math view: (T, η, μ), a monoid of endofunctors.
  • Kleisli view: effectful functions that compose.
MonadTypeEffect
Maybe1 + Amay be missing
EitherE + Amay fail with a reason
ListA*many answers
WriterA × Wadds to a log
ReaderE → Areads config
StateS → A × Sreads and writes state

Further reading

  • Moggi, Notions of Computation and Monads (1991)
  • Wadler, Monads for Functional Programming (1995)
  • Mac Lane, Categories for the Working Mathematician, ch. VI
  • Milewski, Category Theory for Programmers

Glossary ONE PAGE OF WORDS

TermMeaning
FunctorA box type with map (fmap) that obeys the two functor laws
MonadA box type with unit and bind that obeys the three monad laws
unit / return / pure / ηPut a plain value into the box
bind / >>= / flatMapRun a step that returns a box, then flatten
join / μFlatten one layer: M(M A) → M A
ApplicativeBetween functor and monad: combine boxes whose steps do not depend on each other
EffectThe extra thing the box tracks: failure, many results, a log, config, state
Short-circuitSkip the rest of a chain once a step fails
TermMeaning
Do-notationSyntax that hides the bind calls; in Python, generators or comprehensions
CategoryObjects plus arrows that compose, with identities; composition is associative
EndofunctorA functor from a category back to itself, like list on Python types
Natural transformationA map F ⇒ G between functors that commutes with every fmap
Kleisli arrowAn effectful function A → M B
Kleisli composition >=>Chain two Kleisli arrows: lambda x: f(x).bind(g)
Adjunction F ⊣ GA pair of functors; G∘F is always a monad
MonoidA set with an associative operation and an identity, like (str, +, "")

Run a complete program

How can we compose successful steps while keeping missing values or errors explicit?

The file contains its own imports, data generator or fixture, implementation, and checks.

python3 -m venv .venv
source .venv/bin/activate
# No third-party packages required
python monads_python.py
python monads_python.py --self-test

Use Python 3.10+. On Windows, activate with .venv\Scripts\Activate.ps1. Required packages: Python standard library.

Connect the concept to the implementation

Read the contract first. Identify the inputs, intermediate state, and return value in this excerpt from the verified file.

def bind(self, function):
        """Pre: continuation returns Maybe. Post: apply it to the present value."""
        result = function(self.value)
        if not isinstance(result, (Just, Nothing)):
            raise TypeError("Maybe.bind continuation must return Maybe")
        return result

Compare with an actual run

Successful result: Ok(value=0.25)
Invalid input: Err(message='not an integer')
Zero input: Err(message='division by zero')
A present None differs from absence: Just(value=None) Nothing()
Reusable Reader: Hello, Ada Hola, Ada

Output was captured from the complete program running in an isolated temporary directory. Floating-point digits and compressed sizes may vary across versions/hardware.

Check the result, then try an exercise

Verified checks

38 checks passed, including boundary cases and independent reference comparisons where applicable. These executed checks complement the reasoning; they are not a universal proof.

Run the same Reader computation under Hello and Hola environments, then Hello again. Why is one captured mutable generator unsuitable?

Explain the expected result

A reusable Reader must rerun a pure description for each environment. Reusing an already advanced generator shares execution state and can break replay.

Implement it yourself and validate it

  1. Run the downloadable program and its checks.
  2. Reimplement Just.bind from its contract.
  3. Compare outputs with the reference on valid, empty, and invalid cases.
  4. Explain the limitations of the algorithm or experiment.
All coding labs
Decks