Hello World & the Build
Hello, World
There is no ceremony at all, and no compilation step between writing this and seeing it run.
let () = print_endline "Hello, World!"print("Hello, World!")OCaml's
let () = … binds the unit pattern, which runs the expression and asserts it produces nothing. Python has no such notion: a module is a sequence of statements executed top to bottom when it is imported or run. The deeper difference is the one that shapes everything else on this page — the OCaml line has been type-checked before it runs, and the Python line has not been checked at all. print here is resolved when the line executes, not before.Formatted Output
An f-string interpolates any expression directly, with no directive to match against a type.
let name = "OCaml"
let year = 1996
let () = Printf.printf "%s appeared in %d\n" name yearname = "Python"
year = 1991
print(f"{name} appeared in {year}")OCaml's format string is a typed value: the compiler reads
%s and %d and requires exactly a string then an integer, so a mismatch is a compile error. Python's f-string calls str() on whatever each expression evaluates to, so nothing can mismatch and nothing is checked — f"{name} {year}" would work with the arguments swapped, printing something wrong rather than failing. In exchange the syntax embeds arbitrary expressions (f"{year + 1}", f"{value:.2f}") that OCaml would need sprintf and explicit arguments for.opam and dune vs pip and venv
Configuration rather than code, so neither column runs. The two ecosystems reached almost the same design from opposite directions.
(* dune-project *)
(lang dune 3.16)
(* bin/dune *)
(executable
(name main)
(libraries str))
(* Build and run:
dune build
dune exec bin/main.exe
Dependencies come from opam, into a switch. *)# pyproject.toml
# [project]
# name = "demo"
# version = "0.1.0"
# dependencies = ["requests>=2.31"]
# Set up and run:
# python3 -m venv .venv
# source .venv/bin/activate
# pip install -e .
# python3 -m demo
# Dependencies come from PyPI, into a virtual environment.An opam switch and a Python virtual environment are the same idea: a per-project directory of installed packages, activated in the shell, so two projects can want different versions of one library. Both also mean a build depends on machine state the project files do not fully record. The difference is that OCaml compiles, so a missing or mismatched dependency is a build error, while Python resolves imports at runtime — a package missing from a rarely-taken code path fails when that path is first taken, possibly in production.
Dynamic Typing
Nothing Is Checked Before It Runs
The defining difference, and it is worth confronting on the first substantive row rather than discovering it in a deployment.
(* This does not compile. The error names the line, the
expected type and the actual one, before anything runs. *)
let describe value = "value: " ^ string_of_int value
let () =
(* Passing a string here would be a compile error:
This expression has type string but an expression
was expected of type int *)
print_endline (describe 42)# This runs happily until the bad call is REACHED, and
# then raises. Nothing warned beforehand.
def describe(value):
return "value: " + str(value)
print(describe(42))
try:
print(describe([1, 2]) + 1)
except TypeError as error:
print(f"TypeError at runtime: {error}")OCaml checks every expression before the program starts, so a type error is a compile error naming the line. Python checks nothing ahead of time: names are resolved and operations are attempted at the moment the line executes, and an error surfaces only if that line runs. The practical consequence for someone arriving from OCaml is that test coverage takes over the job the type checker was doing — a branch nobody executes is a branch nobody has checked. Python's answer is threefold: tests, type hints with an external checker (see the type-hints section), and a REPL that makes the feedback loop very short.
Duck Typing
Python resolves a method by looking for it on the object at the moment of the call, which makes unrelated types interchangeable without any declaration tying them together.
(* A function works on the types its signature admits,
and the compiler enforces that. Two unrelated types
need a shared signature or a variant. *)
type shape = Circle of float | Square of float
let area = function
| Circle radius -> 3.14159 *. radius *. radius
| Square side -> side *. side
let () =
Printf.printf "%.2f\n" (area (Circle 1.0));
Printf.printf "%.2f\n" (area (Square 2.0))# Any object with an area() method works. There is no
# declaration relating these two classes at all.
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius * self.radius
class Square:
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
for shape in [Circle(1.0), Square(2.0)]:
print(f"{shape.area():.2f}")OCaml's variant closes the set:
shape has exactly two constructors, the compiler proves area handles both, and adding a third breaks the build until it is handled. Python's version is open — any class with an area method can join, from any library, with no coordination — and correspondingly unchecked, since nothing verifies that a given object has the method until the call happens. Each direction is genuinely useful. The OCaml habit that transfers badly is reaching for a closed variant when the Python answer is a protocol; the Python habit that transfers badly is assuming an object has a method because it usually does.Truthiness
Python gives almost every value a truth value, which makes conditions terse and makes one particular bug very easy to write.
(* Only bool is a condition. An empty list is not false;
it is a list, and using one as a condition does not
compile. *)
let () =
let items = [] in
if List.length items = 0 then print_endline "empty" else print_endline "has items";
let text = "" in
if String.length text = 0 then print_endline "blank" else print_endline "has text"# Empty containers, empty strings, 0 and None are all falsy.
items = []
if not items:
print("empty")
else:
print("has items")
text = ""
if not text:
print("blank")
else:
print("has text")In OCaml only
bool may be a condition, so emptiness has to be asked for explicitly. Python treats 0, 0.0, "", [], {}, set() and None as false and everything else as true. The idiom if not items is genuinely more readable than the OCaml line above — and the trap is that it does not distinguish "empty" from "absent". A function returning either a list or None passes both through the same branch, which is exactly the confusion option exists to prevent. When the difference matters, write if items is None.Variables & Types
Bindings vs Assignment
These look the same and are not: one creates a new binding that hides the old, the other overwrites.
let () =
let count = 10 in
(* A second let SHADOWS; the first binding still exists
underneath and is unchanged. *)
let count = count * 2 in
Printf.printf "%d\n" countcount = 10
# This REBINDS the same name. There is no earlier count
# left underneath.
count = count * 2
print(count)OCaml's
let introduces a fresh binding over a new scope, so the earlier count still exists and any closure that captured it still sees 10. Python's = rebinds the name in the enclosing namespace, so nothing sees the old value afterwards and a closure that captured the name sees the new one. That difference is invisible until a closure is involved, at which point it produces the most-reported surprise in the language — a loop that builds functions, all of which end up seeing the loop variable's final value.Integers Have No Ceiling
A rare case where Python's dynamism buys a real guarantee that OCaml does not offer.
(* OCaml's int is 63 bits and wraps silently. *)
let () =
Printf.printf "max_int = %d\n" max_int;
Printf.printf "wraps to = %d\n" (max_int + 1)# Python's int is arbitrary precision. There is no maximum.
biggest = 2 ** 62 - 1
print(f"a big one = {biggest}")
print(f"still fine = {biggest + 1}")
print(f"and beyond = {2 ** 200}")An OCaml
int is 63 bits — one bit is the garbage collector's tag — and it wraps silently on overflow, so a computation that exceeds it produces a wrong answer with no indication. Python integers grow to whatever size is needed, so 2 ** 200 is exact and nothing overflows, at the cost of every arithmetic operation going through a heap object rather than a machine word. When OCaml needs the same thing it reaches for the zarith library. Note also that Python's / always produces a float; integer division is //, which is closer to OCaml's /.None vs option
The habit this page most wants an OCaml programmer to keep, because Python will not keep it for you.
(* An absent value has a DIFFERENT TYPE from a present
one, so the compiler forces the absent case open. *)
let find_even numbers = List.find_opt (fun number -> number mod 2 = 0) numbers
let () =
(match find_even [ 1; 3; 4 ] with
| Some number -> Printf.printf "found %d\n" number
| None -> print_endline "none found");
(match find_even [ 1; 3; 5 ] with
| Some number -> Printf.printf "found %d\n" number
| None -> print_endline "none found")# None is an ordinary value of the SAME variable, so
# nothing forces the check. Forgetting it is the single
# most common Python bug.
def find_even(numbers):
for number in numbers:
if number % 2 == 0:
return number
return None
for candidates in ([1, 3, 4], [1, 3, 5]):
found = find_even(candidates)
if found is not None:
print(f"found {found}")
else:
print("none found")OCaml's
int option is a different type from int: you cannot add one to a number without unwrapping, and the compiler lists the case you forgot. Python's None is just another value the same name might hold, so found + 1 compiles fine and raises TypeError only if find_even happened to return None on that run. Note also is not None rather than != None: is compares identity, which is the correct test for a singleton, and it cannot be overridden by a class that defines a strange __eq__.Tuples
Tuples and destructuring transfer almost unchanged, and Python does not even require the parentheses.
let divide_and_remainder numerator denominator =
(numerator / denominator, numerator mod denominator)
let () =
let (quotient, remainder) = divide_and_remainder 17 5 in
Printf.printf "%d remainder %d\n" quotient remainderdef divide_and_remainder(numerator, denominator):
return numerator // denominator, numerator % denominator
quotient, remainder = divide_and_remainder(17, 5)
print(f"{quotient} remainder {remainder}")Both languages build a tuple with a comma and take one apart by pattern. Python's tuple is immutable, which makes it the one place in the language where an OCaml programmer's expectations about immutability hold — lists, dictionaries and sets are all mutable. Python also allows a starred element (
first, *rest = [1, 2, 3]) which OCaml would express with a list pattern, and it uses tuples so freely that returning several values is the ordinary way to write a function with more than one result.Strings
Building a String
The lengths agree here and will disagree the moment a character is not ASCII — the two languages count different things.
let () =
let greeting = "Hello" in
let subject = "world" in
let sentence = greeting ^ ", " ^ subject ^ "!" in
print_endline sentence;
Printf.printf "length = %d\n" (String.length sentence)greeting = "Hello"
subject = "world"
sentence = f"{greeting}, {subject}!"
print(sentence)
print(f"length = {len(sentence)}")An OCaml
string is a sequence of bytes with no declared encoding, so String.length counts bytes and an accented letter counts as two. A Python str is a sequence of Unicode code points, so len counts characters as a human would for most scripts. Python separates bytes into their own type (bytes, written b"…") and refuses to mix the two without an explicit encode or decode — which is exactly the discipline OCaml leaves to the programmer.Slicing
Slicing is one of Python's genuinely great pieces of syntax, and OCaml has nothing like it.
let () =
let text = "abcdefgh" in
print_endline (String.sub text 0 3);
print_endline (String.sub text 3 (String.length text - 3));
(* Reversing needs an explicit fold or a helper. *)
let reversed = String.init (String.length text)
(fun index -> text.[String.length text - 1 - index]) in
print_endline reversedtext = "abcdefgh"
print(text[:3])
print(text[3:])
print(text[::-1])Python's
[start:stop:step] works on any sequence — strings, lists, tuples, and library types like NumPy arrays — with every part optional, negative indices counting from the end, and a negative step reversing. OCaml's String.sub takes an offset and a length rather than a second index, which is a persistent source of off-by-one confusion when translating in either direction, and it has no step at all. This is a case worth adopting rather than resisting: reaching for a slice is the idiomatic Python answer far more often than an OCaml programmer expects.Splitting and Joining
The same two operations, with Python putting them on the string itself rather than in a module.
let () =
let line = "alpha,beta,gamma" in
let parts = String.split_on_char ',' line in
List.iter (fun part -> Printf.printf "<%s>" part) parts;
print_newline ();
print_endline (String.concat " | " parts)line = "alpha,beta,gamma"
parts = line.split(",")
for part in parts:
print(f"<{part}>", end="")
print()
print(" | ".join(parts))Python's string methods are called on the value (
line.split), which makes them discoverable by typing a dot in a REPL — a real advantage for learning a library. The join is worth noting because it reads backwards to almost everyone at first: the separator is the string you call it on. OCaml's String.concat takes the same two arguments in the same order and looks less surprising because it is a plain function. Python also has .strip(), .replace(), .startswith() and dozens more built in, where OCaml's String module is deliberately minimal.Collections
The Word "list" Means Something Else
The single most important collection fact on this page, and the name gives no warning at all.
(* An OCaml list is an IMMUTABLE singly linked list.
Consing on the front is cheap; length walks it. *)
let () =
let numbers = [ 1; 2; 3 ] in
let extended = 0 :: numbers in
List.iter (Printf.printf "%d ") extended;
print_newline ();
(* numbers is untouched *)
Printf.printf "original still has %d\n" (List.length numbers)# A Python list is a MUTABLE growable array. Appending to
# the end is cheap; inserting at the front is not.
numbers = [1, 2, 3]
numbers.insert(0, 0)
for number in numbers:
print(number, end=" ")
print()
# There is no "original" — numbers was changed in place.
print(f"the same list now has {len(numbers)}")The two types share a name and almost nothing else. OCaml's is an immutable linked list:
0 :: numbers builds a new list sharing the old one's cells, so numbers is unchanged and both exist. Python's is a mutable dynamic array: insert(0, …) shifts every element and modifies the list in place, so there is no old version to refer to. len is free in Python and a full traversal in OCaml; appending is cheap in Python and expensive in OCaml, and the reverse is true for prepending. The habit to break is building a result by consing and reversing at the end — in Python you append, and it is already in order.Dictionaries
Python's dictionary has literal syntax, preserves insertion order, and is used for very much more than OCaml's hash table.
let () =
let ages = Hashtbl.create 8 in
Hashtbl.replace ages "ada" 36;
Hashtbl.replace ages "alan" 41;
(match Hashtbl.find_opt ages "ada" with
| Some age -> Printf.printf "ada is %d\n" age
| None -> print_endline "ada is unknown");
Printf.printf "entries = %d\n" (Hashtbl.length ages)ages = {"ada": 36, "alan": 41}
age = ages.get("ada")
if age is not None:
print(f"ada is {age}")
else:
print("ada is unknown")
print(f"entries = {len(ages)}")Both are hash tables and both offer a lookup that reports absence rather than raising — OCaml's
find_opt returning an option, Python's .get returning None. Two Python details matter. Since Python 3.7, dictionaries preserve insertion order, which OCaml's Hashtbl does not promise. And ages["missing"] raises KeyError where .get returns None, which is the same raising-versus-option pair OCaml offers as find and find_opt. Dictionaries are far more central in Python: they carry keyword arguments, object attributes and JSON, so an OCaml programmer will meet them constantly.Sets
A set has literal syntax and operator syntax in Python, and needs a functor application in OCaml.
module IntSet = Set.Make (Int)
let () =
let first = IntSet.of_list [ 1; 2; 3 ] in
let second = IntSet.of_list [ 3; 4 ] in
Printf.printf "union has %d\n" (IntSet.cardinal (IntSet.union first second));
Printf.printf "has 3? %b\n" (IntSet.mem 3 first)first = {1, 2, 3}
second = {3, 4}
print(f"union has {len(first | second)}")
print(f"has 3? {3 in first}")OCaml's
Set.Make (Int) builds a module specialized to integer elements, and every operation goes through it. Python's set is a builtin whose elements need only be hashable, with |, &, - and ^ for union, intersection, difference and symmetric difference. The trade is the usual one: OCaml's set is persistent, so union allocates a new set and leaves both operands intact, while Python's is mutable — first |= second modifies first in place, and any other name referring to it sees the change.Sorting
Python sorts by a key function rather than a comparison function, and the difference is more than notation.
let () =
let words = [ "banana"; "fig"; "apple" ] in
let by_length = List.sort (fun left right ->
compare (String.length left) (String.length right)) words in
print_endline (String.concat " " by_length);
print_endline (String.concat " " (List.sort compare words))words = ["banana", "fig", "apple"]
by_length = sorted(words, key=len)
print(" ".join(by_length))
print(" ".join(sorted(words)))OCaml's
List.sort takes a comparison returning a negative number, zero or a positive one — the C convention. Python's sorted takes key=, a function computing the value to sort by, and calls it once per element rather than once per comparison. That is both faster for an expensive key and much harder to get wrong, since there is no three-way convention to remember. Python also accepts reverse=True, and its sort is stable — as is OCaml's List.stable_sort, though plain List.sort makes no such promise.Index and Parallel Iteration
Both languages have a way to iterate with an index and to walk two sequences together; Python's work for any iterable and compose into anything.
let () =
let words = [ "alpha"; "beta"; "gamma" ] in
List.iteri (fun index word -> Printf.printf "%d: %s\n" index word) words;
let numbers = [ 1; 2; 3 ] in
List.iter2 (fun word number -> Printf.printf "%s=%d " word number) words numbers;
print_newline ()words = ["alpha", "beta", "gamma"]
for index, word in enumerate(words):
print(f"{index}: {word}")
numbers = [1, 2, 3]
for word, number in zip(words, numbers):
print(f"{word}={number}", end=" ")
print()OCaml supplies
List.iteri and List.iter2, plus mapi and map2, each specific to one module and one arity — and iter2 raises if the lists are different lengths. Python's enumerate and zip are generic lazy iterators usable with any iterable, in a comprehension, or passed onward; zip stops at the shorter input silently, and zip(…, strict=True) asks for OCaml's raising behavior. enumerate(words, start=1) shifts the index, which is worth knowing because writing range(len(words)) and indexing is the mark of someone still thinking in another language.Mutability & Its Traps
Two Names, One List
The most frequent source of confusion for anyone arriving from a language where the default data structures are immutable.
(* An OCaml list is immutable, so an alias can never
surprise you: there is nothing to change. *)
let () =
let original = [ 1; 2; 3 ] in
let alias = original in
let extended = 0 :: original in
Printf.printf "original %d, alias %d, extended %d\n"
(List.length original) (List.length alias) (List.length extended)# A Python list is mutable, so an alias sees every change.
original = [1, 2, 3]
alias = original
original.append(4)
print(f"original {len(original)}, alias {len(alias)}")
# A real copy needs to be asked for.
copy = original[:]
original.append(5)
print(f"original {len(original)}, copy {len(copy)}")In OCaml the question does not arise: a list cannot be modified, so two names for one list are indistinguishable from two lists. In Python
alias = original copies a reference, and every mutation through either name is visible through both. A shallow copy is original[:], list(original) or original.copy(); a deep copy needs copy.deepcopy. The rule worth internalizing is that assignment never copies in Python — it binds a name to an existing object — and that this is invisible until something mutates.The Mutable Default Argument
The most famous gotcha in the language, and one an OCaml programmer is especially likely to write because OCaml's optional arguments behave the way you would expect.
(* OCaml's optional-argument default is an EXPRESSION
evaluated at each call, so a fresh list every time. *)
let collect ?(into = []) value = value :: into
let () =
Printf.printf "%d\n" (List.length (collect 1));
Printf.printf "%d\n" (List.length (collect 2));
Printf.printf "%d\n" (List.length (collect 3))# Python evaluates a default ONCE, when the function is
# defined. The same list is reused by every call.
def collect_wrong(value, into=[]):
into.append(value)
return into
print(len(collect_wrong(1)))
print(len(collect_wrong(2)))
print(len(collect_wrong(3)))
# The fix, and it is the only correct way to write it:
def collect(value, into=None):
if into is None:
into = []
into.append(value)
return into
print(len(collect(1)), len(collect(2)))OCaml evaluates an optional argument's default expression at every call, so
?(into = []) gives a fresh empty list each time — which is what anyone would assume. Python evaluates the default once, at definition time, and stores the resulting object on the function, so a mutable default is shared across every call that omits it. The output above shows the counter climbing 1, 2, 3 instead of staying at 1. The fix is universal: default to None and build the real default inside the body. Linters flag this, and it still gets written.Getting Immutability Back
Python can give you an immutable record with a functional update, and the default runs the other way round.
(* Immutable by default; you opt IN to mutation with a
ref or a mutable record field. *)
type point = { x : int; y : int }
let () =
let origin = { x = 0; y = 0 } in
let shifted = { origin with x = 5 } in
Printf.printf "%d %d\n" origin.x shifted.xfrom dataclasses import dataclass, replace
# Mutable by default; you opt OUT with frozen=True.
@dataclass(frozen=True)
class Point:
x: int
y: int
origin = Point(0, 0)
shifted = replace(origin, x=5)
print(origin.x, shifted.x)@dataclass(frozen=True) makes assignment to a field raise FrozenInstanceError, and dataclasses.replace is exactly OCaml's { record with field = value }. Freezing also makes the instance hashable, so it can be a dictionary key or set element. This is the closest Python gets to an OCaml record and is worth reaching for by default when the data is not meant to change — the language will not suggest it, since every other container is mutable and nothing warns.Control Flow
Statements, Not Expressions
Python draws a hard line between statements and expressions, and OCaml draws none at all.
(* if is an EXPRESSION producing a value, so it can be
bound directly. *)
let () =
let temperature = 31 in
let advice =
if temperature > 30 then "stay inside"
else if temperature > 20 then "pleasant"
else "bring a coat"
in
print_endline advice# if is a STATEMENT; it produces nothing. Either assign in
# each branch, or use the conditional expression.
temperature = 31
if temperature > 30:
advice = "stay inside"
elif temperature > 20:
advice = "pleasant"
else:
advice = "bring a coat"
print(advice)
# The expression form, for the two-branch case only:
label = "hot" if temperature > 30 else "not hot"
print(label)In OCaml everything produces a value, so
if, match and even a for loop (producing unit) can appear wherever a value is expected. Python's if, for, while and try are statements that produce nothing, so the branches have to assign to a name. The conditional expression a if condition else b covers the two-branch case and does not chain readably beyond that. The related consequence is that a Python lambda may contain only an expression, which is why Python code uses named functions where OCaml would inline one.Loops
Python's
for iterates over a sequence rather than counting, and the counter needs no heap cell.let () =
for index = 1 to 3 do
Printf.printf "for %d\n" index
done;
let countdown = ref 3 in
while !countdown > 0 do
Printf.printf "while %d\n" !countdown;
countdown := !countdown - 1
donefor index in range(1, 4):
print(f"for {index}")
countdown = 3
while countdown > 0:
print(f"while {countdown}")
countdown -= 1OCaml's
for … to … done counts, and iterating a collection means List.iter. Python's for only ever iterates something iterable, and range(1, 4) is a lazy sequence that stops before its upper bound — the half-open convention, which catches every newcomer once. The while counter is an ordinary rebindable name in Python and needs a ref in OCaml, because OCaml has no mutable local bindings. Both languages have break and continue… except that OCaml has neither, which is worth knowing before looking for them.Recursion Has a Hard Limit
A recursive habit that is safe and idiomatic in OCaml will raise
RecursionError in Python at a depth that is not large.(* Tail-recursive, and OCaml guarantees constant stack
space. A hundred thousand frames is nothing. *)
let rec sum_to total current =
if current = 0 then total
else sum_to (total + current) (current - 1)
let () = Printf.printf "%d\n" (sum_to 0 100000)# Python has NO tail-call elimination and a recursion
# limit of about 1000. This is what a loop is for.
def sum_to(limit):
total = 0
for current in range(1, limit + 1):
total += current
return total
print(sum_to(100000))
import sys
print(f"recursion limit is {sys.getrecursionlimit()}")OCaml guarantees tail-call elimination, so an accumulator-passing recursion runs in constant stack space and recursing a hundred thousand deep is unremarkable. Python performs no tail-call elimination at all — deliberately, so that stack traces stay complete — and enforces a recursion limit of about 1000 to turn stack exhaustion into a catchable exception rather than a crash. Raising the limit with
sys.setrecursionlimit is possible and mostly a way to get a real segfault instead. Where an OCaml programmer writes a recursive helper, Python writes a loop, and that is idiomatic rather than a concession.Functions
No Currying
The OCaml habit that transfers least well, and the one that changes how pipelines get written.
(* Every OCaml function of two arguments is a function
returning a function, so this is ordinary application. *)
let add first second = first + second
let () =
let add_ten = add 10 in
Printf.printf "%d\n" (add_ten 5);
Printf.printf "%d\n" (add_ten 32)# Python functions take all their arguments at once.
# functools.partial fixes some of them explicitly.
from functools import partial
def add(first, second):
return first + second
add_ten = partial(add, 10)
print(add_ten(5))
print(add_ten(32))In OCaml
add 10 is not a special form — add genuinely has type int -> int -> int, so applying it to one argument is ordinary application. Python has no currying: a two-parameter function must receive two arguments, and calling it with one raises TypeError. functools.partial covers the common case and a lambda covers the rest. The knock-on effect is that OCaml's point-free pipelines built on |> do not survive translation, and Python reaches for a comprehension or a loop where OCaml would compose functions.Keyword and Default Arguments
This is the rare feature where the two languages agree, and Python's version is the more comfortable of the two.
let greet ?(greeting = "Hello") ~name () =
Printf.printf "%s, %s!\n" greeting name
let () =
greet ~name:"Ada" ();
greet ~greeting:"Welcome" ~name:"Alan" ()def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
greet(name="Ada")
greet(greeting="Welcome", name="Alan")OCaml labels an argument with
~ and defaults it with ?, and needs a trailing () because a function with optional arguments cannot otherwise be known to be fully applied. Python needs no such marker: any parameter can be passed by name, defaults are written inline, and the call is unambiguous because there is no partial application to disambiguate against. Python also has *args and **kwargs for variadic and keyword-variadic parameters, which OCaml has no equivalent for at all — and remember from the mutability section that a default value is evaluated once, not per call.Lambdas Are Expression-Only
Python's anonymous function is deliberately restricted, which is why Python code has far fewer of them than OCaml code does.
(* A fun can contain anything an expression can, which
in OCaml is everything — including let bindings and
sequencing. *)
let () =
let describe = fun number ->
let doubled = number * 2 in
let label = if doubled > 10 then "big" else "small" in
Printf.sprintf "%d is %s" doubled label
in
print_endline (describe 3);
print_endline (describe 8)# A lambda may hold ONE expression: no statements, no
# assignment, no if-block. Anything more needs a def.
def describe(number):
doubled = number * 2
label = "big" if doubled > 10 else "small"
return f"{doubled} is {label}"
print(describe(3))
print(describe(8))OCaml's
fun and its named functions are the same thing, so an anonymous function can hold local bindings, sequencing and matching — anything at all. Python's lambda is limited to a single expression, because assignment and control flow are statements and a lambda body must be an expression. That is a deliberate design decision rather than an oversight: the language pushes anything non-trivial into a named def, on the grounds that it then has a name and a docstring. The practical effect is that Python code is less densely functional than OCaml code even where the same approach would work.Decorators
A decorator is a higher-order function with syntax, and that syntax is everywhere in real Python code.
(* A higher-order function wrapping another is ordinary,
but there is no syntax for applying it at the
definition site. *)
let with_logging name function_ argument =
Printf.printf "calling %s\n" name;
let result = function_ argument in
Printf.printf "%s returned %d\n" name result;
result
let double value = value * 2
let () = ignore (with_logging "double" double 21)# A decorator applies the wrapper AT the definition, so
# every call site gets it without knowing.
import functools
def with_logging(function):
@functools.wraps(function)
def wrapper(*args, **kwargs):
print(f"calling {function.__name__}")
result = function(*args, **kwargs)
print(f"{function.__name__} returned {result}")
return result
return wrapper
@with_logging
def double(value):
return value * 2
double(21)Both columns wrap a function in logging, and both are just higher-order functions. What Python adds is the
@ syntax, which applies the wrapper at the point of definition so that every caller gets the wrapped version without changing any call site. OCaml can express the wrapping, as the anchor column shows, but has no way to make it the definition — callers would have to call with_logging "double" double explicitly. Decorators carry a large amount of real Python: @property, @staticmethod, @dataclass, @functools.cache, and every web framework's routing.Closures Capture the Name, Not the Value
The most-reported surprise in Python, and it follows directly from assignment rebinding a name rather than creating a binding.
(* Each iteration binds a FRESH variable, so each closure
captures its own. *)
let () =
let makers = List.map (fun number -> fun () -> number * 10) [ 1; 2; 3 ] in
List.iter (fun make -> Printf.printf "%d " (make ())) makers;
print_newline ()# A closure captures the VARIABLE. All three see the loop
# variable's final value.
wrong = []
for number in [1, 2, 3]:
wrong.append(lambda: number * 10)
print([make() for make in wrong])
# The fix: bind the value with a default argument.
right = []
for number in [1, 2, 3]:
right.append(lambda number=number: number * 10)
print([make() for make in right])OCaml's
fun number -> … introduces a new binding per call, so each closure captures a different number and the three functions return 10, 20 and 30. Python's for loop rebinds one variable, and a closure captures that variable rather than its value at capture time — so after the loop all three see 3 and all three return 30. The output above shows exactly that: [30, 30, 30] then [10, 20, 30]. The idiomatic fix is the default-argument trick shown, which evaluates number at definition time; a comprehension avoids the problem because each iteration of a comprehension does get its own scope.Comprehensions & Generators
List Comprehensions
A comprehension collapses nested iteration and filtering into one expression, and the weight difference is not subtle.
(* No comprehension syntax: compose combinators, and
nest one level per generator. *)
let () =
let pairs =
List.concat_map
(fun first ->
List.filter_map
(fun second -> if first < second then Some (first, second) else None)
[ 1; 2; 3 ])
[ 1; 2; 3 ]
in
List.iter (fun (first, second) -> Printf.printf "(%d,%d) " first second) pairs;
print_newline ()pairs = [(first, second)
for first in range(1, 4)
for second in range(1, 4)
if first < second]
for first, second in pairs:
print(f"({first},{second})", end=" ")
print()The two columns produce the same pairs. Python's comprehension reads as set-builder notation: the result expression first, then generators, then filters, with each additional generator costing one line rather than one nesting level. OCaml has to compose
concat_map with filter_map and indent for each. Python has the same syntax for dictionaries ({key: value for …}), sets ({value for …}) and generators (parentheses instead of brackets). This is one of the clearest ergonomic wins Python has over OCaml, and it is used constantly.Generators
Both languages have lazy sequences. Python lets you write one as ordinary imperative code that pauses.
(* Seq is OCaml's lazy sequence, built from combinators
or from an explicit unfold. *)
let () =
let naturals = Seq.ints 1 in
let squares = Seq.map (fun number -> number * number) naturals in
Seq.iter (Printf.printf "%d ") (Seq.take 5 squares);
print_newline ()# A generator function looks like ordinary code and
# suspends at each yield.
def squares():
number = 1
while True:
yield number * number
number += 1
produced = squares()
for _ in range(5):
print(next(produced), end=" ")
print()OCaml's
Seq.t is a function returning a node on demand, and building a custom one means writing that function or composing existing combinators. Python's yield turns a whole function into a generator: it runs until the yield, hands back a value, and resumes exactly there on the next request, with its local variables intact. That makes complex lazy producers — a parser, a tree walk, a paginated API reader — dramatically easier to write than the equivalent unfold. It is the same capability OCaml 5's effect handlers provide, exposed as one keyword instead of as a general mechanism.map, filter and fold
Python has
map and filter, and idiomatic Python uses a comprehension instead — this is a genuine style difference worth adopting.let () =
[ 1; 2; 3; 4; 5; 6 ]
|> List.filter (fun number -> number mod 2 = 0)
|> List.map (fun number -> number * 2)
|> List.fold_left ( + ) 0
|> Printf.printf "total = %d\n"numbers = [1, 2, 3, 4, 5, 6]
total = sum(number * 2 for number in numbers if number % 2 == 0)
print(f"total = {total}")OCaml's pipeline reads left to right and allocates a new list at each stage. Python's generator expression — the comprehension without brackets, passed straight to
sum — does the whole thing lazily in one pass with no intermediate list at all. map and filter do exist as builtins, but combining them reads worse than the comprehension and the community regards them as a foreign accent. functools.reduce is OCaml's fold_left, and it is deliberately tucked away in a module because a named builtin (sum, max, any, all) is nearly always clearer.Classes & Objects
Classes
Both languages have objects. The difference is that in Python they are the ordinary way to organize a program.
(* OCaml HAS an object system and almost nobody uses it.
A record plus functions is the idiomatic spelling. *)
type account = { owner : string; mutable balance : int }
let deposit account amount = account.balance <- account.balance + amount
let () =
let account = { owner = "Ada"; balance = 0 } in
deposit account 50;
Printf.printf "%s has %d\n" account.owner account.balance# Classes are the primary structuring tool, not an
# alternative to one.
class Account:
def __init__(self, owner):
self.owner = owner
self.balance = 0
def deposit(self, amount):
self.balance += amount
account = Account("Ada")
account.deposit(50)
print(f"{account.owner} has {account.balance}")OCaml's objects are structurally typed and genuinely interesting, and real OCaml code hardly touches them — records, variants and modules cover the ground. Python has no alternative: a class is how you group data with behavior, and every library you use is built from them. The explicit
self as the first parameter of every method is the visible sign that a method really is a function taking the object, which is exactly what the OCaml column's deposit account amount is — Python just puts it inside the class and gives it dot-call syntax.Attributes Are Not Fixed
A Python object's attributes are a dictionary that anything can add to, which makes a typo into a new field rather than an error.
(* A record has exactly the fields its type declares.
Adding one is a compile error naming the field. *)
type point = { x : int; y : int }
let () =
let point = { x = 1; y = 2 } in
(* point.z would be: Unbound record field z *)
Printf.printf "%d %d\n" point.x point.yclass Point:
def __init__(self, x, y):
self.x = x
self.y = y
point = Point(1, 2)
# Assigning an attribute that was never declared just
# creates it — including a MISSPELLED one.
point.z = 3
print(point.x, point.y, point.z)
print(f"attributes: {sorted(vars(point))}")An OCaml record's fields are fixed by its type, so
point.z is a compile error and so is a misspelling of x. A Python instance keeps its attributes in a dictionary (vars(point) above), so point.z = 3 simply adds one — and self.balnce = 0 in a constructor silently creates a second attribute beside balance, which then reads as zero forever. The defense is __slots__, which fixes the attribute set and rejects anything else, or a @dataclass, or a type checker. None of them is on by default.Making an Object Behave Like a Builtin
Python lets a class implement the operators and builtins the language already has, which is how library types feel native.
(* Operators are not overloadable. A custom type gets
its own named functions, and floats get their own
operator family (+. *. and so on). *)
type money = { cents : int }
let add_money left right = { cents = left.cents + right.cents }
let money_to_string money = Printf.sprintf "$%d.%02d" (money.cents / 100) (money.cents mod 100)
let () =
print_endline (money_to_string (add_money { cents = 350 } { cents = 275 }))# Dunder methods make a class work with the language's
# own syntax: +, len(), print(), in, iteration, and more.
class Money:
def __init__(self, cents):
self.cents = cents
def __add__(self, other):
return Money(self.cents + other.cents)
def __str__(self):
return f"${self.cents // 100}.{self.cents % 100:02d}"
print(Money(350) + Money(275))OCaml has no operator overloading at all — which is why floats need
+. and *., and why a custom type gets named functions instead. Python resolves + by calling __add__ on the left operand, len(x) by calling __len__, print(x) by calling __str__, and for … in x by calling __iter__. That is the whole reason NumPy arrays and pandas DataFrames read as naturally as they do. The cost is the usual one: + means whatever the object decides, so reading unfamiliar code requires knowing the types involved.Inheritance
Inheritance is a primary structuring tool in Python and essentially absent from idiomatic OCaml, which reaches for a variant or a record of functions instead.
(* Reuse by COMPOSITION: a shared record and functions
over it. Variants handle the "several kinds" case. *)
type animal = { name : string; sound : string }
let speak animal = Printf.sprintf "%s says %s" animal.name animal.sound
let make_dog name = { name; sound = "Woof" }
let make_cat name = { name; sound = "Meow" }
let () =
print_endline (speak (make_dog "Rex"));
print_endline (speak (make_cat "Tom"))class Animal:
def __init__(self, name):
self.name = name
def sound(self):
raise NotImplementedError
def speak(self):
return f"{self.name} says {self.sound()}"
class Dog(Animal):
def sound(self):
return "Woof"
class Cat(Animal):
def sound(self):
return "Meow"
for animal in [Dog("Rex"), Cat("Tom")]:
print(animal.speak())OCaml has class inheritance in its object system, and idiomatic OCaml does not use it — the two natural answers are a variant when the set of kinds is closed and known, and a record of functions when it is open. Python's answer to both is subclassing, and it is what every library expects. The trade is the familiar one: the OCaml variant makes the compiler prove every kind is handled, while the Python hierarchy is open to subclasses from anywhere and checks nothing until
sound() is actually called — which is what NotImplementedError is standing in for.Records vs Dataclasses
Records and Dataclasses
A dataclass is the closest Python comes to an OCaml record, and one decorator generates most of what the record gives you for free.
type point = { x : int; y : int }
let () =
let origin = { x = 0; y = 0 } in
let shifted = { origin with x = 5 } in
Printf.printf "(%d, %d)\n" origin.x origin.y;
Printf.printf "(%d, %d)\n" shifted.x shifted.y;
Printf.printf "%b\n" ({ x = 1; y = 2 } = { x = 1; y = 2 })from dataclasses import dataclass, replace
@dataclass
class Point:
x: int
y: int
origin = Point(0, 0)
shifted = replace(origin, x=5)
print(f"({origin.x}, {origin.y})")
print(f"({shifted.x}, {shifted.y})")
print(Point(1, 2) == Point(1, 2))@dataclass generates __init__, __repr__ and __eq__ from the annotated fields, so construction, printing and structural equality all work — which is what OCaml gives every record without asking. dataclasses.replace is { record with field = value }. Two differences remain: the annotations are not enforced at runtime, so Point("a", "b") constructs happily, and the instance is mutable unless you add frozen=True. OCaml's structural equality comes from the polymorphic =, which works on any type by inspecting representations — including ones where it should not, such as functions, where it raises.A Lightweight Immutable Record
Python has a middle ground between a bare tuple and a full class, and OCaml's equivalent is simply a record.
(* A tuple works, but its components have no names, so
the reader has to remember the order. *)
let make_point x y = (x, y)
let () =
let (x, y) = make_point 3 4 in
Printf.printf "(%d, %d)\n" x yfrom typing import NamedTuple
# A NamedTuple is a tuple with named fields: immutable,
# hashable, unpackable, and comparable.
class Point(NamedTuple):
x: int
y: int
point = Point(3, 4)
print(f"({point.x}, {point.y})")
first, second = point
print(f"unpacked: {first} {second}")A
NamedTuple is a tuple — it unpacks, compares and hashes like one — while also giving its positions names. OCaml has no need for the category because a record is already lightweight, immutable and structurally comparable, and there is no cost to declaring one. The one thing the Python version offers that an OCaml record does not is that it remains a sequence, so it works anywhere a tuple works. Choose NamedTuple for immutable value objects and @dataclass when you want mutability, defaults or methods.Variants & Pattern Matching
match Exists, Exhaustiveness Does Not
Python 3.10 brought structural pattern matching, and it destructures nearly as well as OCaml's — with one guarantee missing.
type shape =
| Circle of float
| Rectangle of float * float
| Point
let area = function
| Circle radius -> 3.14159 *. radius *. radius
| Rectangle (width, height) -> width *. height
| Point -> 0.0
let () =
List.iter
(fun shape -> Printf.printf "%.2f\n" (area shape))
[ Circle 1.0; Rectangle (2.0, 3.0); Point ]from dataclasses import dataclass
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
@dataclass
class Point:
pass
def area(shape):
match shape:
case Circle(radius):
return 3.14159 * radius * radius
case Rectangle(width, height):
return width * height
case Point():
return 0.0
return None
for shape in [Circle(1.0), Rectangle(2.0, 3.0), Point()]:
print(f"{area(shape):.2f}")The
match statement matches class patterns, sequences, mappings and literals, binds sub-patterns, and supports | alternatives and if guards. What it does not do is check exhaustiveness: delete the Point() case and nothing complains, the match simply falls through and the function returns None, which then flows onward as a number until something adds to it. That trailing return None in the target column is not decoration — it is what Python does implicitly, made visible. Adding a fourth shape to the OCaml column is a compile error listing the unhandled constructor; in Python it is a silent behavior change.Enumerations
A constructor with no payload is an
Enum member in Python, and the class is iterable over its own members.type color = Red | Green | Blue
let to_string = function
| Red -> "Red"
| Green -> "Green"
| Blue -> "Blue"
let () =
List.iter (fun color -> Printf.printf "%s " (to_string color)) [ Red; Green; Blue ];
print_newline ();
Printf.printf "%b\n" (Red = Red)from enum import Enum
class Color(Enum):
RED = "Red"
GREEN = "Green"
BLUE = "Blue"
for color in Color:
print(color.value, end=" ")
print()
print(Color.RED is Color.RED)OCaml's constant constructors are just a variant with no arguments, so exhaustiveness applies to them like anything else. Python's
Enum gives named constants with values, iteration over the members, and identity comparison with is — but no exhaustiveness anywhere. The relevant gap is that an OCaml variant can carry data per constructor (Circle of float) while an Enum member cannot vary its shape; that is why the previous row used dataclasses rather than an enum. Python has no single construct that does both jobs.Destructuring Data
Sequence patterns line up closely, with
*_ playing the role of OCaml's tail pattern.let describe value =
match value with
| [] -> "empty"
| [ single ] -> Printf.sprintf "one: %d" single
| first :: second :: _ -> Printf.sprintf "starts %d, %d" first second
let () =
List.iter (fun list -> print_endline (describe list))
[ []; [ 7 ]; [ 1; 2; 3 ] ]def describe(value):
match value:
case []:
return "empty"
case [single]:
return f"one: {single}"
case [first, second, *_]:
return f"starts {first}, {second}"
return "unmatched"
for candidate in ([], [7], [1, 2, 3]):
print(describe(candidate))The correspondence is good: an empty pattern, a fixed-length pattern binding its elements, and a pattern binding the front with a starred rest. Python's
*_ can appear anywhere in the sequence, including the middle ([first, *middle, last]), which OCaml cannot express in a list pattern at all. What Python cannot do is prove the three cases are exhaustive — the return "unmatched" is there because nothing else guarantees a value comes back. Python also matches dictionaries by key, which OCaml has no pattern syntax for.Modules & Packages
Modules
An OCaml module is a language construct you can define anywhere; a Python module is a file, and importing it runs it.
module Geometry = struct
let pi = 3.14159
let circle_area radius = pi *. radius *. radius
end
let () =
Printf.printf "%.4f\n" Geometry.pi;
Printf.printf "%.2f\n" (Geometry.circle_area 2.0)# A module is a FILE, and it is also an object created
# when the file is first imported. This one is inline for
# the sake of a runnable example.
import math
PI = 3.14159
def circle_area(radius):
return PI * radius * radius
print(f"{PI:.4f}")
print(f"{circle_area(2.0):.2f}")
print(f"and the real one: {math.pi:.4f}")OCaml modules nest, can be defined inline, can be passed to functors and packed into values. Python modules are files in directories, created as objects the first time they are imported — and the import runs the file's top-level code, which is why a module with side effects at the top level is a design error and why
if __name__ == "__main__": exists. Python has no nested modules, no signatures and no functors; what it has is a very large standard library and an even larger index of third-party packages, which is a substantial part of the reason to learn it.No Functors, No Signatures
The whole functor apparatus dissolves, and what it was buying dissolves with it.
module type Comparable = sig
type t
val compare : t -> t -> int
end
module MakeLargest (Element : Comparable) = struct
let largest items =
List.fold_left
(fun best item -> if Element.compare item best > 0 then item else best)
(List.hd items) (List.tl items)
end
module IntCompare = struct
type t = int
let compare = compare
end
module LargestInt = MakeLargest (IntCompare)
let () = Printf.printf "%d\n" (LargestInt.largest [ 3; 9; 4 ])# Duck typing makes the parameterization disappear: the
# function works for anything comparable with >, checked
# at the moment of comparison and not before.
def largest(items):
best = items[0]
for item in items[1:]:
if item > best:
best = item
return best
print(largest([3, 9, 4]))
print(largest(["fig", "apple", "banana"]))An OCaml functor exists to say "this code works for any type that supports these operations," and to have the compiler check that claim. Python makes the same code work for anything at all and checks nothing:
largest above works on numbers and on strings, and on a list of mixed types it raises TypeError at the comparison. That is the exchange in one row — enormous flexibility for no guarantee. Python's partial answer is the typing.Protocol class, which describes a structural interface that a type checker can verify statically; it is opt-in, and it is covered in the type-hints section.The Standard Library Is Enormous
This is a large part of the answer to "why would I write Python", and it is worth stating plainly rather than leaving implied.
(* OCaml's standard library is deliberately small. JSON,
dates and counting all need opam packages, so this
column does what it can with what ships. *)
let () =
let words = [ "apple"; "fig"; "apple"; "banana"; "fig"; "apple" ] in
let counts = Hashtbl.create 8 in
List.iter
(fun word ->
let current = Option.value (Hashtbl.find_opt counts word) ~default:0 in
Hashtbl.replace counts word (current + 1))
words;
let pairs = Hashtbl.fold (fun word count acc -> (word, count) :: acc) counts [] in
let sorted = List.sort (fun (_, left) (_, right) -> compare right left) pairs in
List.iter (fun (word, count) -> Printf.printf "%s=%d " word count) sorted;
print_newline ()import json
from collections import Counter
words = ["apple", "fig", "apple", "banana", "fig", "apple"]
counts = Counter(words)
for word, count in counts.most_common():
print(f"{word}={count}", end=" ")
print()
print(json.dumps(dict(counts.most_common(2))))Counting occurrences and sorting by frequency is fifteen lines of hand-rolled hash-table work in OCaml and one
Counter in Python — and JSON serialization, which OCaml needs an opam package for, is one import. The standard library ships date handling, regular expressions, CSV, SQLite, HTTP, compression, subprocess control, statistics and much more, and PyPI carries the rest. OCaml's library is small on purpose, with a rich opam ecosystem outside it; the practical difference is that in Python the thing you need is usually already installed. This is a real advantage and it is why so much data work happens here.Error Handling
Exceptions
Exceptions are idiomatic in both languages, and Python leans on them very much harder.
exception Too_large of int
let check value =
if value > 100 then raise (Too_large value) else value
let () =
Printf.printf "%d\n" (check 50);
(try Printf.printf "%d\n" (check 500) with
| Too_large value -> Printf.printf "too large: %d\n" value)class TooLarge(Exception):
def __init__(self, value):
super().__init__(f"too large: {value}")
self.value = value
def check(value):
if value > 100:
raise TooLarge(value)
return value
print(check(50))
try:
print(check(500))
except TooLarge as error:
print(f"too large: {error.value}")OCaml declares an exception with a keyword and catches it by pattern in a
try … with. Python's exceptions are classes inheriting from Exception, caught by type with except, and they carry a traceback. The cultural difference is scale: because Python has no option or result in general use, exceptions carry nearly all error handling — a missing dictionary key, a failed parse, a file that is not there, and the end of an iterator are all exceptions. Python also has finally and the with statement for cleanup, where OCaml offers Fun.protect.No result Type
OCaml can make failure part of the return type. Python has no idiomatic way to do that, and the difference in discipline is real.
(* A fallible function returns a value the caller CANNOT
use without handling the failure. *)
let parse_positive text =
match int_of_string_opt text with
| None -> Error (Printf.sprintf "%S is not a number" text)
| Some number when number <= 0 -> Error "must be positive"
| Some number -> Ok number
let () =
(match parse_positive "42" with
| Ok number -> Printf.printf "%d\n" number
| Error message -> print_endline message);
(match parse_positive "oops" with
| Ok number -> Printf.printf "%d\n" number
| Error message -> print_endline message)# Python raises instead. The caller is not forced to
# handle anything; an unhandled error propagates upward.
class NotPositive(ValueError):
pass
def parse_positive(text):
number = int(text) # raises ValueError on bad input
if number <= 0:
raise NotPositive("must be positive")
return number
for candidate in ("42", "oops"):
try:
print(parse_positive(candidate))
except ValueError as error:
print(error)OCaml's
result puts the failure in the type, so the caller cannot reach the value without deciding what to do about the error — the compiler will not let a failure be ignored. Python has no such convention: the standard library raises, third-party libraries raise, and a caller who writes no try simply lets the exception travel up. That is not always worse — an error that nobody can handle locally is better propagated than threaded through every signature — but it does mean the set of exceptions a function may raise is documentation rather than type. Note that NotPositive subclasses ValueError so one except catches both failures, which is the standard way to build an error hierarchy.Guaranteed Cleanup
Both guarantee cleanup on the way out, and Python gives it dedicated syntax that reads at the use site.
(* Fun.protect runs the finalizer whether or not the
body raises. *)
let () =
let acquire () = print_endline "acquired"; ref true in
let release resource = resource := false; print_endline "released" in
let resource = acquire () in
Fun.protect ~finally:(fun () -> release resource)
(fun () -> print_endline "using it");
Printf.printf "still open? %b\n" !resourcefrom contextlib import contextmanager
@contextmanager
def resource():
print("acquired")
state = {"open": True}
try:
yield state
finally:
state["open"] = False
print("released")
with resource() as handle:
print("using it")
print(f"still open? {handle['open']}")OCaml's
Fun.protect takes the body as a function and a ~finally to run afterwards, which works but reads as a higher-order call. Python's with statement is syntax: the object it is given has its cleanup run when the block ends, however it ends. @contextmanager turns a generator into one — everything before the yield is setup, everything after is teardown — so writing one is short. This is why Python code opens files as with open(path) as file: rather than remembering to close them, and it is a pattern worth adopting wholesale.Type Hints & mypy
Type Hints Are Not Checked at Runtime
The most important thing to understand about Python's type hints, and the thing their syntax most obscures.
(* The annotation is checked by the compiler, and the
program will not run until it holds. *)
let double (value : int) : int = value * 2
let () = Printf.printf "%d\n" (double 21)# The annotation is documentation the interpreter ignores
# entirely. An external checker reads it; Python does not.
def double(value: int) -> int:
return value * 2
print(double(21))
# This VIOLATES the annotation and runs anyway:
print(double("ab"))
print(f"the annotation is just data: {double.__annotations__}")The annotation
value: int is stored on the function and otherwise ignored: double("ab") runs and returns "abab", because str * 2 is a valid operation. Nothing in the interpreter enforces a hint, ever. What makes hints useful is an external checker — mypy, pyright or the one built into an editor — run as a separate step, the way a linter is. Used seriously across a whole codebase, that recovers a genuine fraction of what OCaml gives you. Used partially, it recovers a smaller fraction, because a function with no hints is treated as accepting and returning anything.Spelling option in Hints
The
| None in the return type is Python's option, and a type checker will insist you handle it.let find_even numbers = List.find_opt (fun number -> number mod 2 = 0) numbers
let () =
match find_even [ 1; 3; 4 ] with
| Some number -> Printf.printf "%d\n" number
| None -> print_endline "none"def find_even(numbers: list[int]) -> int | None:
for number in numbers:
if number % 2 == 0:
return number
return None
found = find_even([1, 3, 4])
if found is not None:
print(found)
else:
print("none")int | None is a union type, spelled with | since Python 3.10 and as Optional[int] before that. To a checker such as mypy it behaves much like OCaml's int option: writing found + 1 without first narrowing is reported as an error, and the if found is not None above is what narrows it. The differences from OCaml are that there is no wrapper — a present value is the integer, with no Some to unwrap — and that all of it is advisory, since the interpreter runs the code either way.Protocols: a Checkable Interface
Protocols give duck typing a written-down form that a checker can verify — the nearest Python gets to a signature.
(* A signature names what a module must provide, and a
functor requires it. Everything is checked. *)
module type Shaped = sig
type t
val area : t -> float
end
module Square : Shaped with type t = float = struct
type t = float
let area side = side *. side
end
let () = Printf.printf "%.2f\n" (Square.area 3.0)from typing import Protocol
# A Protocol describes a shape structurally. A checker
# verifies it; nothing is declared on the class itself.
class Shaped(Protocol):
def area(self) -> float: ...
class Square:
def __init__(self, side: float):
self.side = side
def area(self) -> float:
return self.side * self.side
def describe(shape: Shaped) -> None:
print(f"{shape.area():.2f}")
describe(Square(3.0))A
Protocol is structural: Square does not inherit from Shaped or mention it, and it satisfies the protocol simply by having a matching area. That is much closer to OCaml's structurally-typed objects than to its module signatures, and it is genuinely useful — it lets an existing class from a library satisfy your interface without modification. What it does not do is carry an abstract type the way module type Shaped's type t does, and like every hint it binds only the checker.Generic Functions
Both work for any element type. OCaml infers the generalization; Python needs it written down, and even then only for the checker.
(* Inferred and generalized automatically: no annotation
anywhere, and the type is 'a list -> 'a. *)
let first_or items fallback =
match items with
| [] -> fallback
| head :: _ -> head
let () =
Printf.printf "%d\n" (first_or [ 1; 2 ] 0);
print_endline (first_or [] "empty")# Since 3.12 a function declares its own type parameter,
# and only a checker cares.
def first_or[T](items: list[T], fallback: T) -> T:
if not items:
return fallback
return items[0]
print(first_or([1, 2], 0))
print(first_or([], "empty"))OCaml infers
'a list -> 'a -> 'a with no annotation, and generalizes automatically — parametric polymorphism is the default, not a feature you reach for. Python 3.12 introduced the [T] syntax that declares a type variable inline (earlier versions needed TypeVar imported from typing), and a checker uses it to relate the argument types to the result. At runtime it means nothing at all: both calls above would work identically with every annotation deleted. The genuine benefit is that a checker will now reject first_or([1, 2], "zero"), which is a real mistake it would otherwise miss.Concurrency & the GIL
Threads and the Global Interpreter Lock
Both spawn workers. Only one of them gets more than one core out of it, and that is the most consequential runtime fact about Python.
(* OCaml 5's domains run on separate cores in genuine
parallel. *)
let () =
let worker value = Domain.spawn (fun () -> value * value) in
let workers = List.map worker [ 1; 2; 3; 4 ] in
let results = List.map Domain.join workers in
List.iter (Printf.printf "%d ") results;
print_newline ()import threading
# Threads are real OS threads, but historically only one
# executes Python bytecode at a time.
results = {}
def worker(value):
results[value] = value * value
threads = [threading.Thread(target=worker, args=(value,))
for value in [1, 2, 3, 4]]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
for value in sorted(results):
print(results[value], end=" ")
print()OCaml 5's
Domain is an OS thread with its own minor heap, and several domains genuinely run at once. Python threads are also OS threads, but the global interpreter lock has meant that only one executes Python bytecode at a time, so CPU-bound work gets no speedup from them at all — they help only when threads are waiting on input and output, which releases the lock. The escape has traditionally been multiprocessing, which forks separate interpreters. This is changing: recent Pythons ship an optional free-threaded build with no lock, but it is not yet the default and library support is still arriving.Asynchronous Concurrency
Both languages can suspend a computation and resume it. One does it with a general mechanism and no new keywords; the other with two keywords that spread.
(* OCaml 5's effect handlers let ordinary-looking code
suspend, so there is no async keyword and no colored
functions. Here is the raw mechanism, one perform. *)
open Effect
open Effect.Deep
type _ Effect.t += Pause : unit Effect.t
(* task is an ORDINARY function with an ordinary type. It
suspends, and nothing above it had to be marked. *)
let task () =
print_endline "before";
perform Pause;
print_endline "after";
42
let () =
let result =
match_with task ()
{ retc = (fun value -> value)
; exnc = raise
; effc = (fun (type a) (performed : a Effect.t) ->
match performed with
| Pause -> Some (fun (continuation : (a, _) continuation) ->
continue continuation ())
| _ -> None) }
in
Printf.printf "%d\n" resultimport asyncio
# async/await COLORS every function: task must be async to
# suspend, main must be async to await it, and the top of
# the program needs asyncio.run to start the whole chain.
async def task():
print("before")
await asyncio.sleep(0)
print("after")
return 42
async def main():
result = await task()
print(result)
asyncio.run(main())Both columns print the same three lines, and everything that matters is in the code that produced them. Python's
async/await is a compile-time transformation into a state machine, and it colors functions: task must be async to suspend, main must be async to await it, and asyncio.run is needed to start the chain — so introducing one asynchronous call at the bottom means changing every function above it. OCaml 5's task is an ordinary function with an ordinary type that suspends anyway, because the handler rather than the callee decides what suspension means. That is why OCaml 5 has no async keyword and why libraries such as Eio present blocking-looking APIs that do not block. The OCaml column shows the raw mechanism rather than a scheduler, since the scheduler is a library.Shared Mutable State
Both reach 2000, and both leave it entirely to the programmer to remember the lock.
let () =
let lock = Mutex.create () in
let total = ref 0 in
let bump () =
for _ = 1 to 1000 do
Mutex.lock lock;
total := !total + 1;
Mutex.unlock lock
done
in
let first = Domain.spawn bump in
let second = Domain.spawn bump in
Domain.join first;
Domain.join second;
Printf.printf "%d\n" !totalimport threading
total = 0
lock = threading.Lock()
def bump():
global total
for _ in range(1000):
with lock:
total += 1
threads = [threading.Thread(target=bump) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(total)Neither language ties the lock to the data, so deleting the locking in either column leaves a program that compiles, runs, and quietly loses increments. Python's
with lock: is at least harder to unbalance than OCaml's explicit Mutex.lock/Mutex.unlock pair, since the release happens however the block exits. The global declaration is worth noting: without it, total += 1 would create a local variable rather than rebinding the module-level one, and the function would raise UnboundLocalError — assignment anywhere in a function body makes that name local for the whole body.