PONY λ M2 Modula-2

Kotlin.CodeCompared.To/Python

An interactive executable cheatsheet comparing Kotlin and Python

Kotlin 2.3 Python 3.13
Basics & Syntax
Hello, World
fun main() { println("Hello, World!") }
print("Hello, World!")
No main, no enclosing function, no braces, no semicolon — a Python file is the program, executed top to bottom. The ceremony Kotlin needs to reach a single statement is exactly what Python removed, and the gap only widens from here.
Indentation is the syntax
Braces do not delimit blocks in Python — indentation does, and a colon opens each one. This is not a style convention the formatter enforces; it is the grammar.
fun classify(number: Int): String { if (number < 0) { return "negative" } else if (number == 0) { return "zero" } else { return "positive" } } fun main() { println(classify(-3)) }
def classify(number): if number < 0: return "negative" elif number == 0: return "zero" else: return "positive" print(classify(-3))
Conditions need no parentheses, blocks need no braces, and elif saves the nested else if. The consequence Kotlin developers feel first: an if is a statement, not an expression, so val x = if (…) a else b becomes the conditional expression x = a if … else b — readable once, backwards forever.
There is no val
Kotlin makes you choose val or var on every binding. Python offers only var — there is no immutable local binding in the language.
fun main() { val fixed = 1 var counter = 0 counter += 1 // fixed = 2 ← compile error println(fixed + counter) }
fixed = 1 counter = 0 counter += 1 fixed = 2 # perfectly legal — nothing prevents it print(fixed + counter) FIXED: int = 1 # SCREAMING_CASE + a type hint is the only "constant" print(FIXED)
The convention is an all-caps name and the honor system; Final from typing exists but, like every hint, binds nothing at runtime. Immutability in Python lives at the value level instead — tuples, frozenset, frozen dataclasses — not at the binding, so bring the val discipline with you as a habit, because the language will not keep it for you.
String templates are f-strings
fun main() { val name = "Ada" val age = 36 println("$name is $age") println("Next year: ${age + 1}") val multiline = """ Dear $name, Regards """.trimIndent() println(multiline) }
name = "Ada" age = 36 print(f"{name} is {age}") print(f"Next year: {age + 1}") multiline = f"""Dear {name}, Regards""" print(multiline)
The f prefix is mandatory — without it, "{name}" is the literal text, a silent bug Kotlin's always-on $ cannot produce. In exchange, every slot takes a full expression with no ${…} escalation, plus format specs Kotlin sends to String.format: f"{value:.2f}", f"{value:>8}". Triple quotes work like Kotlin's raw strings but have no trimIndent(), so leading whitespace is preserved exactly as written.
Dynamic Typing
Duck typing replaces interfaces
The central adjustment. Kotlin dispatches on a declared type; Python looks up the method by name at the moment of the call. Nothing needs to implement anything.
interface Describable { fun describe(): String } class Dog : Describable { override fun describe() = "a dog" } class Robot : Describable { override fun describe() = "a robot" } fun announce(thing: Describable) { println("This is ${thing.describe()}") } fun main() { announce(Dog()) announce(Robot()) }
class Dog: def describe(self): return "a dog" class Robot: # no interface, no inheritance, no declaration def describe(self): return "a robot" def announce(thing): print(f"This is {thing.describe()}") announce(Dog()) announce(Robot())
If it has describe, it works — "if it walks like a duck." No common supertype exists, nothing was declared, and the two classes have no relationship whatsoever. The freedom is real (any object can stand in for any other in a test), and so is the cost: pass something without describe and you learn about it from an AttributeError in production, not from the compiler. typing.Protocol exists to describe such a shape for static checkers, but it still binds nothing at runtime.
Type hints are not checked
Python annotations look reassuringly like Kotlin's. They are documentation. The interpreter parses them, stores them, and enforces nothing.
fun double(number: Int): Int = number * 2 fun main() { println(double(21)) // double("nope") ← compile error: type mismatch }
def double(number: int) -> int: return number * 2 print(double(21)) print(double("nope")) # runs happily — and "doubles" the string! print(double.__annotations__)
That second call is the whole lesson: the hint says int, the argument is a str, and Python runs it anyway — * on a string repeats it, so you get nopenope instead of an error. The annotation is inert metadata, retrievable at runtime and ignored by it. Static checking is a separate opt-in tool (mypy, pyright) that you must run yourself in CI; nothing about writing hints causes them to be verified.
Compile errors become runtime errors
The practical consequence of everything above: mistakes Kotlin rejects at build time survive in Python until the line actually executes.
class Person(val name: String) fun main() { val person = Person("Ada") println(person.name) // println(person.nmae) ← compile error: unresolved reference // Kotlin never ships this typo. }
class Person: def __init__(self, name): self.name = name person = Person("Ada") print(person.name) try: print(person.nmae) # typo — discovered only when this line runs except AttributeError as error: print(f"AttributeError: {error}")
A misspelled attribute is not a compile error; it is an AttributeError raised the instant that branch runs — which may be a rare error path in production at 3 a.m. This is why the Python ecosystem leans so hard on tests (they are the type checker), on linters, and increasingly on mypy in CI. Coming from Kotlin, the correct instinct is not "Python is unsafe" but "the safety net is opt-in and I must install it myself."
None & Missing Null Safety
None is null, unguarded
Kotlin's billion-dollar-mistake fix — T versus T?, enforced by the compiler — has no counterpart. Every Python reference can be None, and nothing tracks which ones are.
fun main() { val ages = mapOf("Ada" to 36) val age: Int? = ages["Grace"] println(age) // println(age + 1) ← compile error: age might be null println(if (age != null) age + 1 else 0) }
ages = {"Ada": 36} age = ages.get("Grace") # None — no error, no warning print(age) try: print(age + 1) # boom, at runtime except TypeError as error: print(f"TypeError: {error}") print(age + 1 if age is not None else 0)
There is no T?, so there is no compiler to stop age + 1; you get a TypeError when the value happens to be missing. Note also the two ways to ask a dict for a key: ages["Grace"] raises KeyError, while ages.get("Grace") returns None — Kotlin's get versus getValue distinction, with the dangerous one as the bracket syntax. Always compare with is None, never == None: is tests identity and cannot be overridden by a class.
No ?. and no ?:
The operators you reach for reflexively do not exist. Safe navigation must be written out by hand.
class Address(val city: String?) class Person(val address: Address?) fun main() { val person = Person(Address(null)) val city = person.address?.city ?: "unknown" println(city) person.address?.city?.let { println(it.uppercase()) } }
class Address: def __init__(self, city): self.city = city class Person: def __init__(self, address): self.address = address person = Person(Address(None)) city = "unknown" if person.address is not None and person.address.city is not None: city = person.address.city print(city) if person.address is not None and person.address.city is not None: print(person.address.city.upper())
The chain person.address?.city ?: "unknown" — one readable line in Kotlin — becomes an explicit guard, and the deeper the nesting the worse it gets. Python's partial answers: or serves as a rough ?: (value or "unknown"), but it is a falsy check, so 0 and "" trigger it too — a classic bug. getattr(obj, "attr", default) covers one hop. There is no elvis, no safe call, and no smart cast; this is the single sharpest edge of the move.
Optional[T] is a hint, not a guard
fun findAge(name: String): Int? = mapOf("Ada" to 36)[name] fun main() { val age = findAge("Ada") // Smart cast: after the null check, age is Int if (age != null) { println(age + 1) } println(findAge("Grace")) }
def find_age(name: str) -> int | None: return {"Ada": 36}.get(name) age = find_age("Ada") if age is not None: print(age + 1) # mypy narrows here; the interpreter does not care print(find_age("Grace"))
int | None (the modern spelling of Optional[int]) is exactly as binding as every other hint: not at all. Run mypy and it does narrow after the is not None check — Kotlin's smart cast, reproduced by an external tool — and it will flag an unguarded age + 1. Run plain python and you get nothing. The type safety is available; you just have to choose it, wire it into CI, and keep it green.
Functions & Lambdas
Default & named arguments
Familiar ground — with one trap that bites every newcomer.
fun greet(name: String, greeting: String = "Hello", excited: Boolean = false): String { val mark = if (excited) "!" else "." return "$greeting, $name$mark" } fun main() { println(greet("Ada")) println(greet("Ada", excited = true)) }
def greet(name, greeting="Hello", excited=False): mark = "!" if excited else "." return f"{greeting}, {name}{mark}" print(greet("Ada")) print(greet("Ada", excited=True)) def add_item(item, basket=[]): # THE classic Python bug basket.append(item) return basket print(add_item("apple")) print(add_item("pear")) # ['apple', 'pear'] — the default is SHARED
Defaults and named arguments work as you expect, right up to the mutable-default trap: the default value is evaluated once, when the function is defined, and the same list is reused on every call that omits it. Kotlin re-evaluates its default expression per call, so this bug is unwritable there. The fix is invariable: default to None and build the real value inside the body (if basket is None: basket = []).
Lambdas are one expression only
Kotlin's lambdas are the backbone of its DSLs and collection chains. Python's lambda is deliberately crippled — a single expression, no statements, no block.
fun main() { val numbers = listOf(3, 1, 2) println(numbers.sortedBy { it }) println(numbers.map { it * 2 }) val process: (Int) -> Int = { value -> val doubled = value * 2 doubled + 1 } println(process(5)) }
numbers = [3, 1, 2] print(sorted(numbers, key=lambda value: value)) print([value * 2 for value in numbers]) # not map + lambda — a comprehension def process(value): # multi-statement "lambda" must be a named function doubled = value * 2 return doubled + 1 print(process(5))
There is no it, no trailing-lambda syntax, no multi-statement body — anything with more than one expression must become a def, which is why nested functions are so common in Python and why the trailing-lambda DSLs Kotlin is famous for (buildString { }, Gradle scripts, Compose) have no Python analog. The idiomatic replacement for map { }/filter { } is not lambda at all; it is the comprehension, coming up next.
No extension functions
One of Kotlin's best ideas has no equivalent. You cannot add a method to str — and the thing that looks like you can is a trap.
fun String.shout(): String = this.uppercase() + "!" fun List<Int>.secondOrNull(): Int? = if (size >= 2) this[1] else null fun main() { println("hello".shout()) println(listOf(1, 2, 3).secondOrNull()) }
def shout(text): # a plain function — call it, do not chain it return text.upper() + "!" def second_or_none(items): return items[1] if len(items) >= 2 else None print(shout("hello")) print(second_or_none([1, 2, 3])) # "str.shout = shout" raises TypeError: built-in types are closed. # Monkey-patching YOUR OWN classes is possible — and frowned upon.
Free functions take the object as the first parameter, so calls nest (shout(clean(text))) instead of chaining (text.clean().shout()) — the readability tax you pay for the rest of your Python life. Built-in types are closed to new attributes outright; your own classes can be monkey-patched at runtime, but that mutates the class globally for every user of it, which is exactly the fragility extension functions were invented to avoid. Nor are there scope functions: no let, apply, also, or run.
Decorators: the thing Kotlin lacks
The trade runs the other way here. A decorator wraps a function in another function using @ syntax — ordinary runtime composition, not an annotation, and not a compiler plugin.
// Kotlin has no decorators. The nearest equivalents are // higher-order wrappers you must call explicitly... fun <T> logged(name: String, block: () -> T): T { println("calling $name") val result = block() println("$name returned $result") return result } fun add(first: Int, second: Int) = first + second fun main() { val total = logged("add") { add(2, 3) } println(total) }
import functools def logged(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 @logged def add(first, second): return first + second print(add(2, 3))
The @logged line is pure sugar for add = logged(add) — the decoration happens once, at import, and every caller gets the wrapped version for free. This is how Flask routes, @dataclass, @property, @functools.cache, and pytest fixtures all work, and it is genuinely more powerful than anything Kotlin offers without annotation processing or a compiler plugin. *args, **kwargs forwards any signature; @functools.wraps preserves the wrapped function's name and docstring.
Collections & Comprehensions
list, dict, set, tuple
The four built-ins, all with literal syntax — and, crucially, the mutable/immutable split is drawn very differently from Kotlin's.
fun main() { val numbers = listOf(1, 2, 3) // immutable val mutable = mutableListOf(1, 2, 3) // mutable mutable.add(4) val ages = mapOf("Ada" to 36) val unique = setOf(1, 2, 2, 3) val pair = "Ada" to 36 println(numbers) println(mutable) println(ages["Ada"]) println(unique) println("${pair.first} ${pair.second}") }
numbers = [1, 2, 3] # list — ALWAYS mutable numbers.append(4) ages = {"Ada": 36} # dict unique = {1, 2, 2, 3} # set pair = ("Ada", 36) # tuple — immutable print(numbers) print(ages["Ada"]) print(unique) name, age = pair # destructuring print(f"{name} {age}")
There is no listOf/mutableListOf distinction: a list is always mutable, and the only immutable sequence is the tuple — used for fixed-shape records (Kotlin's Pair/Triple, with any arity) rather than as a read-only list. So the Kotlin habit of returning List<T> to signal "do not modify this" has no enforcement here; you return a list and trust the caller. Watch the literals: {} is an empty dict, not a set — the empty set is set().
Comprehensions replace map/filter
The idiom that most defines Python code. Kotlin chains methods off the collection; Python builds the new collection in a bracketed expression.
fun main() { val squares = (1..10) .filter { it % 2 == 0 } .map { it * it } println(squares) val lengths = listOf("Ada", "Grace") .associateWith { it.length } println(lengths) }
squares = [number * number for number in range(1, 11) if number % 2 == 0] print(squares) lengths = {name: len(name) for name in ["Ada", "Grace"]} print(lengths) uniform = {len(name) for name in ["Ada", "Eve"]} # set comprehension print(uniform)
Read it middle-out: the for clause first, then the if filter, then the leading expression that builds each element. It fuses map and filter into one pass with no intermediate list, and the same brackets give you dict and set comprehensions. Note range(1, 11): Python ranges are half-open, so the end is excluded — the exact opposite of Kotlin's inclusive 1..10, and a reliable source of off-by-one errors in your first month.
Where the chain breaks down
Kotlin's collection API is huge and chains fluently. Python's is small, and half of it is free functions that wrap rather than chain.
fun main() { val names = listOf("ada", "grace", "alan") val result = names .filter { it.length > 3 } .map { it.replaceFirstChar { char -> char.uppercase() } } .sorted() .joinToString(", ") println(result) println(names.sumOf { it.length }) println(names.groupBy { it.first() }) }
from itertools import groupby names = ["ada", "grace", "alan"] result = ", ".join(sorted(name.capitalize() for name in names if len(name) > 3)) print(result) print(sum(len(name) for name in names)) grouped = { key: list(group) for key, group in groupby(sorted(names), key=lambda name: name[0]) } print(grouped)
sorted, sum, len, and join are functions applied around the collection, so a pipeline reads inside-out instead of left-to-right — the biggest ergonomic loss coming from Kotlin, and the reason comprehensions carry so much weight. There is no groupBy that just works either: itertools.groupby only groups adjacent equal keys, so you must sort first (Kotlin's does not care). collections.defaultdict is the usual escape hatch.
Classes & Data
Classes and the explicit self
No primary constructor, no this — every method takes the receiver as its first parameter, spelled out, by convention named self.
class Person(val name: String, var age: Int) { fun birthday() { age += 1 } fun greet() = "Hi, I'm $name (${age})" } fun main() { val person = Person("Ada", 36) person.birthday() println(person.greet()) }
class Person: def __init__(self, name, age): self.name = name # attributes are created by assigning them self.age = age def birthday(self): self.age += 1 def greet(self): return f"Hi, I'm {self.name} ({self.age})" person = Person("Ada", 36) person.birthday() print(person.greet())
Kotlin's one-line primary constructor becomes an __init__ method that assigns each field by hand, and self must appear in every signature and before every field access — omit it and you get a bare local variable instead of an attribute, silently. There is no private keyword either: a leading underscore (self._secret) is a convention meaning "internal, do not touch," enforced by nothing but manners.
data class becomes @dataclass
The closest thing to a straight port on this page — a decorator that generates the boilerplate data class gives you for free.
data class Point(val x: Int, val y: Int) fun main() { val point = Point(3, 4) println(point) println(point == Point(3, 4)) val moved = point.copy(y = 5) println(moved) val (x, y) = point println("$x and $y") }
from dataclasses import dataclass, replace @dataclass(frozen=True) class Point: x: int y: int point = Point(3, 4) print(point) print(point == Point(3, 4)) moved = replace(point, y=5) # this is .copy() print(moved) print(f"{point.x} and {point.y}") # no destructuring by default
@dataclass generates __init__, __repr__, and __eq__toString(), equals(), and the constructor. Two differences worth internalizing: it is mutable by default (frozen=True is what makes the fields val, and is also what makes it hashable), and copy is the free function dataclasses.replace. The field annotations are what the decorator reads to find the fields — one of the rare places a type hint is load-bearing, though the types in it still are not checked.
Custom getters become @property
class Rectangle(val width: Int, val height: Int) { val area: Int get() = width * height var scale: Int = 1 set(value) { require(value > 0) { "scale must be positive" } field = value } } fun main() { val rectangle = Rectangle(3, 4) println(rectangle.area) rectangle.scale = 2 println(rectangle.scale) }
class Rectangle: def __init__(self, width, height): self.width = width self.height = height self._scale = 1 @property def area(self): return self.width * self.height @property def scale(self): return self._scale @scale.setter def scale(self, value): if value <= 0: raise ValueError("scale must be positive") self._scale = value rectangle = Rectangle(3, 4) print(rectangle.area) # no parentheses — it looks like a field rectangle.scale = 2 print(rectangle.scale)
The @property decorator turns a method into an attribute read, and @scale.setter adds the write side — the same "uniform access" Kotlin gives you with get()/set(), reached through decorators instead of syntax. The backing field is a plain underscore-prefixed attribute rather than Kotlin's magic field. Because Python has no private, properties are also the standard way to add validation later without changing any caller — the field access keeps working.
Pattern Matching
when becomes match / case
Python gained structural pattern matching in 3.10. It destructures like Kotlin's when with is checks — but it is a statement, and it is not exhaustive.
sealed class Shape data class Circle(val radius: Double) : Shape() data class Rectangle(val width: Double, val height: Double) : Shape() fun area(shape: Shape): Double = when (shape) { is Circle -> 3.14159 * shape.radius * shape.radius is Rectangle -> shape.width * shape.height } fun main() { println(area(Rectangle(3.0, 4.0))) println(area(Circle(1.0))) }
from dataclasses import dataclass @dataclass class Circle: radius: float @dataclass class Rectangle: width: float height: float def area(shape): match shape: case Circle(radius=radius): return 3.14159 * radius * radius case Rectangle(width=width, height=height): return width * height case _: # you must write this yourself raise ValueError(f"unknown shape: {shape}") print(area(Rectangle(3.0, 4.0))) print(area(Circle(1.0)))
The class patterns bind fields straight into names — better destructuring than when's smart casts, honestly. What is missing is the guarantee: match is a statement, so it produces no value (note the return inside each arm rather than when as an expression), and no compiler checks that you covered every case. Fall through every arm without a case _ and the match simply does nothing at all — silently. Guards exist (case Circle(radius=r) if r > 10:), and case _ is the else.
No sealed hierarchies
Kotlin's sealed tells the compiler the case list is closed, which is what makes when exhaustive. Python has no such declaration, so exhaustiveness is on you — or on mypy.
sealed class Result data class Success(val value: Int) : Result() data class Failure(val reason: String) : Result() fun describe(result: Result): String = when (result) { is Success -> "ok: ${result.value}" is Failure -> "failed: ${result.reason}" // Add a third subclass and this WILL NOT COMPILE // until you handle it here. } fun main() { println(describe(Success(42))) println(describe(Failure("nope"))) }
from dataclasses import dataclass from typing import assert_never @dataclass class Success: value: int @dataclass class Failure: reason: str Result = Success | Failure # a union — the nearest thing to sealed def describe(result: Result) -> str: match result: case Success(value=value): return f"ok: {value}" case Failure(reason=reason): return f"failed: {reason}" case _: assert_never(result) # mypy errors here if a case is missed print(describe(Success(42))) print(describe(Failure("nope")))
A union type alias plus typing.assert_never in the fallback arm is the recognized idiom: add a third member to the union, forget to handle it, and mypy reports that the value reaching assert_never is not Never — the exhaustiveness error Kotlin gives you at compile time, once again relocated to an external checker. Without mypy in CI, this is a runtime assertion and nothing more. Nothing prevents a fourth class from being written anywhere and passed in; "closed" is not a concept the runtime knows.
Iterators & Generators
Sequence becomes a generator
Kotlin's lazy Sequence with yield inside sequence { } has an almost exact twin — except in Python, laziness is the ordinary way to write an iterator.
fun fibonacci() = sequence { var current = 0 var next = 1 while (true) { yield(current) val sum = current + next current = next next = sum } } fun main() { println(fibonacci().take(8).toList()) val squares = (1..1_000_000).asSequence() .map { it * it } .first { it > 50 } println(squares) }
from itertools import islice def fibonacci(): current, next_value = 0, 1 while True: yield current # a statement — no sequence { } builder needed current, next_value = next_value, current + next_value print(list(islice(fibonacci(), 8))) squares = (number * number for number in range(1, 1_000_001)) print(next(value for value in squares if value > 50))
Any function containing yield is a generator — no builder, no wrapper type — and it produces values lazily and infinitely, exactly like sequence { }. The generator expression in the second half is a comprehension with parentheses instead of brackets: same syntax, lazy evaluation, Kotlin's asSequence() for free. itertools is the standard library's lazy toolbox (islice is take, chain is flatten, takewhile is takeWhile).
Operator Overloading
operator fun becomes a dunder
Both languages let you overload operators by defining specially-named methods. Kotlin marks them operator; Python names them with double underscores — "dunder" methods.
data class Vector(val x: Int, val y: Int) { operator fun plus(other: Vector) = Vector(x + other.x, y + other.y) operator fun times(factor: Int) = Vector(x * factor, y * factor) operator fun get(index: Int) = if (index == 0) x else y } fun main() { val vector = Vector(1, 2) + Vector(3, 4) println(vector) println(vector * 2) println(vector[0]) }
from dataclasses import dataclass @dataclass class Vector: x: int y: int def __add__(self, other): return Vector(self.x + other.x, self.y + other.y) def __mul__(self, factor): return Vector(self.x * factor, self.y * factor) def __getitem__(self, index): return self.x if index == 0 else self.y def __len__(self): return 2 vector = Vector(1, 2) + Vector(3, 4) print(vector) print(vector * 2) print(vector[0]) print(len(vector))
The mapping is nearly one-to-one — plus/__add__, times/__mul__, get/__getitem__ — but Python's protocol reaches much further: __len__ makes len() work, __iter__ makes the object loopable, __enter__/__exit__ make it a context manager (with, Kotlin's use), __repr__ is toString(). This is duck typing formalized: implement the dunder and the built-in syntax works on your class, with no interface to declare.
Concurrency & asyncio
suspend becomes async / await
The keywords line up; the machinery does not. Python's coroutines run on one event loop in one thread, and they are not structured.
import kotlinx.coroutines.* suspend fun fetchGreeting(): String { delay(10) return "Hello from a coroutine" } fun main() = runBlocking { val message = fetchGreeting() println(message) }
import asyncio async def fetch_greeting(): await asyncio.sleep(0.01) return "Hello from a coroutine" async def main(): message = await fetch_greeting() print(message) asyncio.run(main()) # this is runBlocking
async def is suspend fun, await is the call, and asyncio.run is the runBlocking bridge from synchronous code. What is absent: Dispatchers (there is one thread, so no Dispatchers.IO and no CPU parallelism), cancellation that propagates cooperatively through a scope by default, and — most importantly — structured concurrency. A bare asyncio.create_task is not owned by anything and can outlive its caller; asyncio.TaskGroup (3.11+) is the modern fix and behaves much like coroutineScope.
awaitAll, gather, and the GIL
Concurrent awaits work as you would hope. Real CPU parallelism is the part that does not.
import kotlinx.coroutines.* suspend fun compute(number: Int): Int { delay(10) return number * number } fun main() = runBlocking { val results = coroutineScope { (1..4).map { number -> async { compute(number) } }.awaitAll() } println(results) }
import asyncio async def compute(number): await asyncio.sleep(0.01) return number * number async def main(): results = await asyncio.gather(*(compute(number) for number in range(1, 5))) print(results) async with asyncio.TaskGroup() as group: # structured, like coroutineScope tasks = [group.create_task(compute(number)) for number in range(1, 5)] print([task.result() for task in tasks]) asyncio.run(main())
asyncio.gather is awaitAll, and TaskGroup restores the coroutineScope guarantee that nothing escapes the block and one failure cancels the siblings. The wall behind all of it is the GIL: only one thread executes Python bytecode at a time, so even threading gives you concurrency without parallelism, and CPU-bound work must go to multiprocessing (separate interpreters, pickled data across the boundary) — where Kotlin would simply use Dispatchers.Default and every core. Python 3.13 ships an experimental free-threaded build that removes the GIL; it is not the default yet.
Errors & Exceptions
try / except / finally
The most familiar corner of the language — same unchecked-exception model as Kotlin, different keywords, plus one clause the JVM never had.
fun parseAge(text: String): Int { val number = text.toIntOrNull() ?: throw IllegalArgumentException("not a number: $text") require(number >= 0) { "age cannot be negative" } return number } fun main() { for (input in listOf("36", "-1", "oops")) { try { println(parseAge(input)) } catch (exception: IllegalArgumentException) { println("error: ${exception.message}") } finally { // always runs } } }
def parse_age(text): number = int(text) # raises ValueError on bad input if number < 0: raise ValueError("age cannot be negative") return number for entry in ["36", "-1", "oops"]: try: age = parse_age(entry) except ValueError as error: print(f"error: {error}") else: print(age) # runs only if NO exception was raised finally: pass # always runs
Neither language has checked exceptions, so nothing forces a caller to handle anything — the discipline is identical, only the spelling changes: throw is raise, catch is except, finally is finally. The extra else clause is genuinely useful and has no Kotlin equivalent: it runs only when the try body raised nothing, letting you keep the guarded call in try as narrow as possible. What Python lacks is Kotlin's runCatching/Result — there is no idiomatic value-returning wrapper, so exceptions really are the mechanism.