PONYλM2Modula-2

Haskell.CodeCompared.To/Python

An interactive executable cheatsheet comparing Haskell and Python

GHC 9.12 Python 3.14
Hello World & Running
Hello, World
The type signature and the main both disappear. A Python file is executed top to bottom the moment it is handed to the interpreter, so a statement at module level runs immediately and there is no entry point to declare.
main :: IO () main = putStrLn "Hello, World!"
print("Hello, World!")
That immediacy is the first real difference in feel: there is no compile step, no IO () to be inside, and no distinction between a declaration and an action. It also means a module's top level is an effect — importing a file runs every statement in it, which is why the if __name__ == "__main__" guard exists and why import-time side effects are a real hazard in Python codebases.
Nothing is checked before it runs
Both print the same line. The difference is what happens to the branch that never runs — Python does not look at it at all until it is reached.
-- The compiler reads every branch. A misspelled name, a type -- mismatch, or a missing case is caught before the program exists. main :: IO () main = do let total = sum [3, 1, 4, 1, 5] if total > 1000 then putStrLn "large" -- both branches type-checked else putStrLn ("total: " ++ show total)
total = sum([3, 1, 4, 1, 5]) if total > 1000: print(mispelled_name) # never evaluated, never complained about else: print("total:", total) # The name above does not exist. Nothing notices until that branch runs, # which may be in production, months from now.
This is the shape of nearly every loss on this page, so it is worth sitting with once: a Haskell programmer's confidence comes from the compiler having read everything, and Python's does not exist by default. The replacements are real but opt-in and partial — mypy or pyright catch the name and most type errors, ruff catches a broad class of mistakes, and tests catch what neither does. A serious Python codebase runs all three in continuous integration, which is the closest thing available to the guarantee you are giving up.
Purity Becomes a Convention
🚨 Any function may do anything
Nothing separates the two functions. Both are def, both may read a file, mutate a global, or print — and the caller cannot tell by looking at the signature, because the signature does not carry that information.
import Data.IORef -- The type says it: this one cannot touch the outside world. double :: Int -> Int double value = value * 2 -- And this one announces that it can. countCall :: IORef Int -> IO Int countCall counter = do modifyIORef counter (+ 1) readIORef counter main :: IO () main = do print (double 21) counter <- newIORef 0 _ <- countCall counter total <- countCall counter print total
calls = 0 def double(value): return value * 2 # pure, by convention only def count_call(): global calls # and nothing in the signature said so calls += 1 return calls print(double(21)) count_call() print(count_call())
The IO type disappearing is the single largest thing a Haskell programmer gives up, and no Python feature replaces it. What replaces it is discipline that experienced Python programmers already practise for their own reasons: keep functions small and pure where you can, push input and output to the edges, pass dependencies in rather than reaching for globals, and make anything that mutates say so in its name. That is the same architecture you would build in Haskell — it is simply not enforced, so a code review is the type checker.
Values are mutable, and shared
Two changes at once, and they compound: a Python list can be modified in place, and assignment binds a second name to the same list rather than copying it.
-- Nothing is modified. Every "change" is a new value, and the old -- one is still there, unchanged, for anyone holding it. main :: IO () main = do let first = [1, 2, 3] second = first ++ [4] print first print second
first = [1, 2, 3] second = first # a second NAME for one list, not a copy second.append(4) print(first) # [1, 2, 3, 4] — the append changed both print(second) # The Haskell reading is spelled explicitly: third = first + [5] # a NEW list; first is untouched print(first) print(third)
So the habit to build is to notice which operations mutate. list.append, list.sort, dict.update and set.add all modify in place and return None; +, sorted and a comprehension all build something new. Choosing the second kind gets you most of Haskell's reasoning back. For data that must not change there are tuple, frozenset and @dataclass(frozen=True) — all genuinely immutable, and all worth reaching for.
Types Become Annotations
A signature the language ignores
The annotation looks like a signature and behaves like a comment. Python stores it and never consults it — the second call passes strings where the annotation says integers, and produces output rather than an error.
-- The signature is checked, and it is also inferred: leave it off -- and GHC works out the most general type for you. scale :: Int -> [Int] -> [Int] scale factor = map (* factor) main :: IO () main = do print (scale 2 [1, 2, 3]) -- print (scale 2 ["a"]) -- would not compile
def scale(factor: int, values: list[int]) -> list[int]: return [value * factor for value in values] print(scale(2, [1, 2, 3])) # The annotation says list[int]. Nothing checks it at run time, and # string repetition is a real operation, so this runs and produces # something surprising rather than an error: print(scale(3, ["a", "b"]))
The recovery is a separate tool: mypy or pyright read the same annotations and reject that second call, and a project that runs one in continuous integration gets a meaningful fraction of the guarantee back. Two things that do not come back: there is no inference across a whole program, so an unannotated function is simply unchecked, and there is nothing like a type class constraint driving which implementation runs — annotations never affect behavior. Write them anyway; they are the best documentation Python has.
No newtype, so no free safety
typing.NewType is the nearest equivalent and it is a type-checker-only construct: at run time UserId(7) is the integer 7, so the wrong call runs perfectly happily.
-- A newtype costs nothing at run time and makes the two impossible -- to confuse. This is the cheapest safety in the language. newtype UserId = UserId Int deriving (Show) newtype OrderId = OrderId Int deriving (Show) describe :: UserId -> String describe (UserId value) = "user " ++ show value main :: IO () main = do putStrLn (describe (UserId 7)) -- putStrLn (describe (OrderId 7)) -- would not compile
from typing import NewType UserId = NewType("UserId", int) OrderId = NewType("OrderId", int) def describe(user: UserId) -> str: return f"user {user}" print(describe(UserId(7))) # A type checker rejects the next line. The interpreter does not: # at run time UserId(7) IS just 7, and this prints "user 7". print(describe(OrderId(7)))
The stronger option is a frozen dataclass wrapping the value, which is a real distinct type at run time and costs an allocation and an attribute access. That is the trade to weigh per case: NewType is free and checked only by a tool, a wrapper class is real and costs something. Neither gives you the deriving machinery, so __eq__ and __hash__ come from the dataclass decorator rather than from deriving (Eq, Ord).
Laziness Becomes Generators
Laziness stops being the default
A generator is opt-in laziness: yield suspends the function and resumes it when the next value is asked for, and a generator expression in parentheses is the lazy form of a comprehension.
-- Every value is lazy, so an infinite list is ordinary and the -- consumer decides how much of it exists. naturals :: [Integer] naturals = [1 ..] main :: IO () main = do print (take 5 (map (* 2) naturals)) print (take 3 (filter even naturals))
import itertools # Everything is eager by default. An infinite sequence needs a # GENERATOR, which is opt-in laziness with its own syntax. def naturals(): value = 1 while True: yield value value += 1 doubled = (value * 2 for value in naturals()) print(list(itertools.islice(doubled, 5))) evens = (value for value in naturals() if value % 2 == 0) print(list(itertools.islice(evens, 3)))
The correspondence is closer than it looks — take is itertools.islice, iterate is itertools.accumulate, cycle and repeat keep their names, and zip is lazy in Python 3. What is genuinely gone is laziness by default: a Python list comprehension builds the whole list, so an accidental list(naturals()) hangs, and there is no equivalent of a lazily-defined recursive value such as fibs = 0 : 1 : zipWith (+) fibs (tail fibs). In exchange, evaluation order is obvious and there are no space leaks to reason about.
The space leak goes away
A whole category of Haskell expertise becomes unnecessary here: no thunks, no foldl-versus-foldl', no bang patterns, no seq, and no space leak to find with heap profiling.
import Data.List (foldl') -- foldl builds a thunk chain and can exhaust memory on a large list; -- foldl' forces as it goes. Knowing which to reach for is part of -- writing Haskell. main :: IO () main = do print (foldl' (+) 0 [1 .. 100000]) print (sum [1 .. 100000])
# There is no thunk, so there is no thunk chain, so there is no # choice to make and no space leak to diagnose. print(sum(range(1, 100001))) # The evaluation is exactly what it looks like: one pass, one # accumulator, nothing deferred. total = 0 for value in range(1, 100001): total += value print(total)
This is worth naming as a genuine gain rather than a consolation. Python's memory behavior is what the code says: a comprehension allocates its list, a generator does not, and there is nothing deferred waiting to be forced. range is the one thing that behaves lazily and it is a special object rather than a general mechanism. The cost is the other side of the same coin — you cannot express a computation that produces only as much as is demanded without writing a generator on purpose.
Functions & Composition
No currying, and no partial application by default
A Python function of two parameters takes two arguments, full stop. functools.partial is the explicit form of what currying gives you for free.
-- Every function of two arguments IS a function returning a -- function, so partial application is free and pervasive. add :: Int -> Int -> Int add a b = a + b main :: IO () main = do let addTen = add 10 print (addTen 5) print (map (add 100) [1, 2, 3])
from functools import partial def add(a: int, b: int) -> int: return a + b # Partial application is a library call, not a consequence of how # functions work. add_ten = partial(add, 10) print(add_ten(5)) print([add(100, value) for value in [1, 2, 3]]) # A lambda is the other option, and is more common in practice: add_hundred = lambda value: add(100, value) print(add_hundred(1))
The idiomatic Python is usually neither partial nor a lambda but a comprehension, which is why the second line reads the way it does — map and filter exist and lose to comprehensions on readability nearly every time. The habit to unlearn is point-free style: it does not survive the trip, and code written that way reads as obscure to Python programmers rather than as elegant.
Composition becomes nesting
Nesting replaces composition, and a comprehension usually replaces both — one expression doing the filter and the map together, which is what a Python reader expects to see.
-- . composes and $ removes parentheses. A pipeline is a value. process :: [Int] -> Int process = sum . map (* 2) . filter even main :: IO () main = print (process [1 .. 10])
# There is no composition operator and no $ — the pipeline is # written as nesting, read from the inside out. def process(values: list[int]) -> int: return sum(value * 2 for value in values if value % 2 == 0) print(process(range(1, 11))) # The generic version, when the steps are functions you already have: import functools compose = lambda *functions: functools.reduce( lambda outer, inner: lambda value: outer(inner(value)), functions ) print(compose(sum, lambda values: [v * 2 for v in values])([2, 4]))
The generic compose at the bottom is written out to make a point: it is possible, it is in nobody's codebase, and reaching for it marks the code as foreign. The Python instinct is to name intermediate steps with ordinary variables rather than to build a pipeline value, and taking that instinct seriously is what makes Haskell-influenced Python readable to the people maintaining it. There is no $, so parentheses are the whole story.
Folds have names
functools.reduce is foldl and it is deliberately out of the way — it was moved out of the builtins in Python 3 on the grounds that a named function or an explicit loop is clearer.
main :: IO () main = do let values = [1, 2, 3, 4, 5] print (foldr (+) 0 values) print (sum values) print (maximum values) print (length (filter even values))
from functools import reduce values = [1, 2, 3, 4, 5] # reduce exists, and is deliberately unfashionable: Guido moved it out # of the builtins because a named function or a loop reads better. print(reduce(lambda total, value: total + value, values, 0)) # What Python programmers actually write: print(sum(values)) print(max(values, default=0)) print(sum(1 for value in values if value % 2 == 0))
The specialized folds are the idiom instead: sum, max, min, any, all, math.prod, and sum(1 for … if …) for counting. That covers the great majority of real folds, and reaching for reduce where one of them applies is the clearest sign of a functional programmer writing Python. There is no foldr at all, because without laziness a right fold over a large list has no advantage and a real stack cost.
Data Types Become Classes
A record becomes a dataclass
@dataclass generates the constructor, __eq__ and __repr__ — which is deriving (Show, Eq) — and frozen=True makes the instance immutable. replace is the record-update syntax.
data Person = Person { name :: String , age :: Int } deriving (Show, Eq) main :: IO () main = do let ada = Person { name = "Ada", age = 36 } older = ada { age = 37 } print older print (ada == older)
from dataclasses import dataclass, replace @dataclass(frozen=True) class Person: name: str age: int ada = Person(name="Ada", age=36) older = replace(ada, age=37) # the record-update syntax print(older) print(ada == older)
This is the closest correspondence on the page and the feature most worth adopting on day one: @dataclass(frozen=True) gets you a value type with structural equality and no mutation, which is most of what a Haskell record gives you. What is missing is deriving (Ord) unless you pass order=True, and anything like Generic-derived serialization — for which the ecosystem answer is pydantic, which also validates at run time.
🚨 Sum types, without exhaustiveness
A union of dataclasses plus match is the Python translation, and it reads surprisingly close. The line that gives the game away is the raise at the bottom.
data Shape = Circle Double | Rect Double Double deriving (Show) area :: Shape -> Double area (Circle radius) = pi * radius * radius area (Rect width height) = width * height -- Leave a case out and GHC warns; with -Werror it fails the build. main :: IO () main = do print (area (Rect 2 3)) print (area (Circle 1))
from dataclasses import dataclass import math @dataclass(frozen=True) class Circle: radius: float @dataclass(frozen=True) class Rect: width: float height: float Shape = Circle | Rect # a union type, for the type checker def area(shape: Shape) -> float: match shape: case Circle(radius): return math.pi * radius * radius case Rect(width, height): return width * height raise ValueError(f"unhandled shape: {shape}") print(area(Rect(2.0, 3.0))) print(area(Circle(1.0)))
🚨 That raise is there because nothing checks exhaustiveness at run time. Adding a third shape and forgetting a case produces a function that falls off the end and returns None — so the explicit raise turns a silent wrong answer into a loud failure, and it is the idiom to adopt. A type checker can verify exhaustiveness using typing.assert_never in the final branch, which is the closest thing to -Wincomplete-patterns and is worth setting up if the codebase leans on unions.
Pattern Matching
match destructures, and does less
Python 3.10's match is genuinely structural: it destructures lists, tuples, dictionaries and dataclasses, binds sub-patterns, and takes guards with if. The correspondence is close.
describe :: [Int] -> String describe [] = "empty" describe [single] = "one: " ++ show single describe (first : rest) = "first " ++ show first ++ " then " ++ show (length rest) main :: IO () main = do putStrLn (describe []) putStrLn (describe [7]) putStrLn (describe [1, 2, 3])
def describe(values: list[int]) -> str: match values: case []: return "empty" case [single]: return f"one: {single}" case [first, *rest]: return f"first {first} then {len(rest)}" raise ValueError("unreachable") print(describe([])) print(describe([7])) print(describe([1, 2, 3]))
What is missing: no exhaustiveness check, no matching on a function's parameters (so one function with a match replaces several equations), no where clause, no as-patterns spelled with @ in the Haskell sense (Python's case [first, *rest] as whole does exist and works differently), and no view patterns. A subtle one worth knowing: a bare name in a case is a binding, not a comparison, so case circle: matches everything and shadows the variable — compare against a dotted name or a literal instead.
Guards become if/elif
Guards become an if chain, and early return is idiomatic Python rather than a smell — so the shape stays flat instead of nesting.
classify :: Int -> String classify value | value < 0 = "negative" | value == 0 = "zero" | value < 10 = "small" | otherwise = "large" main :: IO () main = mapM_ (putStrLn . classify) [-5, 0, 3, 100]
def classify(value: int) -> str: if value < 0: return "negative" if value == 0: return "zero" if value < 10: return "small" return "large" for value in [-5, 0, 3, 100]: print(classify(value))
match also takes guards (case value if value < 0:), which is closer to the Haskell spelling and worth using when the branches are structural rather than numeric. Two absences: no otherwise (the last return is it), and no where clause, so a helper value used by several guards becomes a local variable computed before the chain — which is often clearer anyway.
Type Classes Become Protocols
Type classes become duck typing
typing.Protocol is structural: a class satisfies it by having the right methods, with no declaration of intent — which is duck typing with a name a type checker can use.
class Describable a where describe :: a -> String data Dog = Dog data Cat = Cat instance Describable Dog where describe _ = "a dog" instance Describable Cat where describe _ = "a cat" announce :: Describable a => a -> IO () announce value = putStrLn (describe value) main :: IO () main = do announce Dog announce Cat
from typing import Protocol class Describable(Protocol): def describe(self) -> str: ... class Dog: def describe(self) -> str: return "a dog" class Cat: def describe(self) -> str: return "a cat" def announce(value: Describable) -> None: print(value.describe()) announce(Dog()) announce(Cat())
The differences are worth being precise about. An instance is chosen by the value's type here, so there is no return-type polymorphism — nothing like read :: Read a => String -> a or mempty, where Haskell picks the instance from the context. There is no coherence guarantee, so two libraries may define conflicting behavior for the same class. And there are no superclasses, no default methods that dispatch, and no deriving. What you gain is that a type can satisfy a protocol written years later, by anyone, without either side knowing.
Show, Eq and Ord become dunder methods
The standard type classes map onto Python's special methods: Show is __str__ and __repr__, Eq is __eq__, Ord is __lt__ and friends, Semigroup is __add__.
data Money = Money Int deriving (Eq, Ord) instance Show Money where show (Money cents) = "$" ++ show (fromIntegral cents / 100 :: Double) main :: IO () main = do print (Money 150) print (Money 150 == Money 150) print (Money 150 < Money 275)
from dataclasses import dataclass @dataclass(frozen=True, order=True) class Money: cents: int def __str__(self) -> str: return f"${self.cents / 100}" print(Money(150)) print(Money(150) == Money(150)) print(Money(150) < Money(275))
order=True on the dataclass generates the comparison methods from the field order, which is deriving (Ord). Two Python specifics: __str__ is for humans and __repr__ for developers, and print uses the first while the interactive prompt uses the second — Show is closer to __repr__. And overriding __eq__ without __hash__ makes the type unhashable, which the dataclass decorator handles correctly when the class is frozen.
Lists, Tuples & Dicts
A list is an array, not a cons cell
🚨 The complexities invert. A Haskell list is a linked list, so : is constant and !! is linear; a Python list is a dynamic array, so indexing is constant and prepending copies everything.
main :: IO () main = do let values = [1, 2, 3] print (0 : values) -- prepending is O(1) print (head values, tail values) print (values !! 1) -- indexing is O(n) print (length values)
values = [1, 2, 3] print([0] + values) # prepending is O(n) — the whole list moves print(values[0], values[1:]) print(values[1]) # indexing is O(1) print(len(values)) # When you want O(1) at both ends: from collections import deque queue = deque(values) queue.appendleft(0) print(list(queue))
That inversion changes which algorithms are natural. The Haskell habit of building a result by prepending and reversing at the end becomes append with no reverse, since appending is amortized constant. Recursion over head and tail becomes a loop or a comprehension — and should, because Python has a recursion limit around a thousand frames and no tail-call elimination. collections.deque is the answer when you genuinely need both ends.
List comprehensions, almost unchanged
Python borrowed this syntax from Haskell and the correspondence is nearly exact — generator, filter, and multiple generators nesting in the same order.
main :: IO () main = do print [value * value | value <- [1 .. 10], even value] print [(x, y) | x <- [1, 2], y <- "ab"]
print([value * value for value in range(1, 11) if value % 2 == 0]) print([(x, y) for x in [1, 2] for y in "ab"])
Two additions on the Python side worth knowing: the same syntax in braces builds a set or a dict ({key: value for …}), and in parentheses builds a lazy generator. What is missing is pattern matching in the generator — [x | Just x <- values] has no equivalent, so filtering out None is an explicit if value is not None. And there is no monad comprehension: the syntax works on iterables only.
Data.Map becomes dict
A dict is a hash map and is mutated in place, where Data.Map is a balanced tree and every insertion returns a new map sharing most of the old one.
import qualified Data.Map as Map main :: IO () main = do let counts = Map.fromList [("apple", 2), ("pear", 5)] updated = Map.insert "plum" 1 counts print (Map.lookup "apple" updated) print (Map.lookup "fig" updated) mapM_ print (Map.toList updated)
counts = {"apple": 2, "pear": 5} counts["plum"] = 1 # mutates in place; there is no new map print(counts.get("apple")) # 2 print(counts.get("fig")) # None — the Maybe, spelled differently for key, value in sorted(counts.items()): print((key, value))
.get is the Maybe-shaped lookup and returns None for a missing key; counts[key] raises KeyError, which is the total-versus-partial distinction made explicit at the call site. Two differences from Data.Map: a dict preserves insertion order rather than sorting by key (hence the sorted above), and there is no persistent map in the standard library, so "a new map with one more entry" means copying or reaching for a library such as immutables.
Maybe and Either Become Exceptions
Maybe becomes None, and stops being tracked
int | None is the annotation and None is the value. The structure survives; the enforcement does not.
findEven :: [Int] -> Maybe Int findEven values = case filter even values of [] -> Nothing (x : _) -> Just x main :: IO () main = do -- The type forces the caller to handle both cases. case findEven [1, 3, 5] of Nothing -> putStrLn "nothing found" Just value -> print value
def find_even(values: list[int]) -> int | None: for value in values: if value % 2 == 0: return value return None found = find_even([1, 3, 5]) if found is None: print("nothing found") else: print(found) # Nothing forces that check. Omit it, and adding one to found raises # a TypeError at run time rather than failing to compile.
A type checker does enforce it — mypy rejects found + 1 without a narrowing check, and that is the single most valuable thing static typing buys in Python. What is gone is the rest of the Maybe machinery: no fmap over it, no >>=, no maybe function, no traverse. The Python shape is an early if value is None: return None, which is the do-notation unrolled by hand.
Either becomes an exception
The idiomatic Python translation of Either is not a value at all — it is an exception, which travels invisibly until something catches it.
parseCount :: String -> Either String Int parseCount text | all (`elem` "0123456789") text && not (null text) = Right (read text) | otherwise = Left ("not a number: " ++ text) main :: IO () main = case parseCount "oops" of Left message -> putStrLn ("failed: " ++ message) Right value -> print value
def parse_count(text: str) -> int: if not text.isdigit(): raise ValueError(f"not a number: {text}") return int(text) try: print(parse_count("oops")) except ValueError as error: print(f"failed: {error}") # The failure is not in the return type, so a caller who does not # catch it simply propagates it — which is sometimes what you want.
This is a real change in how a program is structured, and Python's version has a genuine advantage worth acknowledging: an error that most callers cannot handle does not have to be threaded through every signature between the failure and the handler. What is lost is that the type no longer tells you a call can fail, so the discipline is documenting it and catching narrowly — except ValueError rather than except Exception. Libraries such as returns provide a real Result type; they are a minority taste and reviewers will push back.
Partial functions everywhere
Partial functions are not an exception in Python, they are the norm — indexing, dictionary access, int(), next() and most of the standard library raise rather than returning an optional.
-- head is partial and everyone knows it; the community answer is -- to avoid it, and newer GHC warns when you use it. main :: IO () main = do let values = [1, 2, 3] :: [Int] print (head values) -- print (head ([] :: [Int])) -- would crash at run time
values = [1, 2, 3] print(values[0]) # The same partiality, spread across the whole standard library and # not remarked upon: [0] raises IndexError, dict[key] raises KeyError, # int("x") raises ValueError, and none of it is in a signature. try: print([][0]) except IndexError as error: print(f"caught: {error}")
The habits that make this comfortable are the ones a Haskell programmer already has, aimed slightly differently: prefer the total variant where one exists (dict.get, next(iterator, default), a slice values[:1] rather than values[0]), and catch narrowly where it does not. Python's culture leans the other way — "easier to ask forgiveness than permission" means try it and catch the failure — and that is idiomatic here even though it reads as reckless coming from Haskell.
IO Becomes Nothing At All
do notation becomes ordinary statements
Sequencing needs no notation because statements are already sequential, and the <- versus let distinction disappears with it since nothing is wrapped in anything.
-- The type says this touches the world, and do notation sequences -- the actions. Nothing outside IO can call it. greet :: String -> IO () greet name = do putStrLn ("hello, " ++ name) putStrLn "goodbye" main :: IO () main = do greet "Ada" let doubled = map (* 2) [1, 2, 3] -- pure, inside do putStrLn (unwords (map show doubled))
def greet(name: str) -> None: print(f"hello, {name}") print("goodbye") greet("Ada") doubled = [value * 2 for value in [1, 2, 3]] print(*doubled) # There is no IO type, no do notation, and no <- versus let # distinction — statements simply happen, in order, anywhere.
For a Haskell programmer the loss is not the syntax but the boundary: IO made it impossible to accidentally perform an effect inside a pure calculation, and nothing here prevents that. The architecture that survives is functional core, imperative shell — pure functions doing the work, a thin outer layer doing the reading and writing — which is exactly what IO encouraged and is regarded as good design in Python for its own reasons: it is what makes code testable without mocking.
The monads do not come with you
None of the monad transformer machinery transfers, and there is no point pretending otherwise: State, Reader, Writer and ExceptT have no Python equivalents in use.
-- State, Reader, Writer, ExceptT and the transformer stack are how -- a Haskell program threads context without globals. import Control.Monad.State counter :: State Int Int counter = do modify (+ 1) modify (+ 1) get main :: IO () main = print (evalState counter 0)
# There is no State monad and no transformer stack. The three # replacements, in the order a Python programmer reaches for them: # # 1. Pass the state in and return it — the same thing State does, # written by hand: def step(count: int) -> int: return count + 1 print(step(step(0))) # 2. A class holding the state, which is what most code does. # 3. contextvars, for genuinely ambient context such as a request # identifier — the nearest thing to Reader.
The honest replacements are ordinary: thread the value explicitly (which is what State desugars to anyway), or use a class to hold it, which is what a Python programmer will write and expect to read. contextvars covers the Reader-shaped case of ambient context, and is what asynchronous frameworks use for request-scoped values. Note that >>= on Maybe, the most common everyday monad, becomes early-return None checks — which is less elegant and perfectly clear.
What You Get In Return
What the trade actually buys
This is the row that explains the whole page. A Haskell programmer does not usually move to Python because Python is better; they move because the work, the team or the library is there.
-- Hackage is large and the libraries are frequently excellent. What -- it does not have is the thing you are most likely to have been -- sent here for: -- -- numerical computing at scale (NumPy, SciPy) -- machine learning (PyTorch, JAX) -- data frames people use (pandas, Polars) -- the plotting everyone expects (matplotlib) -- the notebook your colleagues live in main :: IO () main = putStrLn "excellent libraries, in a smaller ecosystem"
# The reason this page exists: the work is where the libraries are. # # And the libraries are mostly C, C++ or Rust with a Python face — # NumPy is C, PyTorch is C++, Polars is Rust — which is worth knowing # because it explains both the speed and the sharp edges. import statistics values = [1.5, 2.5, 3.5, 10.0] print(statistics.mean(values)) print(statistics.median(values)) print(round(statistics.stdev(values), 4))
Two things worth carrying in with you. First, the habits transfer even though the guarantees do not — small pure functions, immutable data, effects at the edges and total functions are all recognized as good Python, and code written that way is easier for Python programmers to review rather than harder. Second, the tooling gap is narrower than it looks: mypy or pyright in strict mode, ruff, and hypothesis for property-based testing (which is QuickCheck, by the same idea and a good implementation) recover a real fraction of what the compiler was doing.

Thank you — anything else?