Output & Running
Hello, World — and the missing top level
This is the first thing that stops a Python reader dead, so it comes first. Python runs a file top to bottom; a statement at column zero is the program. Java has no top level at all. Every statement must live inside a method, every method inside a class, and the one method the launcher will call must be spelled exactly
public static void main(String[] args).print("Hello, World!")class Main {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}Read the ceremony left to right and none of it is arbitrary.
public means the launcher, which is outside your class, is allowed to call it. static means it can be called without anyone constructing a Main first — a chicken-and-egg problem Python never has, since there is no object at module level either. void means it returns nothing (the exit status comes from System.exit, not from a return value). String[] args is Python's sys.argv[1:], minus the program name. The class name and the file name must match, so this file is Main.java.Printing several values
Python's
print is variadic and inserts a space between arguments for you. Java's println takes exactly one argument, so the spaces are yours to supply — and the way you supply them is +, which on a String means concatenation rather than addition.name = "Alice"
age = 30
print(name, age)
print("Name:", name, "Age:", age)class Main {
public static void main(String[] args) {
String name = "Alice";
int age = 30;
System.out.println(name + " " + age);
System.out.println("Name: " + name + " Age: " + age);
}
}The mixed-type concatenation works because Java converts the other operand to a
String automatically when either side of + is one. That convenience is also a trap: "total: " + 1 + 2 yields total: 12, because + is left-associative and the first + has already turned the expression into a string. Python raises a TypeError for "total: " + 1 and so never lets that mistake compile in the first place.f-strings vs. format specifiers
Java has no f-strings. The nearest equivalent is
printf, which is the same C-derived format-specifier language Python's older % operator used — the values are trailing arguments rather than expressions embedded in the literal.name = "Ada"
score = 91.5
print(f"{name} scored {score:.1f}")
print(f"[{name:>8}]")
print(f"[{42:04d}]")class Main {
public static void main(String[] args) {
String name = "Ada";
double score = 91.5;
System.out.printf("%s scored %.1f%n", name, score);
System.out.printf("[%8s]%n", name);
System.out.printf("[%04d]%n", 42);
}
}Two differences worth internalizing. First,
%n rather than \n: it emits the platform line separator, and printf does not append one on its own the way println does. Second, an f-string is checked when the module is compiled — a missing name is a NameError you find immediately — whereas a printf format string is a plain string checked at run time, so %d against a String throws IllegalFormatConversionException only when that line executes. Java also offers "%s scored %.1f".formatted(name, score), which returns the string instead of printing it.Triple quotes vs. text blocks
Both languages have a triple-quoted literal, and they differ on exactly one point: what happens to the indentation you wrote to keep the source tidy. Python keeps every leading space, which is why real Python code reaches for
textwrap.dedent. A Java text block strips the common indentation for you.message = """Dear Ada,
Thank you for the note.
"""
print(message)class Main {
public static void main(String[] args) {
String message = """
Dear Ada,
Thank you for the note.
""";
System.out.println(message);
}
}Java computes the minimum indentation across all lines including the closing delimiter and removes that much from each. Moving the closing
""" left therefore changes the content of the string — the delimiter position is part of the literal's meaning, which surprises everyone once. The opening """ must be followed by a newline, so a text block always starts on the line below.There is a build step now
This is where the workflow you are used to changes shape. A Python edit is live the next time you run the file. A Java edit is not a program until
javac has accepted it, and javac refuses far more programs than the Python parser does — every type mismatch on this page is a build failure rather than a run-time surprise.# Python: the interpreter reads the source and runs it.
$ python program.py
Hello, World!
# Nothing is produced on disk except a __pycache__ of bytecode
# that you never think about and never ship.# Java: the compiler produces class files, then the launcher runs one.
$ javac Main.java # -> Main.class (and one .class per other class in the file)
$ java Main # runs Main.main
Hello, World!
# A single-file program can skip the explicit compile step:
$ java Main.java # compiles in memory, runs the FIRST class in the fileThe pay-off for the wait is that a whole category of Python bug — the misspelled attribute on the error path that nobody exercised — cannot reach production. The cost is the wait itself, and the loss of the read-eval-print habit. Java's
jshell exists and is genuinely useful for a quick experiment, but no team develops in it the way a Python team lives in a REPL or a notebook. Note the last form: java Main.java runs a single source file directly, but it executes the first class in the file, which is why every example on this page puts Main first.Types & Declarations
Type hints check nothing; Java types are load-bearing
If you take one row from this page, take this one — it explains why every other row looks the way it does. A Python annotation is metadata stored on the function object. The interpreter never reads it, so a wrong annotation changes nothing about how the program behaves; only a separate tool you chose to run, such as mypy or pyright, will ever complain. A Java type is an instruction to the compiler about what code it is permitted to generate, and the wrong one means there is no program at all.
def total(prices: list[float]) -> float:
return sum(prices)
# The annotation is a comment with good intentions. Nothing enforces it,
# so this runs and prints 6 — a list of ints, returning an int.
print(total([1, 2, 3]))
# And this runs too, right up until sum() fails at run time.
try:
print(total("not a list at all"))
except TypeError as problem:
print("TypeError:", problem)import java.util.List;
class Main {
static double total(List<Double> prices) {
double sum = 0;
for (double price : prices) sum += price;
return sum;
}
public static void main(String[] args) {
System.out.println(total(List.of(1.0, 2.0, 3.0)));
// total(List.of(1, 2, 3)) <- List<Integer>, will not compile
// total("not a list") <- String, will not compile
// Neither line can reach run time, so there is nothing to catch.
}
}Notice what moves as a result. In Python the two commented-out calls would be genuine run-time events — one silently produces an
int where a float was promised, and the other raises where the annotation said it would not. In Java they are text the compiler rejects, so the entire class of test that exists to check "what happens when the wrong type gets in here" simply has no subject. That is the trade the whole language makes: the errors you spend the most time on move from the log file to the build output, and in exchange you write the types down.Declaring a variable, and var
A Python name is a label you can move to any object at any time. A Java variable is a typed slot: the type is fixed when the variable is declared and never changes, and the compiler checks every assignment against it.
var, added in Java 10, lets the compiler work the type out from the right-hand side — it removes the typing, not the type.count = 3
label = "items"
average = 1.5
# Rebinding to another type is ordinary Python.
count = "three"
names = ["Ada", "Grace"] # no declaration, and no type to infer
print(count, label, average, names)import java.util.List;
class Main {
public static void main(String[] args) {
int count = 3;
String label = "items";
double average = 1.5;
// count = "three"; <- will not compile; count is an int forever
// The var keyword infers the type from the initializer. It is still
// a fixed type, just one you did not have to spell out.
var names = List.of("Ada", "Grace");
System.out.println(count + " " + label + " " + average + " " + names);
}
}The distinction matters most when reading unfamiliar code. In Python, knowing what
result holds means tracing every assignment that could have reached this line. In Java the declaration is the answer, and var is precisely the case where the declaration stops being the answer — which is why house styles usually allow it where the right-hand side names the type anyway (var reader = new BufferedReader(...)) and discourage it where it does not (var value = compute()).Primitives are not objects
Python has exactly one kind of value: the object. Java has two. The eight primitives —
int, long, double, boolean, char, byte, short, float — are raw machine values with no identity, no methods and no null. Everything else is an object reference.number = 42
# Everything in Python is an object, integers included, so 42 has methods.
print(number.bit_count())
print(type(number).__name__)
values = [1, 2, 3] # a list of int objects
print(values)import java.util.List;
class Main {
public static void main(String[] args) {
int number = 42;
// number.bitCount() <- no. An int has no methods; it is 32 raw bits.
System.out.println(Integer.bitCount(number)); // a static helper instead
// The boxed wrapper class IS an object, and does have methods.
Integer boxed = number; // autoboxing
System.out.println(boxed.getClass().getSimpleName());
// Collections can only hold objects, so this is a List<Integer>.
List<Integer> values = List.of(1, 2, 3);
System.out.println(values);
}
}Each primitive has a matching wrapper class (
int/Integer, double/Double), and the compiler converts between them silently, which is called autoboxing. That silence has a price a Python programmer will not expect: a List<Integer> of a million values is a million separate heap objects, so the loop that would be a flat array in NumPy is a pointer chase in Java too — the reason libraries offer IntStream and int[] alongside the generic versions. Boxing also means a boxed value can be null, so unboxing one throws NullPointerException where the primitive could not.null vs. None
These look like the same idea and are not quite.
None is a singleton object with a type; you can pass it around, put it in a set, and ask type(None) for an answer. null is the absence of a reference — it has no class, so every method call on it fails the same way.value = None
# None is a real object of type NoneType, and asking it for a length
# raises an ordinary exception you can catch by name.
print(type(value))
try:
print(len(value))
except TypeError as problem:
print("TypeError:", problem)class Main {
public static void main(String[] args) {
String value = null;
// null has no type and no class; it is the absence of a reference.
// value.getClass() would throw, so there is nothing to print here.
try {
System.out.println(value.length());
} catch (NullPointerException problem) {
System.out.println("NullPointerException: " + problem.getMessage());
}
}
}Java's error message is the better of the two, and it is recent. Since Java 14, helpful NullPointerExceptions name the exact expression that was null — "Cannot invoke String.length() because is null" — where the old message was a bare line number that told you a chain of five calls contained a null somewhere. Note also that a primitive
int cannot be null at all, so the Python habit of using None as "no value yet" has no equivalent for numbers unless you box them or reach for Optional, which the Error Handling section covers.final vs. the SHOUTING convention
Python signals "do not rebind this" with capitals and trusts the reader. Java has a keyword, and the compiler is the one enforcing it.
MAXIMUM_RETRIES = 3
# The capitals are a message to humans. Nothing stops a rebind.
MAXIMUM_RETRIES = 4
print(MAXIMUM_RETRIES)class Main {
static final int MAXIMUM_RETRIES = 3;
public static void main(String[] args) {
// MAXIMUM_RETRIES = 4; <- will not compile: cannot assign a final
System.out.println(MAXIMUM_RETRIES);
}
}final freezes the binding, not the object — exactly the distinction Python programmers already know from tuples containing lists. A final List<String> cannot be pointed at a different list, but you can still add to the one it holds; for that you want List.copyOf(...), whose result throws on modification. The convention of naming constants in capitals is shared by both languages; only in Java is it backed by anything.Converting between types
Python converts by calling the target type:
int(text), str(number), float(text). Java has no callable types, so each conversion is a named static method on the wrapper class, and a cast in parentheses is reserved for conversions between types the compiler already knows are related.text = "42"
number = int(text)
back = str(number)
real = float(text)
print(number + 1, back + "!", real / 2)
# Truncation is a separate function from the constructor.
print(int(7.9), round(7.9))class Main {
public static void main(String[] args) {
String text = "42";
int number = Integer.parseInt(text);
String back = String.valueOf(number);
double real = Double.parseDouble(text);
System.out.println((number + 1) + " " + back + "!" + " " + (real / 2));
// A cast truncates; Math.round rounds.
System.out.println((int) 7.9 + " " + Math.round(7.9));
}
}The two mechanisms are not interchangeable, and confusing them is a common early mistake:
(int) "42" does not compile, because a String is not a number in disguise — you must parse it. Conversely (int) 7.9 is a cast that discards the fraction, which is Python's int(7.9), while Math.round is Python's round. Bad input behaves comparably in both: int("abc") raises ValueError, Integer.parseInt("abc") throws NumberFormatException.Numbers
Python integers grow; Java integers wrap
This is the shortest row on the page and the one most likely to cost you a night. A Python
int has no upper bound — it allocates more digits and carries on. A Java int is exactly 32 bits and a long is exactly 64, and when a sum leaves that range the high bits are discarded and the value silently becomes negative. No exception, no warning, no log line.biggest_32_bit = 2 ** 31 - 1
print(biggest_32_bit + 1)
biggest_64_bit = 2 ** 63 - 1
print(biggest_64_bit + 1)
print(2 ** 100)import java.math.BigInteger;
class Main {
public static void main(String[] args) {
int biggest32 = Integer.MAX_VALUE;
System.out.println(biggest32 + 1);
long biggest64 = Long.MAX_VALUE;
System.out.println(biggest64 + 1);
System.out.println(BigInteger.TWO.pow(100));
}
}Compare the two outputs line by line: where Python prints the next number up, Java prints the most negative value of the type. The defence is to know your ranges — an
int stops just above two billion, which a row count, a millisecond timestamp, or a byte total will pass without ceremony — and to reach for long early. When you truly cannot bound the value, BigInteger gives you Python's behaviour explicitly, at roughly an order of magnitude in speed. If you would rather fail loudly than wrap, Math.addExact and its siblings throw ArithmeticException on overflow.Division, and which slash you get
Python 3 made
/ always produce a float and gave floor division its own operator. Java kept the C rule: the meaning of / depends on the operands, and two integers produce an integer.print(7 / 2) # true division, always a float
print(7 / 2.0) # same answer; the operator does not change
print(7 // 2) # floor division, when you want the whole part
print(-7 // 2) # floors toward negative infinity
print(7 % 2, -7 % 2)class Main {
public static void main(String[] args) {
System.out.println(7 / 2); // int / int -> int, truncated
System.out.println(7 / 2.0); // one double operand -> double
System.out.println(7 / 2); // there is no separate floor operator
System.out.println(-7 / 2); // truncates toward zero
System.out.println(7 % 2 + " " + (-7 % 2));
}
}Two traps live here, and the second is subtler. First,
7 / 2 is 3 in Java, so an average computed from two int counters is quietly wrong unless one side is a double. Second, the rounding directions differ: Python's // floors toward negative infinity, giving -4, while Java truncates toward zero, giving -3. That also flips the sign of the remainder — Python's -7 % 2 is 1, Java's is -1 — so a hash bucket computed with % can come out negative in Java and index out of bounds. Use Math.floorMod for Python's answer.Floating point and exact decimals
Both languages use IEEE 754 doubles, so the famous result is identical — this row exists to show that the escape hatch is identical too, and to warn about the one place they diverge.
from decimal import Decimal
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
print(Decimal("0.1") + Decimal("0.2"))import java.math.BigDecimal;
class Main {
public static void main(String[] args) {
System.out.println(0.1 + 0.2);
System.out.println(0.1 + 0.2 == 0.3);
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
}
}The lesson carries over unchanged: never compare currency with
==, and use the exact decimal type when the answer has to match an accountant. The divergence is in the constructor. Decimal(0.1) in Python and new BigDecimal(0.1) in Java both capture the binary approximation with all its garbage digits; the string forms, Decimal("0.1") and new BigDecimal("0.1"), are the ones you want. Java additionally has no operators for BigDecimal, so arithmetic is add, subtract, multiply and a divide that demands you name a rounding mode.The math module vs. the Math class
Python splits this work between builtins (
abs, max, min, round) and the math module. Java puts all of it on one class, java.lang.Math, which needs no import because everything in java.lang is imported implicitly.import math
print(math.sqrt(16))
print(math.floor(3.7), math.ceil(3.2))
print(abs(-5), max(3, 9), min(3, 9))
print(round(math.pi, 4))class Main {
public static void main(String[] args) {
System.out.println(Math.sqrt(16));
System.out.println(Math.floor(3.7) + " " + Math.ceil(3.2));
System.out.println(Math.abs(-5) + " " + Math.max(3, 9) + " " + Math.min(3, 9));
System.out.printf("%.4f%n", Math.PI);
}
}Two habits to unlearn.
Math.max and Math.min take exactly two arguments, so the Python idiom max(a, b, c) becomes a nested call or IntStream.of(a, b, c).max(). Watch the second line of output, too: Python's math.floor and math.ceil return integers, while Java's return double, so they print 3.0 4.0 and need a cast to (int) if an integer is what you wanted. And round is not a formatting function in Java — Math.round returns a whole number, so rounding to four decimal places for display is printf's job, as here, or BigDecimal.setScale when the rounded value itself is what you need.Strings
== compares contents in Python, identity in Java
This is the single most expensive habit to carry from Python into Java, so read the two columns as a translation table rather than a contrast: Python's
== is Java's .equals, and Python's is is Java's ==. The operators have swapped jobs.first = "hello"
second = "".join(["hel", "lo"])
print(first == second) # contents
print(first is second) # identityclass Main {
public static void main(String[] args) {
String first = "hello";
String second = String.join("", "hel", "lo");
System.out.println(first.equals(second)); // contents
System.out.println(first == second); // identity
}
}The reason it costs so much is that the wrong version usually works. Java interns string literals, so
"hello" == "hello" really is true, and code comparing constants with == passes every test until the day one of the strings arrives from a file, a socket or a database — at which point it is a different object and the comparison silently turns false. The strings had to be built at run time here precisely to make the difference visible. The rule is unconditional: use .equals for every object comparison, and reserve == for primitives and for the rare deliberate identity check. Objects.equals(a, b) does the same thing while tolerating a null on either side.Building a string in a loop
Strings are immutable in both languages, so
+= in a loop allocates a fresh string on every pass in both. Python's answer is a cultural one — everybody knows to use "".join. Java's answer is a class built for the job, and it is used far more widely than join because it can interleave other work between appends.parts = ["a", "b", "c"]
joined = ""
for part in parts:
joined += part
print(joined)
print("".join(parts))class Main {
public static void main(String[] args) {
String[] parts = {"a", "b", "c"};
StringBuilder joined = new StringBuilder();
for (String part : parts) {
joined.append(part);
}
System.out.println(joined);
System.out.println(String.join("", parts));
}
}Java's compiler does rewrite a simple
a + b + c in one expression into efficient code, but it cannot rewrite an accumulation across loop iterations — that really is quadratic, exactly as in Python. StringBuilder is the ordinary tool, not an optimization of last resort, and it is worth knowing it also has insert, reverse and deleteCharAt, which have no direct string equivalents. Its thread-safe sibling StringBuffer is a legacy class you will meet in old code and should not reach for.The everyday string methods
Almost everything you know transfers; only the names change. The one genuine surprise is that Java has no
in operator for strings, so the containment test is a method call like every other.text = " Hello, World "
print(text.strip())
print(text.strip().upper())
print(text.strip().lower())
print(text.replace("World", "Java").strip())
print("World" in text)
print(text.strip().startswith("Hello"))
print(len(text.strip()))class Main {
public static void main(String[] args) {
String text = " Hello, World ";
System.out.println(text.strip());
System.out.println(text.strip().toUpperCase());
System.out.println(text.strip().toLowerCase());
System.out.println(text.replace("World", "Java").strip());
System.out.println(text.contains("World"));
System.out.println(text.strip().startsWith("Hello"));
System.out.println(text.strip().length());
}
}Java's naming is camel case throughout (
toUpperCase, startsWith, indexOf), and length is a method on a String — length() — but a bare field on an array, array.length, with no parentheses. That inconsistency is a fossil of the primitive/object split and trips everyone once. strip() is the Unicode-aware modern version; the older trim() only removes characters below U+0020 and is best avoided in new code.Splitting and joining
The shapes match, with one detail that bites:
split in Java takes a regular expression, not a literal separator. Splitting on a comma is fine because a comma means itself, but splitting on "." or "|" silently matches every character.line = "Ada,Grace,Alan"
names = line.split(",")
print(names)
print(len(names))
print(" | ".join(names))import java.util.Arrays;
class Main {
public static void main(String[] args) {
String line = "Ada,Grace,Alan";
String[] names = line.split(",");
System.out.println(Arrays.toString(names));
System.out.println(names.length);
System.out.println(String.join(" | ", names));
}
}When the separator is punctuation, wrap it in
Pattern.quote(".") or escape it, or you will get an array of empty strings and no error. Two more asymmetries: join is a static method taking the separator first (String.join(" | ", names)), the mirror image of Python's " | ".join(names); and printing a Java array directly gives you a useless [Ljava.lang.String;@1b6d, which is why Arrays.toString appears here. Java's split also drops trailing empty strings by default, where Python's keeps them.Slicing vs. substring
Python's slice syntax is one of the things you will miss most. Java has
substring, which covers the first two forms directly and forces you to do the arithmetic yourself for the rest — there are no negative indices and no step.text = "Hello, World"
print(text[0:5])
print(text[7:])
print(text[-5:])
print(text[::-1])class Main {
public static void main(String[] args) {
String text = "Hello, World";
System.out.println(text.substring(0, 5));
System.out.println(text.substring(7));
System.out.println(text.substring(text.length() - 5));
System.out.println(new StringBuilder(text).reverse());
}
}Both are half-open, so
substring(0, 5) and text[0:5] agree exactly. The failure modes differ sharply, though: an out-of-range Python slice quietly clamps, so text[0:999] returns the whole string, while substring(0, 999) throws StringIndexOutOfBoundsException. Code ported directly from Python tends to discover this on the short input nobody tested.char is a number
Python has no character type — indexing a string gives you a one-character string, and you convert to a number explicitly with
ord. Java's char is a 16-bit unsigned primitive that is a number, and arithmetic on it happens without asking.text = "hello"
first = text[0]
print(first)
print(ord(first) + 1)
print(chr(ord(first) + 1))
print(type(first))class Main {
public static void main(String[] args) {
String text = "hello";
char first = text.charAt(0);
System.out.println(first);
System.out.println(first + 1);
System.out.println((char) (first + 1));
System.out.println(((Object) first).getClass().getSimpleName());
}
}Look at the second line of output:
first + 1 is 105, not "h1" and not "i", because char promotes to int in arithmetic. Getting a character back requires the cast on the third line. The same promotion is why System.out.println('a' + 'b') prints 195. Being 16 bits, a char also cannot hold an emoji or any other character above U+FFFF — those occupy two char values, so length() counts UTF-16 units rather than characters, where Python counts code points.Collections
list vs. List
A Python list is a single concrete type that holds anything. A Java
List is an interface — a contract — and ArrayList is the implementation you almost always want. Declaring the variable as the interface and constructing the implementation, as here, is the house style across the entire ecosystem.names = ["Ada", "Grace"]
names.append("Alan")
print(names[0])
print(len(names))
print(names)import java.util.ArrayList;
import java.util.List;
class Main {
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of("Ada", "Grace"));
names.add("Alan");
System.out.println(names.get(0));
System.out.println(names.size());
System.out.println(names);
}
}The habit pays off when the implementation should change: swapping
new ArrayList<>() for new LinkedList<>() touches one line, because nothing downstream depends on more than the interface. Note the empty diamond <>, which asks the compiler to infer String from the declaration. There is no indexing syntax — names.get(0), not names[0], since Java has no operator overloading — and no negative indices. The printed forms differ slightly: Java has no repr, so its list prints [Ada, Grace, Alan] with the strings unquoted.The fixed-size array Python does not have
Java has two list-like things and Python has one. Alongside
List there is the array — a contiguous block of a fixed length, declared int[], with its own square-bracket syntax and no methods at all. It is the only collection that can hold primitives without boxing, which is why numeric code is full of them.scores = [0] * 3
scores[0] = 95
print(scores, len(scores))
# A list simply grows.
scores.append(88)
print(scores, len(scores))import java.util.Arrays;
class Main {
public static void main(String[] args) {
int[] scores = new int[3]; // zero-filled, length fixed forever
scores[0] = 95;
System.out.println(Arrays.toString(scores) + " " + scores.length);
// An array never grows. "Appending" means copying into a bigger one.
int[] grown = Arrays.copyOf(scores, 4);
grown[3] = 88;
System.out.println(Arrays.toString(grown) + " " + grown.length);
}
}An array's length is fixed at creation and there is no
add; growing means allocating a new array and copying, which is exactly what ArrayList does for you internally. A new int[] is zero-filled and a new String[] is null-filled — there is no uninitialized-memory hazard as in C. You will meet arrays mainly in three places: String[] args, APIs that predate collections, and performance-sensitive numeric code. For everything else, reach for List.dict vs. Map
The data structure is the same and the failure mode is not. Looking up a missing key raises
KeyError in Python — loud, immediate, and named after the thing that went wrong. In Java it returns null, which is not an error at all; the program continues and fails later, somewhere else, with a NullPointerException that names the wrong line.ages = {}
ages["Ada"] = 36
ages["Grace"] = 45
print(ages["Ada"])
print(ages.get("Nobody", 0))
try:
print(ages["Nobody"])
except KeyError as problem:
print("KeyError:", problem)
for name, age in ages.items():
print(name, age)import java.util.LinkedHashMap;
import java.util.Map;
class Main {
public static void main(String[] args) {
Map<String, Integer> ages = new LinkedHashMap<>();
ages.put("Ada", 36);
ages.put("Grace", 45);
System.out.println(ages.get("Ada"));
System.out.println(ages.getOrDefault("Nobody", 0));
// A missing key is not an error. It is null.
System.out.println(ages.get("Nobody"));
for (Map.Entry<String, Integer> entry : ages.entrySet()) {
System.out.println(entry.getKey() + " " + entry.getValue());
}
}
}getOrDefault is the direct equivalent of dict.get(key, default) and is worth making a reflex. The class choice also matters more than it looks: a plain HashMap has no defined iteration order, so a loop over one prints in an order that can change between runs, while Python dictionaries have preserved insertion order since 3.7. LinkedHashMap, used here, is the class that matches what you are used to; TreeMap keeps keys sorted. Also note that the key and value types are written out — Map<String, Integer> — and that the value type must be the boxed Integer, never int.set vs. Set
Both languages have hash sets with the same semantics. What Java lacks is the operator syntax: with no operator overloading,
& and | keep their bitwise meanings and set algebra is done by methods that modify the receiver in place.first = {"a", "b", "c"}
second = {"b", "c", "d"}
intersection = first & second
union = first | second
print(sorted(intersection))
print(sorted(union))
print("a" in first)import java.util.Set;
import java.util.TreeSet;
class Main {
public static void main(String[] args) {
Set<String> first = new TreeSet<>(Set.of("a", "b", "c"));
Set<String> second = new TreeSet<>(Set.of("b", "c", "d"));
Set<String> intersection = new TreeSet<>(first);
intersection.retainAll(second);
Set<String> union = new TreeSet<>(first);
union.addAll(second);
System.out.println(intersection);
System.out.println(union);
System.out.println(first.contains("a"));
}
}That last point is the trap.
first.retainAll(second) is Python's first &= second, not first & second, so computing an intersection without destroying an operand means copying first — which is what the new TreeSet<>(first) calls do here. The three you need are retainAll (intersection), addAll (union) and removeAll (difference). TreeSet keeps its elements sorted, which is why the printed output is stable; a HashSet would print in an unspecified order, exactly as an unsorted Python set does.Returning two things without a tuple
Python returns a tuple and destructures it on the way out, and the whole thing takes one line with no declarations. Java has no tuple type at all. The modern answer is to declare a
record — a named, typed, immutable carrier — which takes one extra line and buys you names for the two halves.def divide(numerator, denominator):
return numerator // denominator, numerator % denominator
quotient, remainder = divide(17, 5)
print(quotient, remainder)
print(divide(17, 5))record DivisionResult(int quotient, int remainder) {}
class Main {
static DivisionResult divide(int numerator, int denominator) {
return new DivisionResult(numerator / denominator, numerator % denominator);
}
public static void main(String[] args) {
DivisionResult result = divide(17, 5);
System.out.println(result.quotient() + " " + result.remainder());
System.out.println(divide(17, 5));
}
}The second printed line shows what the record gives you free:
toString, equals and hashCode are generated, so a record prints as DivisionResult[quotient=3, remainder=2] rather than as an address. Before records arrived in Java 16 this pattern meant a twenty-line class or an Object[], which is why so much older Java code returns a mutable out-parameter or a Map instead. There is still no destructuring at assignment — but there is in a switch, which the Pattern Matching section shows.Immutability the compiler cannot see
Here the usual direction of this page reverses, and it is worth noticing. Python distinguishes mutable from immutable in the type — a tuple is not a list, and any checker knows that assigning to one is wrong. Java's immutable collections are the same
List interface with the mutating methods rewired to throw.fixed = ("a", "b")
print(fixed[0])
try:
fixed[0] = "c"
except TypeError as problem:
print("TypeError:", problem)import java.util.List;
class Main {
public static void main(String[] args) {
List<String> fixed = List.of("a", "b");
System.out.println(fixed.get(0));
try {
fixed.set(0, "c");
} catch (UnsupportedOperationException problem) {
System.out.println("UnsupportedOperationException");
}
}
}So
fixed.set(0, "c") compiles perfectly and fails at run time, which is exactly the shape of bug Java's type system usually eliminates. The practical consequence is that "is this list safe to modify?" is not answerable from a signature — a method taking a List<String> cannot tell whether it was handed an ArrayList or a List.of. Defensive copying (new ArrayList<>(incoming)) is the conventional answer. List.of, Map.of and Set.of also reject null elements outright, which the older Collections.unmodifiableList wrapper does not.Control Flow
if / elif / else
Structurally identical; three things move. The condition needs parentheses, the body needs braces instead of indentation, and there is no
elif — Java writes else if, which is genuinely just an if nested in an else.score = 87
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
else:
grade = "C"
print(grade)
print("pass" if score >= 60 else "fail")class Main {
public static void main(String[] args) {
int score = 87;
String grade;
if (score >= 90) {
grade = "A";
} else if (score >= 80) {
grade = "B";
} else {
grade = "C";
}
System.out.println(grade);
System.out.println(score >= 60 ? "pass" : "fail");
}
}Note the bare
String grade; declaration before the branch. Java requires a variable to be declared before use and will refuse to compile a read of one the compiler cannot prove was assigned on every path — remove the else here and the build fails with "variable grade might not have been initialized". Python has no such check, so the equivalent mistake is a NameError on the one input that takes the missing branch. The conditional expression reads backwards from Python's: condition first, then the two results.There is no truthiness
Python lets any object stand in for a condition, and defines emptiness and zero as false. Java conditions must be of type
boolean and nothing else — not a number, not a reference, not an Integer that happens to be zero.names = []
message = ""
count = 0
if not names:
print("no names")
if not message:
print("no message")
if not count:
print("count is zero")import java.util.ArrayList;
import java.util.List;
class Main {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
String message = "";
int count = 0;
// if (!names) ... does not compile: a List is not a boolean.
if (names.isEmpty()) {
System.out.println("no names");
}
if (message.isEmpty()) {
System.out.println("no message");
}
if (count == 0) {
System.out.println("count is zero");
}
}
}This removes a whole family of Python bugs by construction. The classic is
if not count: firing when count is legitimately 0 rather than missing, and its cousin where an empty list and None are indistinguishable to an if. In Java the three tests above are visibly three different questions, and a null check is a separate, explicit names != null. The cost is verbosity: if (names != null && !names.isEmpty()) is a very common line, and there is no shorter way to write it.range loops and for-each
Java has both loops Python collapsed into one. The three-part
for is the C loop, where you write the counter, the test and the increment yourself; the enhanced for (the colon form) is Python's for x in xs and works on anything iterable, arrays included.for index in range(3):
print(index)
names = ["Ada", "Grace"]
for name in names:
print(name)
for index, name in enumerate(names):
print(index, name)import java.util.List;
class Main {
public static void main(String[] args) {
for (int index = 0; index < 3; index++) {
System.out.println(index);
}
List<String> names = List.of("Ada", "Grace");
for (String name : names) {
System.out.println(name);
}
for (int index = 0; index < names.size(); index++) {
System.out.println(index + " " + names.get(index));
}
}
}The enhanced form is the one to reach for, and the counting form only when you genuinely need the index — which is also the answer to
enumerate, since Java has no equivalent. Two habits worth keeping: < rather than <= in the test, matching range's half-open behaviour, and never modifying the collection inside an enhanced for, which throws ConcurrentModificationException on the next iteration where Python's equivalent silently skips elements. break and continue work as you expect; the for ... else clause has no counterpart.while, and the loop Java has that Python does not
while is the same loop in both languages. Java adds do { ... } while (condition);, which evaluates the body before the test and so always runs at least once — the shape Python spells as while True with a break at the bottom.countdown = 3
while countdown > 0:
print(countdown)
countdown -= 1
# Python has no do-while, so the "run at least once" shape
# is a while True with the test at the bottom.
attempts = 0
while True:
attempts += 1
print("attempt", attempts)
if attempts >= 2:
breakclass Main {
public static void main(String[] args) {
int countdown = 3;
while (countdown > 0) {
System.out.println(countdown);
countdown--;
}
// do-while runs the body first and tests afterwards.
int attempts = 0;
do {
attempts++;
System.out.println("attempt " + attempts);
} while (attempts < 2);
}
}Java also has the
++ and -- operators, which Python deliberately omits; countdown-- is countdown -= 1. Note the semicolon after the closing while of a do-while — it ends a statement, and leaving it off is a compile error rather than a silent change of meaning. Labelled break (outer: for (...) { ... break outer; }) exists too, and is the tidiest way out of a nested loop; Python needs a flag or an exception for that.match vs. the switch expression
Java's
switch became an expression in Java 14, and the arrow form here is the one to write. It returns a value, it does not fall through, and — unlike Python's match — the compiler checks that every possible input is covered.def describe(day):
match day:
case "Saturday" | "Sunday":
return "weekend"
case "Friday":
return "almost"
case _:
return "weekday"
for day in ["Saturday", "Friday", "Tuesday"]:
print(day, "->", describe(day))class Main {
static String describe(String day) {
return switch (day) {
case "Saturday", "Sunday" -> "weekend";
case "Friday" -> "almost";
default -> "weekday";
};
}
public static void main(String[] args) {
for (String day : new String[] {"Saturday", "Friday", "Tuesday"}) {
System.out.println(day + " -> " + describe(day));
}
}
}That last point is the real difference. A Python
match with no case _ simply falls off the end and returns None, which is a run-time surprise; a Java switch expression without a default is a compile error unless the compiler can prove the cases are exhaustive, which it can for an enum or a sealed type. If you meet the older colon form (case "Friday": with break), that is the C-style statement switch, where forgetting break falls through to the next case — the arrow form exists precisely to kill that bug.Methods
def vs. a method on a class
A Java method must declare the type of every parameter and of its return value, and it must live inside a class.
static means it belongs to the class rather than to an instance, which is what makes it callable the way a Python module-level function is.def greet(name):
return f"Hello, {name}!"
def add(first, second):
return first + second
print(greet("Ada"))
print(add(2, 3))
print(add("2", "3"))class Main {
static String greet(String name) {
return "Hello, " + name + "!";
}
static int add(int first, int second) {
return first + second;
}
public static void main(String[] args) {
System.out.println(greet("Ada"));
System.out.println(add(2, 3));
// add("2", "3") <- will not compile; add takes ints
}
}The third Python call is the point of the row.
add("2", "3") returns "23", because + means whatever the operands say it means and nobody wrote down what add was for. The Java version cannot be called that way at all, and if you want both behaviours you write both methods — see overloading, next. A method returning nothing is declared void; falling off the end of a non-void method is a compile error, where a Python function that forgets to return quietly hands back None.Overloading replaces default arguments
Java has no default parameter values and no keyword arguments. What it has instead is overloading: several methods may share a name as long as their parameter lists differ, and the compiler picks one by looking at the argument types at the call site.
def area(width, height=None):
if height is None:
return width * width
return width * height
print(area(4))
print(area(4, 5))class Main {
static int area(int width) {
return area(width, width);
}
static int area(int width, int height) {
return width * height;
}
public static void main(String[] args) {
System.out.println(area(4));
System.out.println(area(4, 5));
}
}The idiomatic shape is the one above — the short overload delegates to the long one, so the default lives in exactly one place. This resolution happens entirely at compile time, which has a consequence worth knowing: given
Object thing = "text";, a call show(thing) chooses the Object overload even though the value is a String, because only the declared type is consulted. Python's dispatch, having no types to consult, cannot be surprised this way. For a constructor with many optional parts, Java's answer is usually a builder rather than a dozen overloads.*args vs. varargs
Java's
int... is Python's *numbers: the caller passes any number of arguments and the method receives them as one collection. Java receives an array rather than a tuple, and there is no **kwargs equivalent at all.def total(*numbers):
return sum(numbers)
print(total())
print(total(1, 2, 3))
values = [4, 5, 6]
print(total(*values))class Main {
static int total(int... numbers) {
int sum = 0;
for (int number : numbers) sum += number;
return sum;
}
public static void main(String[] args) {
System.out.println(total());
System.out.println(total(1, 2, 3));
int[] values = {4, 5, 6};
System.out.println(total(values));
}
}Spreading an existing sequence is where they differ in feel. Python needs the explicit
*values; Java takes the array directly, because a varargs parameter is an array — passing one is the ordinary call, not a special form. The restrictions are that a varargs parameter must come last and there can only be one. The absence of keyword arguments is the bigger loss day to day: a Java call with five positional arguments is genuinely harder to read than the Python equivalent, which is why builders and records show up so often in well-written Java.What a method can change about its arguments
This is one of the places where Python and Java behave identically and are described with completely different vocabulary, so it is worth seeing side by side rather than reading about. Both pass a copy of the reference: reassigning the parameter is invisible to the caller, mutating the object it points at is not.
def rebind(numbers):
numbers = [9, 9] # rebinds the local name only
def mutate(numbers):
numbers.append(9) # changes the caller's list
values = [1, 2]
rebind(values)
print(values)
mutate(values)
print(values)import java.util.ArrayList;
import java.util.List;
class Main {
static void rebind(List<Integer> numbers) {
numbers = new ArrayList<>(List.of(9, 9)); // local variable only
}
static void mutate(List<Integer> numbers) {
numbers.add(9); // changes the caller's list
}
public static void main(String[] args) {
List<Integer> values = new ArrayList<>(List.of(1, 2));
rebind(values);
System.out.println(values);
mutate(values);
System.out.println(values);
}
}Java calls this "pass by value", meaning the reference value is copied; Python calls it "pass by object reference" or "call by sharing". Same behaviour, two vocabularies, and no language here passes by reference in the Pascal or C++ sense — there is no way to write a method that reassigns the caller's variable. Java's primitives make this less confusing than Python's immutables, since a copied
int obviously cannot be changed; the mental model that survives both languages is "the argument is copied, the object is not".Recursion depth
Python guards recursion with a counter — a limit of 1000 frames by default — and raises a catchable
RecursionError before the real stack is in danger. Java has no counter; it recurses until the thread's actual stack runs out and then throws StackOverflowError.import sys
def depth(level):
return level if level >= 3000 else depth(level + 1)
print(sys.getrecursionlimit())
try:
print(depth(0))
except RecursionError as problem:
print("RecursionError:", problem)class Main {
static int depth(int level) {
return level >= 3000 ? level : depth(level + 1);
}
public static void main(String[] args) {
try {
System.out.println(depth(0));
} catch (StackOverflowError problem) {
System.out.println("StackOverflowError");
}
}
}The practical effect is that the Java version usually succeeds where the Python one does not, because a default JVM thread stack holds roughly ten thousand frames of a small method. Neither language eliminates tail calls, so deep recursion is a loop in both if you want to be safe. Note that Java's failure is an
Error, not an Exception: catching it as done here is legal but conventionally wrong, since Error means the runtime is in trouble rather than your logic.Classes & Objects
A class, a constructor, an instance
The pieces line up one to one, and the differences are all about what has to be declared. Fields are declared at the top of the class with their types, rather than appearing the moment
__init__ assigns them. The constructor has no name of its own — it is the method whose name is the class name and which declares no return type. And new is required to build an instance.class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
rectangle = Rectangle(3, 4)
print(rectangle.width)
print(rectangle.area())class Rectangle {
private final int width;
private final int height;
Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
int width() {
return width;
}
int area() {
return width * height;
}
}
class Main {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle(3, 4);
System.out.println(rectangle.width());
System.out.println(rectangle.area());
}
}Two things follow from declaring fields up front. The set of fields is fixed, so you cannot attach a new attribute to an instance later the way Python allows (there is no
__dict__ to add to, and no need for __slots__ to prevent it). And because they are declared, the compiler knows their types everywhere, which is what makes rectangle.area() checkable. The receiver is called this and is implicit — it appears in the constructor only to distinguish the field from the same-named parameter, where Python's self must be written every time and declared as the first parameter of every method.private is enforced, not requested
A leading underscore in Python is a note to the reader; the double underscore mangles the name but still leaves it reachable, as the second line of output shows. Java's
private is checked by the compiler, so there is no spelling of account.balance that compiles from outside the class.class Account:
def __init__(self, balance):
self._balance = balance
self.__secret = "pin-1234"
account = Account(100)
print(account._balance)
print(account._Account__secret)class Account {
private int balance;
private String secret = "pin-1234";
Account(int balance) {
this.balance = balance;
}
int balance() {
return balance;
}
}
class Main {
public static void main(String[] args) {
Account account = new Account(100);
// account.balance <- will not compile
// account.secret <- will not compile
System.out.println(account.balance());
}
}There are four levels, and the default is the one nobody names:
public (everyone), protected (subclasses and the same package), package-private (no keyword at all — the same package only, which is what balance() above is), and private (this class). Because the compiler enforces them, the accessor-method style is genuinely load-bearing rather than ceremonial: changing a field's representation behind balance() cannot break a caller, whereas in Python you would reach for @property to achieve the same thing after the fact. Reflection can still defeat private at run time, but nothing in ordinary code can.Inheritance and overriding
Single inheritance works the same way and dispatch is virtual in both, so
introduce calls the subclass's speak. The visible additions are extends instead of parentheses, an explicit super(name) call that must be the first statement of the constructor, and the @Override annotation.class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
def introduce(self):
return f"{self.name} says {self.speak()}"
class Dog(Animal):
def speak(self):
return "Woof"
print(Dog("Rex").introduce())class Animal {
protected final String name;
Animal(String name) {
this.name = name;
}
String speak() {
return "...";
}
String introduce() {
return name + " says " + speak();
}
}
class Dog extends Animal {
Dog(String name) {
super(name);
}
@Override
String speak() {
return "Woof";
}
}
class Main {
public static void main(String[] args) {
System.out.println(new Dog("Rex").introduce());
}
}@Override is optional and you should always write it. It asks the compiler to verify that the method really does override something, which turns a typo — speaks() instead of speak() — from a method that is silently never called into a build failure. Python has no equivalent check, and that particular bug is a familiar one. The other structural difference is that Java allows only one superclass; where Python reaches for multiple inheritance and mixins, Java uses interfaces with default methods, and there is no method resolution order to reason about.__repr__ vs. toString
Every Java object inherits a
toString from Object, and the inherited one prints the class name and a hash — Color@1b6d3586. Overriding it is the equivalent of writing __repr__, and just as in Python it is what printing a collection of the objects uses.class Color:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"Color({self.name!r})"
color = Color("red")
print(color)
print([color])import java.util.List;
class Color {
private final String name;
Color(String name) {
this.name = name;
}
@Override
public String toString() {
return "Color('" + name + "')";
}
}
class Main {
public static void main(String[] args) {
Color color = new Color("red");
System.out.println(color);
System.out.println(List.of(color));
}
}Java has only the one method where Python has two:
__str__ for humans and __repr__ for developers, with the latter used inside containers. If you need both in Java you write a second method with a name of your choosing and call it deliberately. Writing toString is not optional in practice — a log line or an assertion failure that prints Color@1b6d3586 tells you nothing, and this is the most common reason a debugging session in unfamiliar Java code starts badly. Records, in the next section, generate it for you.Value equality is opt-in, and comes in a pair
Both languages compare objects by identity until you say otherwise, and in both the hash function must be overridden alongside the equality test or hash-based containers break. The rule is identical: equal objects must have equal hashes.
class Color:
def __init__(self, name):
self.name = name
def __eq__(self, other):
return isinstance(other, Color) and self.name == other.name
def __hash__(self):
return hash(self.name)
first = Color("red")
second = Color("red")
print(first == second)
print(len({first, second}))import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
class Color {
private final String name;
Color(String name) {
this.name = name;
}
@Override
public boolean equals(Object other) {
return other instanceof Color color && name.equals(color.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
class Main {
public static void main(String[] args) {
Color first = new Color("red");
Color second = new Color("red");
System.out.println(first.equals(second));
Set<Color> unique = new HashSet<>(List.of(first, second));
System.out.println(unique.size());
}
}Two Java-specific details. The parameter of
equals is Object, not Color — writing equals(Color other) compiles as an overload that collections never call, which is the classic silent failure and exactly what @Override catches. And Python has a safety net Java does not: defining __eq__ without __hash__ makes the class unhashable, so the mistake shows up as a TypeError the first time you put one in a set, whereas Java's inherited identity hash keeps working and simply gives the wrong answer. In modern code the honest advice is to avoid writing either method by hand — use a record, which generates both correctly.Class attributes and static methods
Python distinguishes three things by decorator: an ordinary method receives the instance, a
@classmethod receives the class, and a @staticmethod receives neither. Java has two, marked by one keyword: static members belong to the class, everything else belongs to an instance.class Counter:
total = 0
@staticmethod
def describe():
return "counts things"
@classmethod
def bump(cls):
cls.total += 1
Counter.bump()
Counter.bump()
print(Counter.total)
print(Counter.describe())class Counter {
static int total = 0;
static String describe() {
return "counts things";
}
static void bump() {
total++;
}
}
class Main {
public static void main(String[] args) {
Counter.bump();
Counter.bump();
System.out.println(Counter.total);
System.out.println(Counter.describe());
}
}There is no direct equivalent of
@classmethod, because cls exists to support subclass-aware factories and Java resolves static calls against the declared type at compile time — a static method is not overridden, only hidden. A subtler difference concerns the shared field: Counter.total is one variable in both languages, but in Python an instance assignment such as counter.total = 5 creates a new instance attribute that shadows the class one, while in Java it writes straight through to the single static field. The Python bug where a class-level list is accidentally shared between all instances has an exact Java counterpart in a static collection.Duck typing vs. declared interfaces
In Python,
total_area works on anything with an area method because the lookup happens when the call runs. Java must know at compile time that the call is legal, so the two classes have to share a declared type — an interface, which they announce with implements.class Square:
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius * self.radius
def total_area(shapes):
return sum(shape.area() for shape in shapes)
print(total_area([Square(2), Circle(1)]))import java.util.List;
interface Shape {
double area();
}
class Square implements Shape {
private final double side;
Square(double side) {
this.side = side;
}
public double area() {
return side * side;
}
}
class Circle implements Shape {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
public double area() {
return 3.14159 * radius * radius;
}
}
class Main {
static double totalArea(List<Shape> shapes) {
double total = 0;
for (Shape shape : shapes) {
total += shape.area();
}
return total;
}
public static void main(String[] args) {
System.out.println(totalArea(List.of(new Square(2), new Circle(1))));
}
}The trade is explicitness against reach. Java's version cannot be handed an object from a library that has an
area() method but does not implement Shape; Python's can, which is what makes so much of the ecosystem compose without coordination. In exchange, totalArea's signature documents exactly what it needs, and adding a third shape that forgets area() is a build error rather than an AttributeError in production. Java's closest approach to duck typing is Protocol-style structural checking in reverse: interfaces are declared by the implementer, not inferred at the use site.Records, Enums & Sealed Types
record vs. @dataclass
This is the row where Java is shorter. A
record declares its components in the header and the compiler generates the fields, the constructor, the accessors, equals, hashCode and toString — the same bargain @dataclass(frozen=True) offers, without the decorator.from dataclasses import dataclass
@dataclass(frozen=True)
class Employee:
name: str
salary: int
def raised(self, amount):
return Employee(self.name, self.salary + amount)
employee = Employee("Ada", 100)
print(employee)
print(employee.raised(50))
print(employee == Employee("Ada", 100))record Employee(String name, int salary) {
Employee raised(int amount) {
return new Employee(name, salary + amount);
}
}
class Main {
public static void main(String[] args) {
Employee employee = new Employee("Ada", 100);
System.out.println(employee);
System.out.println(employee.raised(50));
System.out.println(employee.equals(new Employee("Ada", 100)));
}
}Accessors are named after the component with no
get prefix, so it is employee.name() rather than getName(). Records are always final and their components always immutable, so there is no mutable variant to choose — a mutable data holder is an ordinary class. You can add methods, as here, and a compact constructor for validation; you cannot add extra instance fields, which is the constraint that makes the generated methods trustworthy. Since Java 16 this has become the default way to model data, and it is why so much modern Java looks less verbose than its reputation.Enum vs. enum
Both languages give enum members methods and attached data. Java's version is a language construct rather than a library class, so the members are declared first and the payload is passed to a private constructor — a shape that reads oddly at first and then becomes natural.
from enum import Enum
class Status(Enum):
ACTIVE = "active"
CLOSED = "closed"
def is_final(self):
return self is Status.CLOSED
print(Status.ACTIVE.name)
print(Status.ACTIVE.value)
print(Status.CLOSED.is_final())
for status in Status:
print(status.name)enum Status {
ACTIVE("active"),
CLOSED("closed");
private final String label;
Status(String label) {
this.label = label;
}
String label() {
return label;
}
boolean isFinal() {
return this == CLOSED;
}
}
class Main {
public static void main(String[] args) {
System.out.println(Status.ACTIVE.name());
System.out.println(Status.ACTIVE.label());
System.out.println(Status.CLOSED.isFinal());
for (Status status : Status.values()) {
System.out.println(status.name());
}
}
}Two Java advantages follow from it being built in. A variable of type
Status can only ever hold ACTIVE, CLOSED or null, so a whole class of "somebody passed the string" bug is unrepresentable; and a switch over an enum is checked for exhaustiveness, so adding a third member turns every switch that has not been updated into a build failure. That last property is the single best reason to prefer an enum to a set of constants, and it has no Python equivalent — a match over Status that misses a member simply falls through. Comparison uses ==, matching Python's is, because enum members really are singletons.Sealed interfaces: a union the compiler can count
A
sealed interface names every type allowed to implement it, and no other class anywhere can join. That closed list is what lets the compiler prove a switch over it is exhaustive — notice the Java version has no default branch at all, and needs none.from dataclasses import dataclass
@dataclass
class Card:
number: str
@dataclass
class Cash:
amount: int
Payment = Card | Cash # a type alias; nothing stops a third class
def describe(payment):
match payment:
case Card(number=number):
return "card ending " + number[-4:]
case Cash(amount=amount):
return f"{amount} in cash"
return "unknown"
print(describe(Card("4111111111111234")))
print(describe(Cash(20)))sealed interface Payment permits Card, Cash {}
record Card(String number) implements Payment {}
record Cash(int amount) implements Payment {}
class Main {
static String describe(Payment payment) {
return switch (payment) {
case Card card -> "card ending "
+ card.number().substring(card.number().length() - 4);
case Cash cash -> cash.amount() + " in cash";
};
}
public static void main(String[] args) {
System.out.println(describe(new Card("4111111111111234")));
System.out.println(describe(new Cash(20)));
}
}The Python column is the same idea without enforcement.
Card | Cash is a type alias a checker may consult, but nothing prevents a fourth class from appearing, so the fall-through return "unknown" has to stay. In Java, adding record Cheque(...) implements Payment means editing the permits clause and then finding that every switch over Payment stops compiling until it handles the new case — the compiler hands you the list of places to change. This combination of sealed types, records and pattern switches is how modern Java expresses what a functional language would call an algebraic data type.Pattern Matching
isinstance vs. the instanceof pattern
Python narrows a type by asking
isinstance and then simply using the value, because there was never a static type to narrow. Java's instanceof used to require a cast on the next line; since Java 16 the test can bind a new, correctly typed variable in the same breath.def describe(value):
if isinstance(value, str):
return f"string of length {len(value)}"
if isinstance(value, int):
return f"int worth {value * 2}"
return "something else"
print(describe("hello"))
print(describe(21))
print(describe(3.5))class Main {
static String describe(Object value) {
if (value instanceof String text) {
return "string of length " + text.length();
}
if (value instanceof Integer number) {
return "int worth " + (number * 2);
}
return "something else";
}
public static void main(String[] args) {
System.out.println(describe("hello"));
System.out.println(describe(21));
System.out.println(describe(3.5));
}
}The binding is scoped to exactly where the test is known to have succeeded, which is cleverer than it looks: inside the
if body, and also after an early return in the negative branch, so if (!(value instanceof String text)) return ""; leaves text usable below. The pattern is what makes the older, error-prone shape — test, then cast to a type you retyped by hand — obsolete. Note that 21 arrives as an Integer rather than an int, because Object can only hold a reference; that is autoboxing from the Types section doing its work.Destructuring in a switch, with guards
This is the closest Java comes to Python's
match, and it arrived in Java 21. A record pattern takes the object apart into named, typed variables in the case label itself, and when is Python's if guard.from dataclasses import dataclass
@dataclass
class Delivery:
city: str
parcels: int
def summarize(value):
match value:
case Delivery(city="Oslo", parcels=parcels):
return f"{parcels} to head office"
case Delivery(city=city, parcels=parcels) if parcels > 10:
return f"bulk run to {city}"
case Delivery(city=city):
return f"one drop in {city}"
case _:
return "not a delivery"
print(summarize(Delivery("Oslo", 3)))
print(summarize(Delivery("Bergen", 40)))
print(summarize(Delivery("Tromso", 2)))
print(summarize("nonsense"))record Delivery(String city, int parcels) {}
class Main {
static String summarize(Object value) {
return switch (value) {
case Delivery(String city, int parcels) when city.equals("Oslo") ->
parcels + " to head office";
case Delivery(String city, int parcels) when parcels > 10 ->
"bulk run to " + city;
case Delivery(String city, int parcels) ->
"one drop in " + city;
default ->
"not a delivery";
};
}
public static void main(String[] args) {
System.out.println(summarize(new Delivery("Oslo", 3)));
System.out.println(summarize(new Delivery("Bergen", 40)));
System.out.println(summarize(new Delivery("Tromso", 2)));
System.out.println(summarize("nonsense"));
}
}The mechanics differ in one way that matters when you write them. A Python class pattern matches by keyword —
Delivery(city="Oslo") — and literals in the pattern are themselves part of the match, so the first case tests the city without a guard. A Java record pattern is strictly positional and its components can only bind or nest, never compare, so an equality test has to move into a when clause. Patterns nest in both: case Shipment(Delivery(String city, int parcels), var stamp) is valid Java. There is still no destructuring outside a pattern context, so a plain assignment cannot take a record apart.What a switch does with null
A Python
match handles None like any other value, and a bare case _ catches it. Java's switch historically threw NullPointerException the moment the selector was null — before testing a single case, and regardless of the default.def describe(value):
match value:
case None:
return "nothing"
case "hello":
return "a greeting"
case _:
return "something else"
print(describe(None))
print(describe("hello"))
print(describe("goodbye"))class Main {
static String describe(String value) {
return switch (value) {
case null -> "nothing";
case "hello" -> "a greeting";
default -> "something else";
};
}
public static void main(String[] args) {
System.out.println(describe(null));
System.out.println(describe("hello"));
System.out.println(describe("goodbye"));
}
}Since Java 21 you can write
case null, and doing so is the only thing that changes the behaviour: without it, this method still throws on describe(null) even though default is right there. That asymmetry catches people, because every other construct in the language treats default as "everything else". The safest habit coming from Python is to assume a switch is null-hostile unless it says otherwise, and to write case null, default -> ... when you genuinely want them handled together.Generics & Erasure
list[str] vs. List<String>
This is the type-hint row again, applied to the container that most often carries data through a program. The Python annotation records an intention; the Java type parameter is enforced at every call site that touches the list.
names: list[str] = ["Ada", "Grace"]
# The annotation is not consulted, so this is allowed and the
# list quietly ends up holding two different kinds of thing.
names.append(42)
print(names)
for name in names:
print(name.upper() if isinstance(name, str) else name)import java.util.ArrayList;
import java.util.List;
class Main {
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of("Ada", "Grace"));
// names.add(42); <- will not compile; the element type is String
System.out.println(names);
for (String name : names) {
System.out.println(name.toUpperCase());
}
}
}The pay-off is visible in the loop. Java's enhanced
for can declare String name because the element type is guaranteed, so calling toUpperCase needs no check; the Python loop has to defend itself, or trust that nothing like the append(42) above ever happened anywhere in the codebase. This is also why Java collections need no equivalent of the isinstance filtering you see in defensive Python — the compiler already did it, once, at the boundary.Writing your own generic type
Python 3.12 adopted a syntax that lines up almost exactly with Java's: a type parameter in square brackets after the class name, used as an ordinary type inside the body. Java writes it in angle brackets and has had it since 2004.
class Box[T]:
def __init__(self, item: T):
self.item = item
def get(self) -> T:
return self.item
text = Box("hello")
number = Box(42)
print(text.get().upper())
print(number.get() + 1)class Box<T> {
private final T item;
Box(T item) {
this.item = item;
}
T get() {
return item;
}
}
class Main {
public static void main(String[] args) {
Box<String> text = new Box<>("hello");
Box<Integer> number = new Box<>(42);
System.out.println(text.get().toUpperCase());
System.out.println(number.get() + 1);
// String wrong = number.get(); <- will not compile
}
}The difference is again what happens with the wrong type.
number.get() is an Integer to the Java compiler, so assigning it to a String is a build failure, and the call to toUpperCase on the other box is checked the same way. In Python both are run-time events. Note the empty diamond in new Box<>("hello"): the compiler infers the argument from the declared variable, which is why generic Java is much less noisy to write than to read.The type argument is gone by run time
Here Java is weaker than a Python programmer expects, and the reason is history. Generics arrived in Java 5 and had to keep working with libraries compiled before them, so the compiler checks the type argument and then erases it. At run time a
List<String> is just a List.words = ["Ada"]
numbers = [1]
print(type(words) is type(numbers))
print(type(words).__name__)import java.util.ArrayList;
import java.util.List;
class Main {
public static void main(String[] args) {
List<String> words = new ArrayList<>(List.of("Ada"));
List<Integer> numbers = new ArrayList<>(List.of(1));
System.out.println(words.getClass() == numbers.getClass());
System.out.println(words.getClass().getSimpleName());
// if (words instanceof List<String>) ... <- will not compile
// new T[10] inside a generic class <- will not compile
}
}Both columns print that the two containers have the same class, for the same underlying reason: neither runtime records what the container is supposed to hold. What Java gets in exchange for erasure is a compile-time guarantee Python has no equivalent of; what it loses is everything reflective. You cannot ask an object what its type argument was, you cannot write
instanceof List<String>, you cannot create an array of a type parameter, and a class cannot implement both Comparable<Foo> and Comparable<Bar>. When a library really needs the type at run time it asks you to hand it over as a Class<T> argument, which is why so many Java APIs take a SomeType.class parameter that looks redundant.Wildcards, and why List<Object> will not do
A Python function taking a sequence of numbers takes any sequence of numbers. The naive Java translation,
List<Number>, accepts neither of these calls — generics are invariant, so a List<Integer> is not a List<Number> even though an Integer is a Number.def total(values):
return sum(values)
integers = [1, 2, 3]
floats = [1.5, 2.5]
print(total(integers))
print(total(floats))import java.util.List;
class Main {
static double total(List<? extends Number> values) {
double sum = 0;
for (Number value : values) {
sum += value.doubleValue();
}
return sum;
}
public static void main(String[] args) {
List<Integer> integers = List.of(1, 2, 3);
List<Double> doubles = List.of(1.5, 2.5);
System.out.println(total(integers));
System.out.println(total(doubles));
// static double total(List<Number> values) would reject BOTH calls
}
}The reason invariance is right is worth holding on to: if a
List<Integer> could be used as a List<Number>, somebody could add a Double to it, and the original list's element type would be a lie. The wildcard ? extends Number says "some specific subtype of Number, I do not know which", which makes reading safe and writing impossible — you cannot add to it, which is exactly the guarantee needed. Its mirror image, ? super Integer, allows writing and not reading. The mnemonic the Java world uses is PECS: producer extends, consumer super. (The first line of output differs only because total is declared to return double, so a sum of integers prints as 6.0 where Python's sum keeps it an int.) This is the part of generics that feels alien coming from Python, and it exists entirely to preserve the guarantee erasure cannot enforce at run time.Error Handling
try / except vs. try / catch
The structure is the same down to
finally running on the way out of a return. Only the keyword changes — except becomes catch, and the exception type comes first with the variable after it.def parse(text):
try:
return int(text)
except ValueError as problem:
print("bad input:", problem)
return 0
finally:
print("done with", text)
print(parse("42"))
print(parse("abc"))class Main {
static int parse(String text) {
try {
return Integer.parseInt(text);
} catch (NumberFormatException problem) {
System.out.println("bad input: " + problem.getMessage());
return 0;
} finally {
System.out.println("done with " + text);
}
}
public static void main(String[] args) {
System.out.println(parse("42"));
System.out.println(parse("abc"));
}
}Java has no
else clause on a try. Catching several types at once is catch (NumberFormatException | ArithmeticException problem), the equivalent of Python's tuple of exception classes. The habit worth breaking early is the bare except: — its Java spelling, catch (Exception problem), is just as broad and just as frowned upon, and its wider cousin catch (Throwable ...) also swallows OutOfMemoryError. Note too that Java exceptions carry a stack trace you print with problem.printStackTrace(), and that getMessage() may legitimately return null.Checked exceptions: the compiler makes you decide
Nothing in Python prepares you for this. Java divides exceptions in two. Unchecked ones —
RuntimeException and its subclasses, which is everything Python has — behave exactly as you expect. Checked ones must be either caught or re-declared with throws by every method they pass through, and the compiler refuses to build code that does neither.def read_configuration():
raise OSError("config file missing")
# Nothing in the signature says this can fail, and nothing
# obliges the caller to do anything about it.
try:
read_configuration()
except OSError as problem:
print("Handled:", problem)
# Equally legal: call it and ignore the possibility entirely.
def careless():
read_configuration()import java.io.IOException;
class Main {
static void readConfiguration() throws IOException {
throw new IOException("config file missing");
}
public static void main(String[] args) {
try {
readConfiguration();
} catch (IOException problem) {
System.out.println("Handled: " + problem.getMessage());
}
}
// static void careless() { readConfiguration(); }
// <- will not compile: unreported exception IOException;
// must be caught or declared to be thrown
}The commented-out method is the whole point: it is a build failure, not a warning. The upside is that a signature tells you what can go wrong, and a failure mode cannot be forgotten silently — the "we never handled that error path" bug is unrepresentable for checked types. The downside is real too, and opinion is genuinely divided within the Java world: checked exceptions do not compose with lambdas at all, and the pressure to keep signatures clean drives the anti-pattern
catch (IOException e) { }, which is strictly worse than never having declared anything. Modern APIs and every recent JVM language lean unchecked, so most new code you write will throw RuntimeException subclasses — but the standard library's I/O, reflection and concurrency APIs are checked and always will be.with vs. try-with-resources
Both languages guarantee cleanup on every exit path, and both do it by making the resource itself responsible. Python's protocol is
__enter__ and __exit__ (or the generator shorthand shown here); Java's is a single close() method declared by the AutoCloseable interface.from contextlib import contextmanager
@contextmanager
def connection():
print("opened")
try:
yield "handle"
finally:
print("closed")
with connection() as handle:
print("using", handle)class Connection implements AutoCloseable {
Connection() {
System.out.println("opened");
}
String handle() {
return "handle";
}
@Override
public void close() {
System.out.println("closed");
}
}
class Main {
public static void main(String[] args) {
try (Connection connection = new Connection()) {
System.out.println("using " + connection.handle());
}
}
}The resource is declared inside the
try parentheses, and several may be declared there separated by semicolons — they are closed in reverse order, as nested with statements are. The one place Java is less capable is that close() cannot inspect or suppress the exception on the way out, where Python's __exit__ receives it and can return true to swallow it; an exception thrown by close() itself is instead attached to the original as a suppressed exception. Anything with a close method in the standard library already implements this interface, so files, sockets and database connections all work with it.Defining your own exception
Subclass, call the parent constructor with a message, carry whatever extra data the handler needs — the recipe is identical. The one decision Java forces on you is which parent to extend, and that decision is the checked/unchecked choice from two rows up.
class InsufficientFunds(Exception):
def __init__(self, shortfall):
super().__init__(f"short by {shortfall}")
self.shortfall = shortfall
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFunds(amount - balance)
return balance - amount
try:
withdraw(100, 150)
except InsufficientFunds as problem:
print(problem)
print(problem.shortfall)class InsufficientFunds extends RuntimeException {
private final int shortfall;
InsufficientFunds(int shortfall) {
super("short by " + shortfall);
this.shortfall = shortfall;
}
int shortfall() {
return shortfall;
}
}
class Main {
static int withdraw(int balance, int amount) {
if (amount > balance) {
throw new InsufficientFunds(amount - balance);
}
return balance - amount;
}
public static void main(String[] args) {
try {
withdraw(100, 150);
} catch (InsufficientFunds problem) {
System.out.println(problem.getMessage());
System.out.println(problem.shortfall());
}
}
}Extending
RuntimeException, as here, gives you Python's behaviour: callers may catch it and are never obliged to. Extending Exception makes it checked, which puts throws InsufficientFunds in the signature of withdraw and every method above it. The usual guidance is to extend RuntimeException for programming errors and conditions a caller cannot sensibly recover from, and Exception only when the caller genuinely has a decision to make. Also note throw versus throws: the first raises, the second declares — a one-letter difference that is a compile error rather than a subtle bug, thankfully.Optional vs. returning None
Returning
None and returning null have the same flaw: the signature does not mention it, so the caller learns about the empty case from a crash. Optional<String> puts the possibility in the type, which is the closest Java gets to Python's str | None — with the difference that the compiler acts on it.def find(names, wanted):
for name in names:
if name == wanted:
return name
return None
names = ["Ada", "Grace"]
found = find(names, "Ada")
print(found.upper() if found is not None else "missing")
absent = find(names, "Alan")
print(absent.upper() if absent is not None else "missing")import java.util.List;
import java.util.Optional;
class Main {
static Optional<String> find(List<String> names, String wanted) {
for (String name : names) {
if (name.equals(wanted)) {
return Optional.of(name);
}
}
return Optional.empty();
}
public static void main(String[] args) {
List<String> names = List.of("Ada", "Grace");
System.out.println(find(names, "Ada").map(String::toUpperCase).orElse("missing"));
System.out.println(find(names, "Alan").map(String::toUpperCase).orElse("missing"));
}
}Because
Optional is a real object rather than a special form, the emptiness is handled by chaining rather than by an if: map applies a function only when a value is present, and orElse supplies the fallback. ifPresent, filter and orElseThrow round it out. Two cautions from the Java community's own experience with it: optional.get() without checking is just null with extra steps, and Optional is meant for return types — using it for fields or parameters is widely considered a mistake, since it adds an allocation and a second kind of absence to every access.Lambdas & Streams
lambda, and the interface behind it
Java's lambda syntax is
parameter -> expression, and unlike Python's it can have a braced body with statements and a return. What has no Python counterpart is the type: a lambda is not a first-class value of some universal "function" type — it is an instance of whatever interface with a single abstract method the context expects.def apply_twice(function, value):
return function(function(value))
doubler = lambda value: value * 2
print(doubler(5))
print(apply_twice(doubler, 5))
print(apply_twice(lambda value: value + 3, 5))import java.util.function.IntUnaryOperator;
class Main {
static int applyTwice(IntUnaryOperator function, int value) {
return function.applyAsInt(function.applyAsInt(value));
}
public static void main(String[] args) {
IntUnaryOperator doubler = value -> value * 2;
System.out.println(doubler.applyAsInt(5));
System.out.println(applyTwice(doubler, 5));
System.out.println(applyTwice(value -> value + 3, 5));
}
}So the parameter type here is
IntUnaryOperator, and calling it means calling that interface's method, applyAsInt. The standard library supplies a couple of dozen of these in java.util.function: Function<T, R> (call it with apply), Predicate<T> (test), Consumer<T> (accept), Supplier<T> (get), plus primitive-specialized variants like the one above that exist purely to avoid boxing. Learning which name to declare is most of the friction in coming from Python; after that, lambdas behave as you expect, except that any local variable they capture must be final or never reassigned.Comprehension vs. stream pipeline
The comprehension is the piece of Python you will reach for most and not find. Java's answer is the stream: a pipeline of
filter and map stages ending in a terminal operation that produces the result.numbers = [1, 2, 3, 4, 5, 6]
evens_squared = [number * number for number in numbers if number % 2 == 0]
print(evens_squared)
print(sum(evens_squared))import java.util.List;
class Main {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
List<Integer> evensSquared = numbers.stream()
.filter(number -> number % 2 == 0)
.map(number -> number * number)
.toList();
System.out.println(evensSquared);
System.out.println(evensSquared.stream().mapToInt(Integer::intValue).sum());
}
}Read the two and note the order flips — a comprehension states the output expression first and the filter last, a stream applies its stages top to bottom. Streams are lazy, so nothing runs until
toList(), and single-use: a stream cannot be traversed twice, which is why the sum on the last line opens a fresh one. The mapToInt step is not decoration — Stream<Integer> has no sum(), because summing boxed values is a different (and much slower) operation from summing an IntStream. For a simple loop over a collection, an enhanced for is still the more readable choice in Java; streams earn their keep when the pipeline is several stages long or ends in a non-trivial collector.sorted(key=...) vs. Comparator
Python sorts by a key function: you say what to extract, and the comparison is implied. Java sorts by a
Comparator, which is an object that compares two elements — but Comparator.comparing builds one from exactly the key function you would have written, so the two end up looking alike.names = ["Grace", "Ada", "Alan"]
print(sorted(names, key=len))
print(sorted(names, key=str.lower, reverse=True))import java.util.Comparator;
import java.util.List;
class Main {
public static void main(String[] args) {
List<String> names = List.of("Grace", "Ada", "Alan");
System.out.println(names.stream()
.sorted(Comparator.comparingInt(String::length))
.toList());
System.out.println(names.stream()
.sorted(Comparator.comparing((String name) -> name.toLowerCase()).reversed())
.toList());
}
}Both sorts are stable, so equal keys keep their original order — which is what puts
Ada before Alan in the first line of output despite neither being compared on its name. The comparator style composes in a way key= does not: .reversed() flips the whole comparison, and Comparator.comparing(Employee::department).thenComparing(Employee::name) expresses a multi-key sort that Python spells with a tuple key. Note also that names.stream().sorted() returns a new list, while Collections.sort(list) and list.sort(comparator) sort in place, the way list.sort() does in Python.Method references
When a lambda does nothing but call one existing method, Java lets you name that method instead:
String::toUpperCase replaces name -> name.toUpperCase(). It is the same idea as passing str.upper to map in Python — an unbound method used as a function of its receiver.names = ["ada", "grace"]
print([name.upper() for name in names])
print(list(map(str.upper, names)))import java.util.List;
class Main {
public static void main(String[] args) {
List<String> names = List.of("ada", "grace");
System.out.println(names.stream().map(name -> name.toUpperCase()).toList());
System.out.println(names.stream().map(String::toUpperCase).toList());
}
}There are four forms and it is worth recognizing them:
String::toUpperCase (an instance method used as a one-argument function), Integer::parseInt (a static method), employee::name (a method bound to one particular object), and ArrayList::new (a constructor, which Python spells as just the class name). The double colon is the only new syntax; everything else follows from the functional-interface rules. Method references are the most common reason well-written modern Java reads more compactly than its reputation suggests.Grouping into a dictionary
Python groups with a loop and a
defaultdict, and that really is the idiomatic answer. Java has a dedicated collector, groupingBy, which is the piece of the streams library that most often justifies using streams at all.from collections import defaultdict
words = ["apple", "avocado", "banana", "blueberry", "cherry"]
grouped = defaultdict(list)
for word in words:
grouped[word[0]].append(word)
for letter in sorted(grouped):
print(letter, grouped[letter])import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
class Main {
public static void main(String[] args) {
List<String> words = List.of("apple", "avocado", "banana", "blueberry", "cherry");
Map<Character, List<String>> grouped = words.stream()
.collect(Collectors.groupingBy(word -> word.charAt(0),
TreeMap::new,
Collectors.toList()));
for (Map.Entry<Character, List<String>> entry : grouped.entrySet()) {
System.out.println(entry.getKey() + " " + entry.getValue());
}
}
}The three-argument form used here is worth learning rather than the short one.
Collectors.groupingBy(classifier) alone returns a HashMap, whose iteration order is unspecified — so the printed output can differ between runs, which is why TreeMap::new is passed to get sorted keys, the same job the sorted(grouped) call does on the Python side. The third argument is the downstream collector, and swapping it is where the power is: Collectors.counting() gives a frequency table, Collectors.mapping(String::length, toList()) transforms while grouping, and Collectors.partitioningBy(predicate) splits into just two buckets.Concurrency
A thread, and no interpreter lock
The API is close enough to read at a glance — construct,
start, join. What differs is underneath: a Java thread is a real operating-system thread with no global interpreter lock above it, so two threads running pure computation genuinely use two cores.import threading
def work(worker_id):
print(f"worker {worker_id} running")
worker = threading.Thread(target=work, args=(1,))
worker.start()
worker.join()
print("main finished")class Main {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> System.out.println("worker 1 running"));
worker.start();
worker.join();
System.out.println("main finished");
}
}For anyone arriving from data work this is the headline. The Python pattern of reaching for
multiprocessing to get around the GIL — paying for process startup, and for pickling every argument and result across a pipe — has no counterpart here; threads share one heap, so passing a large array to a worker costs a reference. That is also why the JVM is the substrate for Spark, Flink and Kafka. The bill arrives in the next row: with real parallelism, every piece of shared mutable state is your problem, and Python's habit of assuming a coarse-grained lock is protecting you does not transfer.Locking shared state
Both columns print
400000, and in both the lock is what makes that true — total += 1 and total++ are each a read, an add and a write, and without mutual exclusion two threads can read the same value and both write back the same increment. Java's synchronized keyword takes the lock that every object carries for exactly this purpose.import threading
lock = threading.Lock()
total = 0
def increment():
global total
for _ in range(100_000):
with lock:
total += 1
workers = [threading.Thread(target=increment) for _ in range(4)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
print(total)class Counter {
private int total = 0;
synchronized void increment() {
total++;
}
synchronized int total() {
return total;
}
}
class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread[] workers = new Thread[4];
for (int index = 0; index < workers.length; index++) {
workers[index] = new Thread(() -> {
for (int step = 0; step < 100_000; step++) {
counter.increment();
}
});
workers[index].start();
}
for (Thread worker : workers) {
worker.join();
}
System.out.println(counter.total());
}
}The Python version is less safe than its reputation suggests: the GIL guarantees that a single bytecode is atomic, not that
total += 1 is, so the explicit lock is doing real work here rather than satisfying a formality. Java simply has no ambient lock to lull you, and it adds a second hazard Python does not have — visibility. Without synchronized or volatile, one thread's write may never become visible to another at all, because each core may keep its own cached copy; a loop polling an unsynchronized flag can spin forever on a value that was changed long ago. For a plain counter, AtomicInteger is both faster and harder to get wrong than a lock. The Java column cannot run on this page: the browser's sandbox (Compiler Explorer) allows a program only one thread beyond main, so the fourth worker fails to start.Thread pools and futures
Python's
concurrent.futures was modelled on Java's java.util.concurrent, so this is the one corner of the language where the vocabulary transfers wholesale: a pool you submit work to, a future you ask for a result, and a blocking call that waits for it.from concurrent.futures import ThreadPoolExecutor
def square(number):
return number * number
with ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(square, 7)
print(future.result())import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
class Main {
static int square(int number) {
return number * number;
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newSingleThreadExecutor();
try {
Future<Integer> future = pool.submit(() -> square(7));
System.out.println(future.get());
} finally {
pool.shutdown();
}
}
}The names line up almost too neatly —
submit is submit, and future.result() is future.get(). Two Java details to keep in mind. A pool must be shut down or its threads keep the JVM alive after main returns, which is what the finally block is for; since Java 19 ExecutorService is AutoCloseable, so try (var pool = ...) does it for you the way Python's with does. And an exception inside the task does not escape at submit — it is stored and rethrown, wrapped in an ExecutionException, when you call get(), so a future whose result nobody asks for can swallow a failure entirely.Virtual threads vs. asyncio
Both columns run a thousand concurrent tasks without a thousand operating-system threads. Python does it by making the code visibly asynchronous —
async, await, an event loop you start explicitly, and a parallel universe of libraries that must also be async. Java 21's virtual threads do it without changing the code at all: this is ordinary blocking code, and the JVM unmounts the thread from its carrier whenever it blocks.import asyncio
async def task(number):
await asyncio.sleep(0)
return number
async def main():
results = await asyncio.gather(*(task(number) for number in range(1000)))
print(len(results), "coroutines completed")
asyncio.run(main())import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
class Main {
public static void main(String[] args) {
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int index = 0; index < 1000; index++) {
int taskNumber = index;
pool.submit(() -> taskNumber);
}
}
System.out.println("1000 tasks completed");
}
}That is the whole pitch, and it is aimed squarely at the problem Python calls "function colour". A Java library written in 2005 against blocking sockets becomes scalable on virtual threads with no rewrite, because the blocking call itself was taught to yield; in Python, one synchronous call anywhere in an async stack blocks the entire event loop, so the ecosystem had to be duplicated. The cost is that virtual threads help only with blocking, not with CPU work, and that they interact badly with
synchronized in older JDKs. The Java column cannot run on this page: starting even one virtual thread needs two operating-system threads — a carrier and an unblocker — and the browser's sandbox allows only one beyond main.Files & I/O
Writing a file and reading it back
Java's modern file API is
java.nio.file, and it lines up closely with pathlib: a Path value that names a location, plus one-call helpers for the whole-file cases. Both examples write, read back and clean up after themselves, so they leave nothing behind.from pathlib import Path
path = Path("python-java-notes.txt")
path.write_text("first line\nsecond line\n")
print(path.read_text().strip())
print(len(path.read_text().splitlines()))
path.unlink()import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
class Main {
public static void main(String[] args) throws IOException {
Path path = Path.of("python-java-notes.txt");
Files.writeString(path, "first line\nsecond line\n");
System.out.println(Files.readString(path).strip());
System.out.println(Files.readAllLines(path).size());
Files.delete(path);
}
}The older API you will still meet —
File, FileReader, BufferedReader, a loop and a close() — predates this one and is worth avoiding in new code. Note that readString and readAllLines pull the whole file into memory, exactly as read_text does; the streaming equivalent of iterating a Python file handle is Files.lines(path), which returns a stream and should be closed with try-with-resources. Every one of these methods throws the checked IOException, which is why main declares throws IOException — the most common way a small program meets the checked-exception rule.Standard output and standard error
Two streams in both languages, reached in slightly different ways: Python routes everything through
print with a file= argument, while Java exposes the two streams as separate objects, System.out and System.err, each with its own println.import sys
print("this goes to stdout")
print("this goes to stderr", file=sys.stderr)
sys.stdout.write("no newline added")
sys.stdout.write("\n")class Main {
public static void main(String[] args) {
System.out.println("this goes to stdout");
System.err.println("this goes to stderr");
System.out.print("no newline added");
System.out.print("\n");
}
}The third and fourth lines show the pair you actually need day to day:
println appends a newline, print does not, matching print() and sys.stdout.write(). There is also System.out.printf for format strings. One behavioural difference to know when your log lines come out interleaved oddly: System.err is unbuffered while System.out is line-buffered, the same asymmetry Python has, and System.out.flush() is the fix. In a real application neither stream is used directly — logging goes through SLF4J or java.util.logging, in the way Python code uses the logging module rather than print. One thing to know about this page in particular: the Run button below the Java column reports standard output only, so the System.err line runs but does not appear there, while the Python cell — whose runner folds both streams into one buffer — shows all three. Run the two columns in a terminal and redirect one stream to see the real difference.Reading standard input
Python's
input() prints a prompt and returns one line. Java wraps the input stream in a Scanner, which does the same job and adds typed reads — nextInt(), nextDouble() — that parse as they read.import sys
name = input("Name: ")
print(f"Hello, {name}!")
for line in sys.stdin:
print("echo:", line.rstrip())import java.util.Scanner;
class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("Name: ");
String name = scanner.nextLine();
System.out.println("Hello, " + name + "!");
while (scanner.hasNextLine()) {
System.out.println("echo: " + scanner.nextLine());
}
}
}The trap that catches everyone once is mixing them:
nextInt() consumes the number but leaves the newline behind, so the next nextLine() returns an empty string. Reading everything as lines and parsing yourself avoids it entirely. For large input, BufferedReader wrapped around an InputStreamReader is substantially faster than Scanner, which matters in competitive programming and almost nowhere else. Neither column can run on this page, because the sandboxes that execute these examples supply no standard input.Packages, Build & Tooling
import, and the parts that need no import
A Java
import does not load anything — it is purely a naming convenience that lets you write LocalDate instead of java.time.LocalDate. Classes are found and loaded by the runtime when first used, whether or not you imported them.import math
from collections import Counter
from datetime import date
print(math.hypot(3, 4))
print(Counter("banana")["a"])
print(date(2026, 8, 18).year)
print(len("no import needed"))import java.time.LocalDate;
import java.util.List;
class Main {
public static void main(String[] args) {
System.out.println(Math.hypot(3, 4));
System.out.println(List.of("b", "a", "n", "a", "n", "a")
.stream().filter(letter -> letter.equals("a")).count());
System.out.println(LocalDate.of(2026, 8, 18).getYear());
System.out.println("no import needed".length());
}
}That is why there is no equivalent of Python's import-time side effects, no circular-import problem, and no cost to an unused import beyond clutter. Everything in the
java.lang package — String, Math, Integer, System, Thread — is imported implicitly, which is why Math.floor above needs no line at the top. Java imports a single class per statement rather than a module: import java.util.List;, not import java.util. The wildcard form import java.util.*; exists and most style guides discourage it, since it makes the origin of a name invisible and can break when a library adds a class whose name collides.Packages are directories, and they are mandatory
Python maps modules to files loosely: the directory name matters, the file name matters, and nothing inside the file has to agree with either. Java ties three things together — the package declaration, the directory path, and the name of every public class — and the compiler checks all three.
# Any directory of .py files is importable; __init__.py is optional
# and the directory name is the only thing that has to match.
#
# analytics/
# __init__.py
# reports.py <- defines summarize()
from analytics.reports import summarize
# A file's own name is not written anywhere inside it, and a
# module can define as many top-level names as it likes.// The package declaration must be the first line, and the directory
// path must match it exactly:
//
// com/example/analytics/Reports.java
//
// package com.example.analytics;
//
// public class Reports {
// public static String summarize() { return "..."; }
// }
//
// import com.example.analytics.Reports;
//
// A public class must live in a file of the same name, so one public
// type per file is not a convention — it is enforced by javac.The reverse-domain convention (
com.example.analytics) exists because package names are the only namespace protecting one library's Reports from another's. The consequences for how code is laid out are large: one public type per file means a Java codebase has many more files than the equivalent Python one, and a class cannot be moved between packages without editing its first line and every import that names it — which is why every IDE has a dedicated refactoring for it. Both columns here are illustrative; the layout is the point and there is nothing to execute.pip and a virtual environment vs. Maven or Gradle
This is the workflow difference you will feel every day. Python installs packages into an interpreter, so the interpreter has to be isolated per project — hence virtual environments, and hence the whole class of problem where the wrong one is active. Java resolves dependencies per build, from a declaration in the project file, into a cache shared by every project on the machine.
# Create an environment, install into it, record what you installed.
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install requests
$ pip freeze > requirements.txt
# The environment is a directory of installed packages, and the
# interpreter finds them because it is the one inside .venv.# There is no environment to create or activate. Dependencies are
# declared in the build file and downloaded into a shared local cache.
# pom.xml (Maven)
# <dependency>
# <groupId>com.squareup.okhttp3</groupId>
# <artifactId>okhttp</artifactId>
# <version>4.12.0</version>
# </dependency>
# build.gradle.kts (Gradle)
# dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") }
$ mvn package # or: ./gradlew buildSo there is nothing to activate and no way to be in the wrong environment, and two projects on one machine can use different versions of the same library without noticing each other. The coordinates are always three parts — group, artifact, version — and they are globally unique, so
pip install requests's ambiguity about which package that is does not arise. What you gain in reproducibility you pay for in ceremony: a Maven pom.xml is XML and long, Gradle trades that for a build script that is a real program, and neither is as quick to start as a pip install. Both columns describe shell and configuration, so there is nothing to run.Shipping the program, and the missing REPL
A Python program is shipped as source and run by whatever interpreter the user has. A Java program is shipped as a
.jar — a zip file of compiled classes and a manifest naming the entry point — and runs on any JVM of the right version or newer, on any operating system, without recompilation.# Ship the source. The user needs a compatible interpreter.
$ python -m build # or just: hand over the .py files
$ pip install dist/*.whl
$ my-tool --help
# And the habit this page cannot show you in a code block:
$ python
>>> import mymodule
>>> mymodule.summarize(data) # poke at it until it makes sense# Ship one archive of compiled classes. The user needs a JVM.
$ mvn package # -> target/my-tool-1.0.jar
$ java -jar target/my-tool-1.0.jar --help
# Or bundle a trimmed runtime so the user needs nothing:
$ jlink --add-modules java.base --output runtime
# There is a REPL, and almost nobody develops in it:
$ jshell
jshell> "hello".toUpperCase()
$1 ==> "HELLO"Backward compatibility is the JVM's strongest selling point and the reason so much old Java is still in production: a jar compiled in 2010 will very likely still run today, which is not a claim the Python 2-to-3 transition can make. The habit you will miss is the one in the last block.
jshell genuinely works, but the Java workflow is edit-compile-run with the IDE compiling continuously in the background, and the exploratory loop moves into unit tests and the debugger instead. Expect the feedback cycle to be seconds rather than instant, and expect the compiler to catch things you would previously have found by poking. Both columns are shell transcripts, so there is nothing to execute here.