curayious Language Lab

Python — The Contrast Case

Why here, and not first. You already write Python, so a track that teaches it is wasted. Its job in this series is to be the contrast: read immediately after Java 8, the same program in a third of the ceremony, and the question stops being "which is better" and becomes "what exactly did the ceremony buy, and when is that worth it". That question is answerable only by having written both.

It is also the reference implementation — bench/expected/logagg.txt is generated from this file — which makes it the one track where being unambiguous matters more than being clever.

Q1 · Origin and niche

Guido van Rossum, CWI Amsterdam, 1991, as a successor to a teaching language called ABC. The founding constraint was readability by people who are not primarily programmers, and it is visible in every decision: significant whitespace, for x in xs, no braces, one obvious way to do it.

Python spent its first fifteen years as a scripting and glue language and then won two enormous territories almost by accident: scientific computing (NumPy, 2006 — because the C extension API made it a comfortable front end for FORTRAN and C numerical code) and, on top of that, machine learning. Neither was designed for. Both happened because Python is unusually good at being a driver for fast code written in something else.

Who pays for it today: ML and data science (near-totally), data engineering, DevOps and infrastructure automation, scientific research, web backends (Django, FastAPI), and an uncountable number of internal scripts that quietly became load-bearing.

Q2 · Hello, build

There is no build.

python logagg.py bench/data/access.log

What replaces the build step is environment management, and it is the single hardest practical thing about Python — harder than the language:

Tool What it is
venv Stdlib, creates an isolated interpreter + site-packages. The baseline.
pip The installer. Resolves poorly, but universal.
poetry / pdm Lockfiles and reproducible resolution
uv Rust-implemented, dramatically faster, now the default recommendation
conda Separate ecosystem; handles non-Python native deps, which matters for scientific stacks

Shipping is where Python is genuinely weak. There is no equivalent of a static binary or a fat jar. The options — a container, pyinstaller, zipapp, or "just install Python on the target" — are all worse than what C and Java offer, and this is the main reason Go took so much of Python's infrastructure-tooling territory after 2012.

Q3 · Type and memory model

A variable is a name bound to an object in a namespace. Not a memory cell (C), not a reference slot with a declared type (Java) — a dictionary entry. x = 5 puts the name x into a namespace dict pointing at an int object. Rebinding x changes the dict; it does not change any memory the old value occupied.

Follows directly:

Typing is dynamic and strong. Dynamic: types live on values, not names. Strong: 1 + "x" is a TypeError, not "1x" — which is the distinction JavaScript fails and the reason "dynamically typed" alone is not a useful description of a language.

Type annotations (3.5+) are real and useful and not enforced at runtime. mypy or pyright check them; the interpreter ignores them entirely. logagg.py is fully annotated for exactly that reason — the annotations are documentation the tooling happens to verify.

Memory is reference counting, plus a cycle collector. That combination is unusual and has consequences:

Q4 · The unit of abstraction

The module, and then the object — but the object is a bag of attributes.

Python has classes, but it does not believe in them the way Java does. There is no private (only a _name convention and __name mangling), attributes can be added at runtime, and any object with the right method satisfies any "interface":

stats = res.services.setdefault(service, Stats())

Stats is a @dataclass here because it is a bag of four counters with a method, and @dataclass generates __init__, __repr__, and __eq__ from the annotations. The Java track needed the same thing and wrote the constructor by hand — and Java 16's record is precisely this feature, arriving eight years later.

Duck typing is the real abstraction mechanism. logagg's aggregate() takes a path, but a version taking any iterable of strings would work identically for a file, a list, a socket, or a generator — with no interface declared anywhere. That is enormously convenient and it is also why Python codebases past ~50k lines start adding Protocol types and a type checker: the contract exists, it was just never written down.

The module is the more important unit in practice. import is the namespacing mechanism, a module is a singleton object, and "just put it in a module-level function" is idiomatic in a way it simply cannot be in Java.

Q5 · The error model

Exceptions, all unchecked, and used for control flow far more liberally than in Java.

The cultural rule is EAFP — easier to ask forgiveness than permission:

try:
    value = d[key]
except KeyError:
    value = default

is idiomatic, where the Java instinct (if (d.containsKey(key))) is described disapprovingly as LBYL. Iteration itself terminates by raising StopIteration. Exceptions are cheap and ordinary here in a way they are not elsewhere.

logagg.py deliberately does not use them for the malformed-line path:

reason = classify(fields)
if reason is not None:
    res.malformed.append((lineno, reason))
    continue

