PONYλM2Modula-2

Python.CodeCompared.To/Kotlin

An interactive executable cheatsheet comparing Python and Kotlin

Python 3.14 Kotlin 2.4
Output & Running
Hello, World
Kotlin needs an entry point, and that is the entire ceremony — no class, no public static void, no file named after a type. If you have met Java, this is the first sign that Kotlin is a different experience.
print("Hello, World!")
fun main() { println("Hello, World!") }
A Kotlin file may hold top-level functions, top-level properties and several classes, and its name need not match anything inside it. fun main() can take no parameters at all when you do not want the command-line arguments, which Java does not allow. Semicolons are optional and nobody writes them; braces do the job Python's indentation does, but the formatter and every codebase indent anyway.
Printing several values
Kotlin interpolates with a dollar sign and needs no prefix letter on the string — every string literal interpolates, so there is no f to forget.
name = "Ada" age = 36 print(f"{name} is {age}") print(f"{name} will be {age + 1} next year") print(name, "is", age)
fun main() { val name = "Ada" val age = 36 println("$name is $age") println("$name will be ${age + 1} next year") println("$name is $age") }
A bare name needs no braces ("$name") and anything more than a name does ("${age + 1}"), which is the reverse of f-strings, where the braces are always required. There is no equivalent of print's several comma-separated arguments; println takes one value, so several values are joined with interpolation or with +. Formatting specifiers live in "%.2f".format(value) rather than inside the interpolation, so f"{price:.2f}" becomes "%.2f".format(price).
Running it: a compiler where you expected an interpreter
The workflow around the code is the first practical difference, and it shapes how a Kotlin team works day to day.
# python script.py # python -c 'print(1 + 1)' # python <- the REPL # # Nothing is compiled ahead of time; the file IS the program. print("Python: run the file")
// kotlinc script.kt -include-runtime -d app.jar && java -jar app.jar // kotlin script.main.kts <- scripting mode, still compiles first // kotlinc-jvm <- a REPL that exists and is rarely used // // A compile step always happens, even when it is hidden. fun main() { println("Kotlin: compile, then run on the JVM") }
Compilation is not instant — a cold Gradle build of a real project is measured in tens of seconds — so the tight edit-run loop a Python developer relies on is replaced by an IDE that type-checks as you type and by tests you run in bulk. What you get back is that a huge class of mistake never reaches run time at all. Kotlin also compiles to JavaScript and to native binaries, but on Android and on the server it is the JVM, which means JVM startup, JVM memory, and access to every Java library ever published.
Null Safety
Null is part of the type, and the compiler checks it
This is the headline of the page. Python can annotate a return as str | None and then let you call .upper() on it anyway, because nothing reads the annotation. Kotlin refuses to compile the same line.
def find_name(user_id: int) -> str | None: return "Ada" if user_id == 1 else None name = find_name(2) try: print(name.upper()) # AttributeError at run time except AttributeError as error: print("AttributeError:", error)
fun findName(userId: Int): String? = if (userId == 1) "Ada" else null fun main() { val name: String? = findName(2) // name.uppercase() <- will not compile println(name?.uppercase()) }
String and String? are two different types in Kotlin, and there is no way to reach a method on the nullable one without saying what should happen when it is null. That single rule removes the most common exception in the JVM world and the most common AttributeError in the Python one. The cost is that you must now be explicit at every point where absence is possible — which is the same discipline a Python developer applies by hand and by habit, moved into the compiler where it cannot be forgotten.
The four null operators
Kotlin has dedicated punctuation for the three things a Python developer writes out with a conditional expression, plus one for asserting that you know better.
def find_name(user_id: int) -> str | None: return "Ada" if user_id == 1 else None name = find_name(2) print(name.upper() if name is not None else None) print(name if name is not None else "(nobody)") print(len(name) if name else 0)
fun findName(userId: Int): String? = if (userId == 1) "Ada" else null fun main() { val name = findName(2) println(name?.uppercase()) // safe call -> null println(name ?: "(nobody)") // Elvis -> a default println(name?.length ?: 0) // chained println(findName(1)!!) // force, throws if null }
?. short-circuits the rest of the chain to null instead of throwing. ?: — the Elvis operator, so called because it has a quiff — supplies a value when the left side is null, and its right-hand side may be a return or a throw, which is how Kotlin spells an early guard. !! asserts non-null and throws a NullPointerException if wrong; it is a code smell, and finding one in a code review is a conversation. Python has none of these as operators — x?.y has been proposed and rejected several times — so the equivalents are conditional expressions and or, which is not the same thing because or also fires on empty strings and zero.
Smart casts: the check narrows the variable itself
Kotlin's is check does what isinstance does and then does something more: it changes the static type of the variable for the rest of the branch, with no cast and no new name.
def describe(value: object) -> str: if isinstance(value, str): return value.upper() # a type checker narrows; the runtime does not if value is None: return "(none)" return str(value) print(describe("ada")) print(describe(None)) print(describe(7))
fun describe(value: Any?): String { if (value is String) { return value.uppercase() // value IS String from here on } if (value == null) { return "(none)" } return value.toString() } fun main() { println(describe("ada")) println(describe(null)) println(describe(7)) }
The same narrowing happens after a null check, so if (name != null) { name.length } compiles even though name is declared String?. Python's type checkers do exactly this analysis, which is why the pattern feels familiar — but there it is advice from a separate tool and here it is the language. One real limit: a smart cast only applies where the compiler can prove the value cannot change between the check and the use, so a mutable class property does not smart-cast, and you assign it to a local first.
The hole: values arriving from Java
Kotlin's guarantee is airtight within Kotlin and stops at the boundary with Java, which on Android and on the server is a boundary you cross constantly.
# Everything crossing into Python is untyped and may be None. # There is no boundary to be surprised by, because there is # no guarantee anywhere in the first place. import os value = os.environ.get("NO_SUCH_VARIABLE") print(value is None) print("every value has always been able to be None")
fun main() { // System.getenv returns Java's String, which carries NO nullability // information. Kotlin calls this a PLATFORM TYPE and declines to check it. val value: String? = System.getenv("NO_SUCH_VARIABLE") println(value == null) // Written as String (not String?), the same call compiles and throws // at run time if the value is absent. The compiler allowed it. println("the compiler trusted the Java signature") }
A value coming back from an un-annotated Java method has a platform type, which the compiler will let you assign to either String or String? without complaint — so the null check you skipped becomes a NullPointerException at run time after all. The mitigations are real: Java libraries increasingly carry @Nullable/@NotNull annotations that Kotlin honors, and the habit is to declare the nullable type at the boundary and narrow immediately. For a Python reader the useful framing is that this is the one place Kotlin behaves the way Python always does, and it is worth knowing where those places are.
Types & Inference
val and var: reassignment is the thing being controlled
Every Kotlin name is declared with one of two keywords, and the choice is about the name, not about the value it points at.
total = 0 for number in [1, 2, 3]: total += number print(total) LIMIT = 100 # a convention, nothing more LIMIT = 200 print(LIMIT)
fun main() { var total = 0 for (number in listOf(1, 2, 3)) { total += number } println(total) val limit = 100 // val: cannot be reassigned // limit = 200 <- will not compile println(limit) }
A val cannot be reassigned, which is Python's Final annotation made real and made the default choice — Kotlin style is val everywhere and var only where a loop or an accumulator needs it. What val does not mean is immutable contents: val items = mutableListOf(1) still lets you call items.add(2), exactly as a Python Final list would. Since names are declared once rather than simply assigned, a typo produces "unresolved reference" at compile time instead of a new variable holding nothing.
Inference means the type is known, not that it is absent
A Kotlin declaration usually has no type written on it, which makes the code look as loose as Python's. It is not: the compiler worked the type out and will hold you to it forever.
value = 42 value = "now a string" # perfectly legal print(value) def half(number): # no annotation: anything goes return number / 2 print(half(9)) print(half("nine") if False else "and half('nine') would explode")
fun main() { var value = 42 // value = "now a string" <- will not compile: value is Int println(value) val half = { number: Int -> number / 2.0 } println(half(9)) val name = "Ada" // inferred String val price = 19.99 // inferred Double val ready = true // inferred Boolean println("$name $price $ready") }
This is the distinction that makes Kotlin comfortable for a Python developer. You write about as few types as you do in Python, and you get the guarantee anyway. Types are written where inference cannot reach — on function parameters always, on public return types by convention — and everywhere else they are omitted. Note that integer division is integer division: 9 / 2 is 4, not 4.5, so the example writes 2.0. Python 3 made / always produce a float and left // for the truncating form; Kotlin follows Java instead.
Fixed-width integers, and what happens at the edge
Python's integers grow to whatever size the arithmetic needs. Kotlin's are machine words, and this is the sharpest correctness difference in the section.
big = 2 ** 62 print(big * 4) # arbitrary precision, just keeps growing print(7 / 2) print(7 // 2) print(0.1 + 0.2)
fun main() { val big = 1L shl 62 println(big * 4) // Long wraps around silently println(7 / 2) // Int division truncates println(7.0 / 2) println(0.1 + 0.2) }
An Int is 32 bits and a Long is 64, and overflow wraps around silently rather than raising — so a running total that exceeds two billion becomes a negative number with nothing to indicate it happened. BigInteger from the Java library is the arbitrary-precision escape hatch, and it is verbose enough that people avoid it. The division rule is the other trap: 7 / 2 is 3 because both operands are Int, matching Python's // rather than its /. Floating-point behaves identically in both, since both use IEEE 754 doubles.
Any, Unit and Nothing
Kotlin has three types at the edges of its hierarchy, and each one names something Python expresses with a convention instead.
def log(message) -> None: print(message) result = log("hello") print(result) def always_fails(): raise RuntimeError("no return value is possible") print([1, "two", True])
fun log(message: Any): Unit { println(message) } fun alwaysFails(): Nothing = throw RuntimeException("no return value is possible") fun main() { val result: Unit = log("hello") println(result) println(listOf<Any>(1, "two", true)) }
Any is the top type, corresponding to object — but note it is not nullable, so a variable that may be anything including null is Any?. Unit is the type of a function that returns nothing useful and has exactly one value, printed as kotlin.Unit, which is where Python returns None. Nothing is the type with no values at all, and it is the return type of throw and of functions that never come back — which is what lets val name = maybeNull ?: throw … type-check, since a branch of type Nothing fits anywhere.
Strings
String methods, mostly renamed
Almost everything a Python developer does to a string has a same-shaped Kotlin method with a different name. This is muscle memory, not a concept.
sentence = " the quick brown fox " print(sentence.strip()) print(sentence.strip().upper()) print(sentence.strip().split(" ")) print(sentence.strip().replace("quick", "slow")) print("fox" in sentence)
fun main() { val sentence = " the quick brown fox " println(sentence.trim()) println(sentence.trim().uppercase()) println(sentence.trim().split(" ")) println(sentence.trim().replace("quick", "slow")) println("fox" in sentence) }
strip is trim, upper is uppercase, and join reverses into list.joinToString(", "), which reads better than ", ".join(list) once you stop expecting the separator first. The pleasant surprise is in: Kotlin lets a class define contains and then spells it in, so the membership test is written identically in both languages. Strings are immutable in both, and a Kotlin string is a sequence of UTF-16 code units rather than of code points, so a character outside the Basic Multilingual Plane counts as two.
Multi-line strings and raw strings are the same thing
Kotlin has one triple-quoted form that is multi-line and raw at once, and it still interpolates — so there is no combination of prefixes to remember.
name = "Ada" letter = f""" Dear {name}, Regards """ print(letter.strip()) print(r"C:\path\with\backslashes")
fun main() { val name = "Ada" val letter = """ Dear $name, Regards """.trimIndent() println(letter) println("""C:\path\with\backslashes""") }
Because it is raw, backslashes mean nothing special inside it, which makes it the natural home for regular expressions and Windows paths. Because it is still interpolating, $ is significant even there, and a literal dollar sign needs ${'$'} — the one genuinely ugly corner of Kotlin strings. Indentation is not stripped automatically the way a Python developer might hope; .trimIndent() removes the common leading whitespace and is written on essentially every multi-line literal.
Comparing strings: == does what you want
Kotlin's == and === mean the opposite of what their length suggests to someone coming from Java, and they line up neatly with Python's == and is.
first = "hello" second = "".join(["hel", "lo"]) print(first == second) print(first is second) print("Apple" < "banana") print("Apple".lower() < "banana")
fun main() { val first = "hello" val second = buildString { append("hel"); append("lo") } println(first == second) println(first === second) println("Apple" < "banana") println("Apple".lowercase() < "banana") }
== calls equals and is therefore structural, so string comparison does the obvious thing — which is worth stating explicitly because Java's == on strings compares identity and produces the single most famous beginner bug on the JVM. Kotlin fixed it by making the short operator the useful one. === is identity and corresponds to is. Ordering compares UTF-16 code units, so uppercase letters sort before all lowercase ones, exactly as in Python; a locale-aware comparison needs compareTo(other, ignoreCase = true) or a Collator.
Collections
Read-only by default, mutable on request
Kotlin splits every collection into a read-only interface and a mutable one, and the read-only spelling is the shorter one — which is the whole point.
numbers = [1, 2, 3] print(numbers + [4]) growable = [1, 2, 3] growable.append(4) print(growable) person = {"name": "Ada", "age": 36} print(person["name"]) print(sorted({1, 2, 2, 3}))
fun main() { val numbers = listOf(1, 2, 3) // numbers.add(4) <- List has no add println(numbers + 4) val growable = mutableListOf(1, 2, 3) growable.add(4) println(growable) val person = mapOf("name" to "Ada", "age" to 36) println(person["name"]) println(setOf(1, 2, 2, 3).sorted()) }
There are no bracket literals: listOf, mutableListOf, mapOf, setOf are functions, and a map entry is written with the to infix function. List genuinely has no add method, so passing one to a function is a promise that the function will not change it — a guarantee Python offers only through tuple and frozenset. The honest caveat is that List is a read-only view, not an immutable value: hand the same underlying MutableList to two places and one can still change what the other sees. For real immutability, Kotlin teams reach for the kotlinx.collections.immutable library.
The comprehension becomes a chain
Kotlin has no comprehension syntax and does not need one, because the standard library's collection functions read in source order and chain without ceremony.
people = [ {"name": "Ada", "age": 36}, {"name": "Bo", "age": 17}, {"name": "Cy", "age": 44}, ] names = [person["name"].upper() for person in people if person["age"] >= 18] print(names) ages = {person["name"]: person["age"] for person in people} print(ages)
data class Person(val name: String, val age: Int) fun main() { val people = listOf(Person("Ada", 36), Person("Bo", 17), Person("Cy", 44)) val names = people.filter { it.age >= 18 }.map { it.name.uppercase() } println(names) val ages = people.associate { it.name to it.age } println(ages) }
The chain reads left to right in the order the work happens, which the comprehension does not — [f(x) for x in xs if p(x)] puts the last step first. it is the implicit name for a single-parameter lambda and is used constantly. The library is large and worth browsing: associate, groupBy, partition, sumOf, maxByOrNull, zip, flatMap, chunked, windowed all exist and most have no one-line Python equivalent. Each step builds a new list; for a lazy chain over a large collection, insert .asSequence(), which is the generator-expression equivalent.
Grouping and aggregating
The grouping idiom Python spells with defaultdict and a loop is a single library call, and it is one of the places where Kotlin code is markedly shorter than the Python it replaces.
from collections import defaultdict words = ["fig", "apple", "pear", "plum", "banana"] by_length = defaultdict(list) for word in words: by_length[len(word)].append(word) print(dict(by_length)) print(sum(len(word) for word in words)) print(max(words, key=len))
fun main() { val words = listOf("fig", "apple", "pear", "plum", "banana") println(words.groupBy { it.length }) println(words.sumOf { it.length }) println(words.maxByOrNull { it.length }) }
groupBy returns a Map<K, List<V>> with the types worked out for you; sumOf replaces sum(... for ...); and maxByOrNull is max(..., key=...) with the empty case handled — it returns null rather than raising, and the name says so. That naming convention runs through the whole library: a function ending in OrNull hands back null where its plain sibling throws, so the signature tells you which failure mode you are choosing.
zip, flatMap and chunked
Three collection shapes a Python developer writes by hand each have a named library function here, and the third one has no short Python equivalent at all.
names = ["Ada", "Bo", "Cy"] scores = [90, 75, 88] print(list(zip(names, scores))) nested = [[1, 2], [3], [4, 5]] print([value for row in nested for value in row]) numbers = list(range(1, 8)) print([numbers[index:index + 3] for index in range(0, len(numbers), 3)])
fun main() { val names = listOf("Ada", "Bo", "Cy") val scores = listOf(90, 75, 88) println(names.zip(scores)) val nested = listOf(listOf(1, 2), listOf(3), listOf(4, 5)) println(nested.flatten()) println((1..7).chunked(3)) }
zip matches, and produces a list of Pair objects rather than tuples. The nested comprehension for flattening becomes flatten(), or flatMap { } when a transformation happens on the way — the two-loop comprehension is one of the least readable things in Python and the named function is a clear win. chunked and its neighbor windowed (overlapping runs) have no Python counterpart short of an itertools recipe, which is worth knowing about before you write the slicing loop.
Control Flow & when
if is an expression, so there is no ternary
Nearly everything in Kotlin produces a value, including if, when and try. That single decision removes several constructs a Python developer expects to need.
age = 20 label = "adult" if age >= 18 else "minor" print(label) def classify(score): if score >= 90: return "A" elif score >= 80: return "B" return "C" print(classify(85))
fun classify(score: Int): String = if (score >= 90) "A" else if (score >= 80) "B" else "C" fun main() { val age = 20 val label = if (age >= 18) "adult" else "minor" println(label) println(classify(85)) }
Because if is an expression, Kotlin has no conditional expression like Python's a if c else b — the ordinary if already does that job, in the reading order most people find easier. The same property lets a whole function body be a single expression after =, with the return type inferred and no return keyword, which is what classify above does. Note there is no elif: it is else if, and when the chain is about one subject you use when instead.
when against match
Both languages have a multi-way construct that arrived recently and they solve overlapping but different problems. Python's match destructures; Kotlin's when is a conditional expression with type narrowing.
def describe(value): match value: case 0: return "zero" case int() as number if number < 0: return "negative" case str() as text: return f"text of {len(text)}" case [first, *_]: return f"a list starting with {first}" case _: return "something else" print(describe(0)) print(describe(-5)) print(describe("abc")) print(describe([9, 8]))
fun describe(value: Any?): String = when { value == 0 -> "zero" value is Int && value < 0 -> "negative" value is String -> "text of ${value.length}" value is List<*> -> "a list starting with ${value.first()}" else -> "something else" } fun main() { println(describe(0)) println(describe(-5)) println(describe("abc")) println(describe(listOf(9, 8))) }
when comes in two forms: with a subject (when (value) { 1 -> …; in 2..9 -> …; is String -> … }) and without one, where each arm is a full boolean condition. It is an expression, so its arms return values; it smart-casts, so the is String arm can call .length; and arms may list several values separated by commas. What it cannot do is bind pieces of a structure — case [first, *_] has no equivalent, and you call .first() yourself. The next section shows the case where when is stronger than match: exhaustiveness over a sealed type.
Ranges are values, and they are inclusive
Kotlin's range operator produces an ordinary object you can iterate, test membership against, or store — and unlike Python's range, both ends are included.
for number in range(1, 6): print(number, end=" ") print() for number in range(10, 0, -2): print(number, end=" ") print() print(3 in range(1, 6))
fun main() { for (number in 1..5) { print("$number ") } println() for (number in 10 downTo 1 step 2) { print("$number ") } println() println(3 in 1..5) }
That inclusivity is the thing to internalize: 1..5 is five numbers, where range(1, 5) is four. Kotlin also has until (1 until 5) for the Python behavior and ..< as its newer spelling, plus downTo and step as infix functions rather than as extra arguments. Ranges work on characters and on any comparable type, so if (grade in 'A'..'C') reads exactly as it sounds — and in inside a when arm is one of the tidiest things in the language.
Breaking out of a nested loop
Python has no way to break out of more than one loop, so the standard workaround is to wrap the loops in a function and return. Kotlin lets you label a loop and name it.
def find_pair(rows, target): for row_index, row in enumerate(rows): for column_index, value in enumerate(row): if value == target: return row_index, column_index return None print(find_pair([[1, 2], [3, 4]], 4))
fun main() { val rows = listOf(listOf(1, 2), listOf(3, 4)) val target = 4 outer@ for ((rowIndex, row) in rows.withIndex()) { for ((columnIndex, value) in row.withIndex()) { if (value == target) { println("$rowIndex $columnIndex") break@outer } } } }
outer@ labels the loop and break@outer leaves it, with continue@outer available too — which removes the flag variable or the extracted function that Python forces. The same labels work on lambdas: return@forEach returns from the lambda rather than from the enclosing function, and knowing that is what saves you the first time a return inside a forEach does something unexpected. Kotlin also has no for … else, which is the one Python loop feature with no counterpart here.
Functions
Defaults and named arguments
Default and named arguments work almost exactly as they do in Python, which is worth saying because they are absent from Java and their arrival is a large part of why Kotlin feels comfortable here.
def make_tag(name, content="", self_closing=False, indent=0): space = " " * indent if self_closing: return f"{space}<{name} />" return f"{space}<{name}>{content}</{name}>" print(make_tag("br", self_closing=True)) print(make_tag("p", content="hi", indent=2))
fun makeTag( name: String, content: String = "", selfClosing: Boolean = false, indent: Int = 0, ): String { val space = " ".repeat(indent) return if (selfClosing) "$space<$name />" else "$space<$name>$content</$name>" } fun main() { println(makeTag("br", selfClosing = true)) println(makeTag("p", content = "hi", indent = 2)) }
The differences are small. Kotlin evaluates a default expression on every call, so the mutable-default-argument bug is unreachable. Names use camelCase rather than snake_case by convention. And the trailing comma after the last parameter is legal and encouraged, which makes diffs cleaner — Python allows it too. What Kotlin lacks is **kwargs: there is no way to accept arbitrary named arguments, because the compiler has to know every parameter to check the call.
Variadic parameters
Kotlin's variadic is a keyword before the parameter rather than a star, and the spread on the calling side uses the star you were expecting.
def total(*numbers, scale=1): return sum(numbers) * scale print(total(1, 2, 3)) print(total(*[1, 2, 3], scale=2))
fun total(vararg numbers: Int, scale: Int = 1): Int = numbers.sum() * scale fun main() { println(total(1, 2, 3)) println(total(*intArrayOf(1, 2, 3), scale = 2)) }
Inside the function the parameter is an Array, not a List, which is a small annoyance since arrays have a different API — numbers.toList() is the usual first line. Because Kotlin has named arguments, a parameter after the vararg is perfectly usable, which is why scale works here; positionally it would be unreachable. Only one parameter may be vararg, same as Python.
Top-level functions and properties
A Kotlin file may declare functions and properties outside any class, which is the single most visible way it differs from Java and the reason a Kotlin file can look like a Python module.
PI_ISH = 3.14159 def area(radius): return PI_ISH * radius ** 2 print(area(2)) # Both live in the module and are imported by name.
const val PI_ISH = 3.14159 fun area(radius: Double): Double = PI_ISH * radius * radius fun main() { println(area(2.0)) // Both live in the FILE, not in a class. }
There is no Utils class holding static methods and no public static final; a top-level fun is just a function, imported as import com.example.area. const val is a compile-time constant inlined at every use site, while a plain top-level val is initialized once when the file's class is loaded — which is the closest thing to a module-level name that runs at import. Note radius * radius rather than radius ** 2: Kotlin has no exponent operator, only Math.pow.
Infix functions and operator overloading
Operator overloading works in both languages by giving a method a known name. Kotlin adds a second idea Python has no form of: any single-argument method can be called without a dot or parentheses.
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}c" print(Money(150) + Money(99))
data class Money(val cents: Int) { operator fun plus(other: Money) = Money(cents + other.cents) infix fun and(other: Money) = Money(cents + other.cents) override fun toString() = "${cents}c" } fun main() { println(Money(150) + Money(99)) println(Money(150) and Money(99)) }
The operator method names are ordinary words — plus, minus, times, get, contains, compareTo — marked with the operator keyword rather than wrapped in double underscores, and implementing contains is what makes in work on your own type. The infix modifier is the unfamiliar one: it lets a and b be written for a.and(b), which is where to, downTo, step and until come from — they are library functions, not syntax. The rule is to use it only where the result reads as English.
Lambdas & Higher-order Functions
Lambdas can hold statements
Kotlin's lambda is written in braces with the parameters before an arrow, and — unlike Python's — it may contain as many statements as it likes.
numbers = [3, 1, 2] print(sorted(numbers, key=lambda number: -number)) def apply_twice(function, value): return function(function(value)) print(apply_twice(lambda value: value + 1, 5)) print(apply_twice(lambda value: value * 2, 5))
fun applyTwice(function: (Int) -> Int, value: Int): Int = function(function(value)) fun main() { val numbers = listOf(3, 1, 2) println(numbers.sortedByDescending { it }) println(applyTwice({ value -> value + 1 }, 5)) println(applyTwice({ it * 2 }, 5)) }
That removes the recurring Python annoyance of a lambda that grows one line too long and has to be promoted to a def. The last expression in the braces is the value, with no return. A single parameter can be left unnamed and referred to as it, which is why so much Kotlin reads as { it.name }. Function types are written (Int) -> Int, the direct equivalent of Callable[[int], int], and are checked — passing a two-parameter lambda where a one-parameter one is expected does not compile.
The trailing lambda, and why Kotlin looks like it has custom syntax
When the last parameter of a Kotlin function is a lambda, the lambda may be written after the closing parenthesis. This is a small syntactic rule with an outsized effect on how libraries are designed.
import time def timed(label, work): start = time.time() result = work() print(f"{label} finished") return result value = timed("job", lambda: sum(range(1000))) print(value)
fun <T> timed(label: String, work: () -> T): T { val result = work() println("$label finished") return result } fun main() { val value = timed("job") { (0 until 1000).sum() } println(value) }
It is why listOf(1,2).forEach { … }, repeat(3) { … }, runBlocking { … } and every Gradle build script look like language constructs when they are ordinary function calls. If the lambda is the only argument, the parentheses disappear entirely. Python has nothing comparable — a multi-statement callback must be a named def defined above the call, which is why the same design shows up as a decorator or a context manager there instead.
Sequences are generator expressions
A chain of filter and map on a Kotlin list builds a full intermediate list at every step. asSequence() makes the whole chain lazy, which is exactly the difference between a list comprehension and a generator expression.
numbers = range(1, 1_000_000) first_three = [] for value in (number * number for number in numbers if number % 7 == 0): first_three.append(value) if len(first_three) == 3: break print(first_three)
fun main() { val firstThree = (1..1_000_000) .asSequence() .filter { it % 7 == 0 } .map { it * it } .take(3) .toList() println(firstThree) }
Without asSequence() this example would build a million-element filtered list and then a million-element squared list to take three values from it. With it, each element flows through the whole chain one at a time and the work stops after the third — the same evaluation order a Python generator gives you. The rule of thumb matches Python's: for small collections the eager version is faster because it avoids the per-element machinery, and for large ones or early termination the lazy version wins. sequence { yield(…) } is the builder form, and is Kotlin's generator function.
Referring to a function without calling it
A Kotlin function name on its own is not a value, so referring to one without calling it needs a pair of colons.
def shout(text): return text.upper() words = ["fig", "pear"] print(list(map(shout, words))) print(list(map(str.upper, words))) print(list(map(len, words)))
fun shout(text: String): String = text.uppercase() fun main() { val words = listOf("fig", "pear") println(words.map(::shout)) println(words.map(String::uppercase)) println(words.map(String::length)) }
::shout is a reference to a top-level function and String::uppercase is a reference to a member, which becomes a one-argument function taking the receiver — the same shape as Python's str.upper. String::length references a property and is usable anywhere a function is expected, which Python cannot do without operator.attrgetter. There is also instance::method for a reference bound to a particular object, and ::ClassName for a constructor, so names.map(::Person) works.
Data Classes
data class against dataclass
The two features are close enough that the names almost match, and they arrived independently — Kotlin's in 2016, Python's in 3.7.
from dataclasses import dataclass, replace @dataclass class Point: x: int y: int first = Point(1, 2) second = Point(1, 2) print(first) print(first == second) print(replace(first, y=99))
data class Point(val x: Int, val y: Int) fun main() { val first = Point(1, 2) val second = Point(1, 2) println(first) println(first == second) println(first.copy(y = 99)) }
Both generate a readable string form, structural equality and a hash. Kotlin's copy() is dataclasses.replace and is a method rather than a module function, so it needs no import; it takes named arguments for whatever you want changed. Two differences worth holding on to: Kotlin's == calls equals and is therefore structural, with === reserved for identity — the opposite naming from Python, where == is structural and is is identity. And Kotlin generates componentN() functions, which is what makes the destructuring in the next row work.
Destructuring is positional, and that matters
A Kotlin data class can be unpacked into local names, and the loop form reads exactly like Python's .items() loop.
from dataclasses import dataclass @dataclass class Point: x: int y: int point = Point(3, 4) print(point.x, point.y) pairs = {"a": 1, "b": 2} for key, value in pairs.items(): print(key, value)
data class Point(val x: Int, val y: Int) fun main() { val point = Point(3, 4) val (x, y) = point println("$x $y") val pairs = mapOf("a" to 1, "b" to 2) for ((key, value) in pairs) { println("$key $value") } }
The trap is that destructuring is positional, not by name: val (y, x) = point compiles perfectly and silently gives you the wrong values, and reordering the properties of a data class quietly breaks every destructuring of it. Python has the same hazard for tuples and avoids it for dataclasses by having no destructuring at all. The rule Kotlin teams settle on is to destructure two- or three-property types and to use property names beyond that.
Classes & Objects
A class, its constructor, and its properties
The constructor is written in the class header, and a parameter marked val or var there becomes a property in one stroke — no assignment, no self. anywhere.
class Account: def __init__(self, owner, balance=0): self.owner = owner self._balance = balance @property def balance(self): return self._balance def deposit(self, amount): self._balance += amount account = Account("Ada") account.deposit(50) print(account.owner, account.balance)
class Account(val owner: String, private var balanceAmount: Int = 0) { val balance: Int get() = balanceAmount fun deposit(amount: Int) { balanceAmount += amount } } fun main() { val account = Account("Ada") account.deposit(50) println("${account.owner} ${account.balance}") }
There is no self parameter on methods; the receiver is this and is almost always implicit. What Kotlin calls a property is what Python's @property produces: a name that looks like a field and may be backed by a getter, so you can start with a plain val and add a get() later without changing a single caller. That is the same refactoring safety net @property gives Python, obtained without the decorator. Initialization logic that does not fit in the header goes in an init { } block.
Classes are final unless you open them
Two keywords have no Python counterpart and both exist to make inheritance deliberate rather than accidental.
class Shape: def area(self): raise NotImplementedError def describe(self): return f"a shape of area {self.area()}" class Square(Shape): def __init__(self, side): self.side = side def area(self): return float(self.side ** 2) def describe(self): return f"a square: {super().describe()}" print(Square(4).describe())
abstract class Shape { abstract fun area(): Double open fun describe(): String = "a shape of area ${area()}" } class Square(private val side: Double) : Shape() { override fun area(): Double = side * side override fun describe(): String = "a square: ${super.describe()}" } fun main() { println(Square(4.0).describe()) }
A Kotlin class cannot be subclassed unless it is marked open or abstract, and a method cannot be overridden unless it is too — the opposite default from Python and from Java. override is then mandatory on the subclass, so a typo in a method name is a compile error rather than a method that silently never gets called, which is a real and frequent Python bug. Inheriting is written with a colon and a call to the parent constructor, and super is a keyword rather than a function. Single inheritance only; interfaces with default implementations do the mixin job.
Singletons and static members
Kotlin has no static keyword. Instead it has object, which declares a singleton, and companion object, which is a singleton attached to a class.
class Registry: _instance = None entries = [] @classmethod def instance(cls): if cls._instance is None: cls._instance = cls() return cls._instance @staticmethod def version(): return "1.0" class Parser: def __init__(self, text): self.text = text @classmethod def of(cls, text): return cls(text.strip()) Registry.instance().entries.append("first") print(Registry.instance().entries, Registry.version()) print(Parser.of(" hi ").text)
object Registry { val entries = mutableListOf<String>() fun version(): String = "1.0" } class Parser private constructor(val text: String) { companion object { fun of(text: String): Parser = Parser(text.trim()) } } fun main() { Registry.entries.add("first") println("${Registry.entries} ${Registry.version()}") println(Parser.of(" hi ").text) }
object Registry is a class and its only instance at once — the singleton pattern with no double-checked locking to get wrong — and it is initialized lazily on first use. A companion object holds what Python puts behind @staticmethod and @classmethod, and its members are reached through the class name, so Parser.of(...) reads like a static call while actually being a method on a real object that can implement interfaces. The named-constructor pattern above, with a private constructor and a factory in the companion, is the standard Kotlin replacement for Python's several @classmethod alternative constructors.
Interfaces, and delegation without inheritance
Kotlin's interfaces do the job Python spreads across abc.ABC and typing.Protocol, and then it adds a keyword that removes the most tedious code in object-oriented programming.
class Logger: def log(self, message): print(f"[log] {message}") class Service: def __init__(self, logger): self._logger = logger # Every method has to be forwarded by hand. def log(self, message): self._logger.log(message) Service(Logger()).log("started")
interface Logger { fun log(message: String) } class ConsoleLogger : Logger { override fun log(message: String) = println("[log] $message") } class Service(logger: Logger) : Logger by logger fun main() { Service(ConsoleLogger()).log("started") }
by is delegation: class Service(logger: Logger) : Logger by logger declares that Service implements Logger and generates every forwarding method automatically, so composition costs a single line instead of one method per member. Python has no equivalent short of __getattr__ forwarding, which is dynamic, untyped and invisible to tooling. The same keyword also drives delegated properties — by lazy { } is functools.cached_property, and by Delegates.observable { } has no Python counterpart at all.
Extension Functions
Extension functions against monkey-patching
This is the feature a Python developer is most likely to fall in love with. An extension function is declared outside a class and called as if it were a method on it — including on types you do not own, like String and List.
# A built-in cannot be extended at all, so both of these # have to be free functions called the other way round. def shout(text): return text.upper() + "!" def median(numbers): ordered = sorted(numbers) middle = len(ordered) // 2 if len(ordered) % 2 == 1: return float(ordered[middle]) return (ordered[middle - 1] + ordered[middle]) / 2 print(shout("hello")) print(median([5, 1, 3, 2]))
fun String.shout(): String = uppercase() + "!" fun List<Int>.median(): Double { val ordered = sorted() val middle = ordered.size / 2 return if (ordered.size % 2 == 1) ordered[middle].toDouble() else (ordered[middle - 1] + ordered[middle]) / 2.0 } fun main() { println("hello".shout()) println(listOf(5, 1, 3, 2).median()) }
It is what monkey-patching wants to be — and note that Python cannot even attempt this on a built-in, since str.shout = … raises TypeError, so both left-hand functions have to be called the other way round. The function is resolved statically and compiles to an ordinary static method taking the receiver as its first argument, so nothing is modified, nothing global changes, and two libraries can add a shout() to String without colliding. Crucially it is scoped by import: the extension is visible only in files that import it, so its blast radius is one file rather than one process. The consequences of static resolution are worth knowing — an extension cannot be overridden by a subclass, and a real member method of the same name always wins.
Extension properties, and extensions on nullable types
Extensions are not limited to functions, and they are not limited to non-null receivers — which produces something with no Python analogue at all.
def last_word(text): return text.strip().split(" ")[-1] def or_placeholder(text): return text if text is not None else "(none)" print(last_word("the quick brown fox")) missing = None print(or_placeholder(missing))
val String.lastWord: String get() = trim().split(" ").last() fun String?.orPlaceholder(): String = this ?: "(none)" fun main() { println("the quick brown fox".lastWord) val missing: String? = null println(missing.orPlaceholder()) }
An extension property has no storage; it is a getter, so it is the read-only half of @property applied from outside. The striking one is the second: fun String?.orPlaceholder() extends the nullable type, so missing.orPlaceholder() is called on a null receiver with no safe-call operator and no exception — inside the function, this is simply null. That is how toString() on a nullable value works, and how a library can offer value.orEmpty(). There is no way to express "a method that works on None" in Python.
Scope Functions
let, also, and the null-safe chain
Kotlin has five short standard-library functions — let, run, with, apply, also — that exist purely to give an expression a temporary scope. They have no Python equivalent and they ambush you in real code, so they are worth meeting deliberately.
def find_name(user_id): return "Ada" if user_id == 1 else None name = find_name(1) if name is not None: trimmed = name.strip().upper() print(f"found {trimmed}") value = [3, 1, 2] print("about to sort:", value) value.sort() print(value)
fun findName(userId: Int): String? = if (userId == 1) "Ada" else null fun main() { findName(1)?.let { name -> println("found ${name.trim().uppercase()}") } val value = mutableListOf(3, 1, 2) .also { println("about to sort: $it") } .also { it.sort() } println(value) }
let passes the receiver as it and returns whatever the block returns, which makes value?.let { … } the idiom for "do this only if it is not null" — the direct replacement for a Python if x is not None: block, with the advantage that it is smart-cast to the non-null type. also passes it as well but returns the receiver, so it slots into a chain without changing its value, which is what makes it the natural place for logging and assertions. The distinction between the five comes down to two questions: is the receiver it or this, and is the result the block or the receiver.
apply: configuring an object at the point of creation
A block of assignments to a freshly built object is a shape every Python developer writes. apply turns it into a single expression where the object is the implicit receiver.
class Request: def __init__(self): self.url = "" self.method = "GET" self.headers = {} def __repr__(self): return f"{self.method} {self.url} {self.headers}" request = Request() request.url = "https://example.com" request.method = "POST" request.headers["accept"] = "application/json" print(request)
class Request { var url: String = "" var method: String = "GET" val headers: MutableMap<String, String> = mutableMapOf() override fun toString(): String = "$method $url $headers" } fun main() { val request = Request().apply { url = "https://example.com" method = "POST" headers["accept"] = "application/json" } println(request) }
Inside the block, this is the new object, so its properties are addressed by bare name and the repeated request. prefix disappears. The block returns the receiver, so the whole thing is one expression and can be assigned, returned, or passed as an argument — which is why so many Kotlin builders and Android view-configuration blocks look this way. The nearest Python has is a constructor taking every field as a keyword argument, which works well until some of the configuration is conditional.
Sealed Types & Exhaustiveness
Sealed classes: a closed set the compiler can count
A sealed type declares that its direct subtypes are all in this one compilation unit and there will be no others. That closed-world promise is what lets the compiler check a when for completeness.
from dataclasses import dataclass @dataclass class Success: value: str @dataclass class Failure: reason: str def render(result): match result: case Success(value): return f"ok: {value}" case Failure(reason): return f"failed: {reason}" return "unreachable?" print(render(Success("data"))) print(render(Failure("timeout")))
sealed interface Result data class Success(val value: String) : Result data class Failure(val reason: String) : Result fun render(result: Result): String = when (result) { is Success -> "ok: ${result.value}" is Failure -> "failed: ${result.reason}" } fun main() { println(render(Success("data"))) println(render(Failure("timeout"))) }
The when above has no else, and it compiles because the compiler can see that Success and Failure are the only possibilities. Add a third subtype and every such when in the codebase becomes a compile error pointing at the place that has not been updated. This is the thing Python genuinely cannot do: a match over a union is never checked for completeness at run time, and the missing case shows up as a function that silently returns None. It is the strongest argument for a type system that a Python reader will meet on this page.
Modelling failure as a value
The tuple-of-value-and-error shape a Python developer reaches for has a typed counterpart here, and the compiler enforces that you deal with both halves.
def parse_port(text): try: return int(text), None except ValueError: return None, f"not a number: {text}" for text in ["8080", "eighty"]: port, error = parse_port(text) print(f"port {port}" if error is None else error)
sealed interface Parsed data class Port(val number: Int) : Parsed data class Invalid(val text: String) : Parsed fun parsePort(text: String): Parsed = text.toIntOrNull()?.let { Port(it) } ?: Invalid(text) fun main() { for (text in listOf("8080", "eighty")) { val message = when (val parsed = parsePort(text)) { is Port -> "port ${parsed.number}" is Invalid -> "not a number: ${parsed.text}" } println(message) } }
In the Python version nothing stops a caller from ignoring the error and using port anyway, and None flows on to fail somewhere else. In the Kotlin version there is no way to reach .number without first establishing that the value is a Port. Note when (val parsed = …), which binds the subject to a name for use in the arms — a small convenience with no Python equivalent. Kotlin also ships a built-in Result<T> and a runCatching { } helper, which is the quicker route when the failure is an exception you merely want to capture.
Coroutines
suspend against async def
Both languages mark a function that can pause. The difference is at the call site: Python requires await on every call to one, and Kotlin requires nothing at all.
import asyncio async def fetch(name): await asyncio.sleep(0.01) return f"data for {name}" async def main(): print(await fetch("first")) asyncio.run(main())
import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking suspend fun fetch(name: String): String { delay(10) return "data for $name" } fun main() = runBlocking { println(fetch("first")) }
A suspend function is called exactly like an ordinary one — no keyword, no ceremony — and the compiler enforces only that the caller is itself suspend or is inside a coroutine builder. That removes the visual noise of await from every line and makes refactoring a plain function into a suspending one a one-word change rather than a cascade through every caller. It also means you cannot tell by looking at a call whether it suspends, which some people count as a loss. runBlocking is asyncio.run: the bridge from ordinary code into coroutine code, used at the entry point and in tests and essentially nowhere else.
Running work concurrently
async { } starts a coroutine and hands back a Deferred, which is a future you await(). Structurally this is asyncio.gather with the pieces named differently.
import asyncio async def fetch(name, delay_seconds): await asyncio.sleep(delay_seconds) return f"{name} done" async def main(): results = await asyncio.gather( fetch("first", 0.03), fetch("second", 0.01), ) print(results) asyncio.run(main())
import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking suspend fun fetch(name: String, delayMillis: Long): String { delay(delayMillis) return "$name done" } fun main() = runBlocking { val results = coroutineScope { listOf( async { fetch("first", 30) }, async { fetch("second", 10) }, ).awaitAll() } println(results) }
The real difference is underneath. Python's coroutines run on one thread, so gather gives concurrency and never parallelism, and CPU-bound work in a coroutine blocks the whole loop. Kotlin's run on a thread pool, so two coroutines can genuinely execute at the same instant on different cores — there is no GIL. That makes Dispatchers.Default a real option for CPU work where Python needs multiprocessing, and it also means shared mutable state between coroutines needs the same care as between threads.
Structured concurrency: children are cancelled for you
This is the idea Kotlin is proudest of, and it is genuinely absent from asyncio until you reach for a task group. Every coroutine belongs to a scope, and the scope does not finish until its children do.
import asyncio async def worker(name): try: await asyncio.sleep(10) except asyncio.CancelledError: print(f"{name} cancelled") raise async def main(): task = asyncio.create_task(worker("background")) await asyncio.sleep(0.01) task.cancel() try: await task except asyncio.CancelledError: print("main saw the cancellation") asyncio.run(main())
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking fun main() = runBlocking { try { coroutineScope { launch { try { delay(10_000) } catch (cancelled: CancellationException) { println("background cancelled") throw cancelled } } delay(10) throw IllegalStateException("something went wrong") } } catch (error: IllegalStateException) { println("scope saw: ${error.message}") } }
A coroutineScope waits for every coroutine launched inside it, and if any of them fails — or if the scope body itself throws — the rest are cancelled automatically. There is no way to start a coroutine that outlives its scope by accident, which is exactly the create_task hazard where a forgotten reference produces a task that runs unobserved and swallows its own exception. Python's asyncio.TaskGroup, added in 3.11, is the same idea and is now the recommended way to write asyncio code; a reader who has adopted it will find Kotlin's version immediately familiar.
Flow against an async generator
A Flow is a stream of values produced over time, and it is the direct counterpart of Python's async generator — with the operator library from the collections section attached to it.
import asyncio async def ticks(count): for number in range(count): await asyncio.sleep(0.01) yield number async def main(): async for value in ticks(3): print(value * 10) asyncio.run(main())
import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking fun ticks(count: Int) = flow { for (number in 0 until count) { delay(10) emit(number) } } fun main() = runBlocking { ticks(3).map { it * 10 }.collect { println(it) } }
Both are cold: nothing runs until something consumes it, so ticks(3) alone does no work and collect is what starts it — the same relationship as an async generator and the async for that drives it. What Kotlin adds is the operators: map, filter, debounce, combine, flatMapLatest and dozens more, where Python would reach for a separate library. StateFlow and SharedFlow are the hot variants, and they are what an Android view model exposes to its screen.
Error Handling
try/catch, and no checked exceptions
The structure maps directly, and — unlike Java, which is the comparison a Kotlin reader will hear made — nothing has to be declared or handled.
def read_port(text): try: return int(text) except ValueError as error: print("failed:", error) return 0 finally: print("done") print(read_port("8080")) print(read_port("eighty"))
fun readPort(text: String): Int = try { text.toInt() } catch (error: NumberFormatException) { println("failed: ${error.message}") 0 } finally { println("done") } fun main() { println(readPort("8080")) println(readPort("eighty")) }
Kotlin has no checked exceptions: no throws clause, no compiler demand that you catch anything, which is exactly Python's model and a deliberate departure from Java's. try is an expression, so it produces a value, which is why the function above is a single expression with no return; the last expression of the try or of the catch is the result, and finally does not contribute one. There is no else clause as Python has, and no exception chaining syntax — you pass the cause to the constructor instead.
require, check, and the assertion that stays in production
Kotlin's standard library supplies three guard functions that a Python developer writes out as if … raise every time.
def withdraw(balance, amount): if amount <= 0: raise ValueError(f"amount must be positive, got {amount}") if amount > balance: raise RuntimeError("insufficient funds") return balance - amount print(withdraw(100, 30)) try: withdraw(100, -5) except ValueError as error: print("ValueError:", error)
fun withdraw(balance: Int, amount: Int): Int { require(amount > 0) { "amount must be positive, got $amount" } check(amount <= balance) { "insufficient funds" } return balance - amount } fun main() { println(withdraw(100, 30)) try { withdraw(100, -5) } catch (error: IllegalArgumentException) { println("IllegalArgumentException: ${error.message}") } }
require throws IllegalArgumentException and states "the caller was wrong"; check throws IllegalStateException and states "this object is not in a fit state"; requireNotNull and checkNotNull do the same and return the narrowed non-null value. The message is a lambda, so building the string costs nothing when the condition holds. Unlike Python's assert these are ordinary code and are never stripped out, so they are safe for validating real input — which assert is not, since python -O removes it.
runCatching turns an exception into a value
The standard library ships a Result type and a helper that runs a block and captures whatever it throws, so a failure becomes an ordinary value you can chain over.
def parse(text): try: return int(text), None except ValueError as error: return None, error value, error = parse("eighty") print(value if error is None else f"failed: {error}")
fun main() { val parsed = runCatching { "eighty".toInt() } println(parsed.getOrElse { "failed: ${it.message}" }) val doubled = runCatching { "21".toInt() } .map { it * 2 } .getOrDefault(0) println(doubled) }
runCatching is the bridge from exception-style code into value-style code, and Result supports map, recover, getOrNull, getOrElse and onFailure, so a pipeline of fallible steps reads as a chain instead of as nested try blocks. Python's nearest equivalent is the tuple-of-value-and-error convention, which nothing enforces. The caution worth passing on is that runCatching catches Throwable, including cancellation inside a coroutine, so coroutine code re-throws CancellationException rather than swallowing it.
Gradle, Packages & Tooling
Packages and imports
A Kotlin import looks like Python's and does something quite different: it is a compile-time alias with no runtime effect at all.
# billing/invoice.py # class Invoice: ... # # main.py # from billing.invoice import Invoice # import billing.invoice as invoicing # # The import RUNS the file once and binds a module object. print("Python: import executes a module")
// src/main/kotlin/billing/Invoice.kt // package billing // class Invoice // // Main.kt // import billing.Invoice // import billing.Invoice as Bill // // The import binds a NAME. Nothing is executed. fun main() { println("Kotlin: import is a compile-time alias") }
Nothing runs at import time, so there are no import-time side effects, no circular-import failures, and no if __name__ == "__main__" guard — a file's top level cannot contain statements in the first place. There is no module object to hold a reference to, so import billing.invoice as invoicing becomes an alias on an individual name (import billing.Invoice as Bill). Package names should mirror directories by convention but are not required to, and Kotlin imports a handful of packages (kotlin.*, kotlin.collections.*) into every file automatically, which is why listOf needs no import.
Gradle against pip and a virtualenv
Gradle is the part of the Kotlin experience a Python developer complains about, and being honest about it up front is more useful than pretending otherwise.
# python -m venv .venv && source .venv/bin/activate # pip install requests # pip freeze > requirements.txt # python main.py # # The environment is a directory you ACTIVATE. print("Python: an interpreter, an activated environment, and pins")
// build.gradle.kts // plugins { kotlin("jvm") version "2.4.0" } // dependencies { // implementation("com.squareup.okhttp3:okhttp:4.12.0") // } // // ./gradlew run <- resolves, compiles, and runs fun main() { println("Kotlin: one build file, a daemon, and a cache") }
The build file is itself written in Kotlin, which is why it reads as a sequence of trailing-lambda blocks — plugins { } and dependencies { } are function calls, not configuration syntax. Dependencies are declared per project and cached globally, so there is no environment to activate and no chance of running against the wrong one. What you pay is time and complexity: a cold build downloads a great deal and takes minutes, the Gradle daemon holds memory between builds to make later ones fast, and the failure modes are considerably harder to reason about than a pip install that went wrong. The wrapper script ./gradlew pins the Gradle version itself, which is one thing it does better than pip.
Tests
Tests live in a parallel source tree rather than beside the code, and the assertion is a function call rather than a keyword.
# test_billing.py, run with: pytest def add(left, right): return left + right def test_add(): assert add(2, 3) == 5 test_add() print(add(2, 3))
// src/test/kotlin/BillingTest.kt, run with: ./gradlew test // import kotlin.test.Test // import kotlin.test.assertEquals // // class BillingTest { // @Test fun `adds two numbers`() { // assertEquals(5, add(2, 3)) // } // } fun add(left: Int, right: Int) = left + right fun main() { println(add(2, 3)) }
There is no assert-rewriting magic like pytest's, so failure messages come from the assertion function you chose — assertEquals, assertTrue, assertFailsWith — and the argument order is expected-then-actual, which is the reverse of what most people guess. The delightful part is the backtick-quoted function name: any string may be a test method name, so tests read as sentences. Kotest is the popular alternative with a more expressive style, and MockK is the mocking library, filling the role unittest.mock plays.