The EAFP version — try: micros = parse(...) except ValueError: — is arguably more Pythonic and would be wrong for this spec, because SPEC 1 requires a specific ordered reason, and catching ValueError cannot tell you whether it was the status or the latency that failed. That is a small, real instance of a general truth: idiomatic and correct are not the same axis, and the spec wins.

The fatal path catches OSError — the base class covering FileNotFoundError, PermissionError, and IsADirectoryError, all of which must produce RC 12.

Q6 · The concurrency model

Threads that cannot run Python bytecode in parallel.

The Global Interpreter Lock is one mutex around the interpreter. Only one thread executes bytecode at a time. It exists because reference counts would otherwise need atomic updates on every single object touch, which would cost more in single-threaded performance than multi-core parallelism gains for most Python programs. It is a defensible trade that has aged badly.

What that means concretely:

Workload Use Why
CPU-bound multiprocessing Separate interpreters, real parallelism, pay for IPC
I/O-bound threading or asyncio The GIL is released during I/O waits
Numeric NumPy, etc. The GIL is released inside the C extension

For --shard N (SPEC 6), the Python answer is multiprocessing.Pool, each worker returning a partial dict, merged in the parent. The threading version would be slower than serial — the GIL serialises the work and you pay for the context switching. Writing both and timing them is the exercise, and the result is the most durable thing this track teaches.

Worth knowing for the follow-up: PEP 703 (free-threaded CPython, optional-no-GIL) shipped as an experimental build in 3.13 and is on a multi-year path to becoming the default. This section will need rewriting, and that is a good reason for it to be in git.

Q7 · The idiom test — logagg

logagg.py · annotated walkthrough in NOTES.md

PASS  python

~190 lines, and most of the difference from Java is in three places:

C Java 8 Python
Growable line buffer 45 lines in.readLine() for raw in fh
Hash map 60 lines computeIfAbsent setdefault
Sort by two keys qsort + comparator Comparator.comparingLong(...).reversed().thenComparing(...) key=lambda kv: (-kv[1].count, kv[0])

That last row is the most telling. (-count, name) is a tuple, compared element-wise, with the negation doing the descending sort. It is shorter than the Java comparator chain and, more to the point, it has no type-inference ambiguity to work around — the cost of Java's version was not verbosity but the (Stats s) annotation the compiler needed.

Two places Python's defaults had to be overridden:

with open(path, "r", encoding="ascii", newline="") as fh:

newline="" disables universal-newline translation. Without it a CRLF fixture is silently normalised, which sounds helpful and means the program cannot detect a real problem in its input. encoding="ascii" is explicit for the same reason Locale.ROOT was in Java: the default is read from the environment.

sys.stdout.buffer.write(render(source, res).encode("ascii"))

print() on Windows emits \r\n. Same problem as C's _setmode and Java's System.out.write — all three tracks hit it, and all three solve it differently, which is a better illustration of what a "standard library" is than any amount of prose.

Q8 · How it fails

  1. Mutable default arguments. def f(xs=[]) evaluates [] once, at definition. Every call shares the same list. The fix is xs=None.
  2. Late-binding closures. [lambda: i for i in range(3)] gives three functions all returning 2. i is looked up when called, not when created.
  3. is versus ==. is compares identity. It appears to work for small integers and short strings because CPython interns them, then fails for 1000. Use it only for None.
  4. Shallow copy. list(xs) copies the list, not the objects in it. copy.deepcopy exists and is usually a sign the data model is wrong.
  5. The exception handler that catches everything. except: catches KeyboardInterrupt and SystemExit. Always except Exception: at minimum, and name the exception you expect.
  6. Errors appear at runtime, on the unlucky branch. A typo'd attribute in the error path of a rarely-hit function is a production incident that the C and Java tracks would both have caught at compile time. This is the cost of dynamic typing and no amount of testing fully removes it — which is the argument for annotations plus mypy in anything long-lived.

Q9 · Read real code

Recommended: CPython's Lib/dataclasses.py. You use @dataclass in this very track, it is pure Python, and it is a masterclass in metaprogramming — it literally builds __init__ as a source string and execs it, with a comment explaining why that is faster than the alternatives. Reading it turns the decorator from magic into machinery.

Alternative: Lib/collections/__init__.py — OrderedDict and Counter. Short, familiar, and full of the small defensive habits of stdlib code.

For the other direction: httpx or attrs — modern, well-typed third-party code showing what Python looks like when annotations are taken seriously from the start.

Q10 · What transfers