Python for Java Developers
This article is for people who already know Java and want to write Python. Almost everywhere the Java code comes first, with the same thing in Python right below it, so most of the explanations are in the code comments.
The order is this: syntax first, then the core types and their methods, then what works differently from Java: variables, decorators, generators, threads and asyncio. At the end, all of this becomes a microservice. The article covers only the language and its standard library; FastAPI, Django and Flask will get separate articles.
A few words about Python itself. Guido van Rossum created it, and the first version came out in 1991, so it is older than Java. Version 3.0 broke compatibility with version 2 in 2008, and only the third branch is alive now. The examples were run on Python 3.14 and 3.15 and on Java 26, except those needing third-party Java libraries. Where a feature is recent, its version is given.
Pros: far less code than in Java, a fast program start and a huge ecosystem, especially for data and machine learning.
Cons: computation is noticeably slower than on the JVM, threads usually do not speed it up, and without a separate tool type errors show up only at run time.
Syntax: Java and Python side by side
What a program looks like
In Java all code lives inside a class, and a run starts at main. In Python a file simply runs from top to bottom.
public class Main { public static void main(String[] args) { System.out.println("Hello"); }}# The whole file: no class, no main, no semicolon.print("Hello") # HelloIf a file can be both run and imported from another file, the startup code goes behind a check. This is the closest thing to main in Java.
def main(): print("Hello") # Hello
# __name__ is the name of the current module. If the file was run# directly, it holds "__main__". If it was imported, the file name,# so main() is not called.if __name__ == "__main__": main()How blocks and comments are written
Python uses indentation instead of curly braces. This is syntax, not style: a wrong indent stops the program from starting.
int price = 150;
// A one-line commentif (price > 100) { System.out.println("expensive"); // expensive} else { System.out.println("cheap");}
/* Python has no multi-line comment */price = 150
# A one-line comment# No parentheses around the condition, a colon at the end,# and the body is indented by 4 spaces.if price > 100: print("expensive") # expensiveelse: print("cheap")A long expression is wrapped inside parentheses. Instead of Javadoc, a documentation string goes right under the declaration; it is called a docstring.
/** * Computes the total with tax. */int total(int price, int tax) { return price + tax;}
System.out.println(total(100, 20)); // 120def total(price, tax): """Computes the total with tax."""
# A line break is allowed inside parentheses. return (price + tax)
print(total(100, 20)) # 120
# The docstring is available at run time too.print(total.__doc__) # Computes the total with tax.The stub for an empty block in Java is {}. Python cannot express an empty block, so it has the word pass, which does nothing.
void todo() {}
class Empty {}def todo(): pass
class Empty: passHow to declare a variable
Python has no declaration. A variable appears on first assignment, and the type belongs not to it but to the value it holds. This is called dynamic typing. What a variable really is, is covered below.
int age = 30; # Javaage = 30 # Python
var name = "Ann"; # Javaname = "Ann" # Python
# There is no final. UPPER_CASE means "do not change", but only by convention.final int MAX_SIZE = 100; # JavaMAX_SIZE = 100 # Python
# In Java this is a compile error; in Python age now holds a string.age = "thirty" # Python
# Assign several values at once.int a = 1, b = 2; # Javaa, b = 1, 2 # Python
# Swap: Java needs a temporary variable.int tmp = a; a = b; b = tmp; # Javaa, b = b, a # Python
a + " " + b # Java: 2 1f"{a} {b}" # Python: 2 1If you do want to state the type, there is a type hint. Python does not check it, but IDEs and a separate tool, mypy, do.
int count = 0; # Javacount: int = 0 # Python
String title = "Report"; # Javatitle: str = "Report" # Python
# Python does not object: a hint is only a note for tools.count = "many" # PythonNaming conventions
The rules are in a document called PEP 8, the counterpart of the Java Code Conventions. The main difference is that words in variable and function names are separated by underscores.
| What | Java | Python |
|---|---|---|
| Variable | userName | user_name |
| Method, function | findUser() | find_user() |
| Class | OrderService | OrderService |
| Constant | MAX_SIZE | MAX_SIZE |
| Package | com.shop.orders | orders |
| File | OrderService.java | order_service.py |
| Private field | private int size | _size |
| Indent | 4 spaces | 4 spaces |
What the basic types are
Python has no primitives; everything is an object, even a number. So there is no int and Integer pair. There are no different integer types either: a single int holds a number of any size.
| Java | Python | Example |
|---|---|---|
byte, short, int, long, BigInteger | int | 42 |
float, double | float | 3.14 |
boolean | bool | True, False |
char, String | str | "a", "Ann" |
null | None | None |
byte[] | bytes | b"abc" |
Object | object | object() |
BigDecimal | decimal.Decimal | Decimal("9.99") |
Note that True, False and None start with a capital letter. There is no separate character type: a character is a string of length 1.
int number = 42; # Javanumber = 42 # Python
Object text = "Ann"; # Javatext = "Ann" # Python
# Get the class of a value. In Python a number has one too.text.getClass().getSimpleName() # Java: Stringtype(text).__name__ # Python: strtype(number).__name__ # Python: int
# Check the type.text instanceof String # Java: trueisinstance(text, str) # Python: True
# A number has methods; only a literal needs the parentheses.Integer.bitCount(255) # Java: 8(255).bit_count() # Python: 8How to convert between types
There is no cast in parentheses. You call the target type's class as a function instead.
(int) 3.7 # Java: 3int(3.7) # Python: 3
(double) 3 # Java: 3.0float(3) # Python: 3.0
Integer.parseInt("42") # Java: 42int("42") # Python: 42
Double.parseDouble("3.5") # Java: 3.5float("3.5") # Python: 3.5
String.valueOf(42) # Java: 42str(42) # Python: 42
# A character and its code.(int) 'A' # Java: 65ord("A") # Python: 65
(char) 65 # Java: Achr(65) # Python: A
# A trap: in Python a non-empty string is always true, even "false".Boolean.parseBoolean("false") # Java: falsebool("false") # Python: TrueA string and a number do not join on their own. Where Java silently calls String.valueOf, Python raises an error.
int age = 30; # Javaage = 30 # Python
"Age: " + age # Java: Age: 30
# In Python "Age: " + age raises TypeError. f-strings are covered below."Age: " + str(age) # Python: Age: 30f"Age: {age}" # Python: Age: 30What the arithmetic operators are
The most common mistake here is division. In Python / always gives a fractional number, and integer division is written //. There are no ++ and -- operators.
7 / 2 # Java: 37 // 2 # Python: 3
7.0 / 2 # Java: 3.57 / 2 # Python: 3.5
7 % 2 # Java: 17 % 2 # Python: 1
# Python has an operator for powers, and the result stays whole.Math.pow(2, 10) # Java: 1024.02 ** 10 # Python: 1024
int counter = 0; # Javacounter = 0 # Python
# counter++ does not work in Python.counter++; # Javacounter += 1 # PythonHow to compare values
Here Python is the other way around. == compares contents, like equals in Java. And is checks that it is the same object, like == on references in Java.
var first = new ArrayList<>(List.of(1, 2)); # Javafirst = [1, 2] # Python
var second = new ArrayList<>(List.of(1, 2)); # Javasecond = [1, 2] # Python
# Whether the contents are equal.first.equals(second) # Java: truefirst == second # Python: True
# Whether it is the same object.first == second # Java: falsefirst is second # Python: False
# An empty value. None is checked only with is.String name = null; # Javaname = None # Python
name == null # Java: truename is None # Python: True
name != null # Java: falsename is not None # Python: False
# Comparisons can be chained, as in maths.int age = 30; # Javaage = 30 # Python
age >= 18 && age < 65 # Java: true18 <= age < 65 # Python: TrueHow to write logical conditions
Words replace &&, || and !. Also, a condition can be any value, not just a boolean: empty collections, an empty string, zero and None count as false.
boolean active = true; # Javaactive = True # Python
boolean admin = false; # Javaadmin = False # Python
active && !admin # Java: trueactive and not admin # Python: True
active || admin # Java: trueactive or admin # Python: Trueactive = Trueadmin = Falseprint(active and not admin) # Trueprint(active or admin) # True
# The usual way to check that a collection is empty.items = []if not items: print("no items") # no items
# One condition covers both None and empty.name = Noneif name: print(name)and and or return not True or False but one of their operands. The short form of a default value relies on this.
String name = ""; # Javaname = "" # Python
# or returns the first true value, here "guest".name == null || name.isEmpty() ? "guest" : name # Java: guestname or "guest" # Python: guest
# Careful: 0 is also false, so a real value gets lost.0 or 100 # Python: 100How to write a condition and a ternary
else if shortens to elif. The ternary reads like a normal phrase: the value first, then the condition.
int score = 75;String grade;
if (score >= 90) { grade = "A";} else if (score >= 60) { grade = "B";} else { grade = "C";}System.out.println(grade); // B
String result = score >= 60 ? "pass" : "fail";System.out.println(result); // passscore = 75
if score >= 90: grade = "A"elif score >= 60: grade = "B"else: grade = "C"print(grade) # B
# value_if_true if condition else value_if_falseresult = "pass" if score >= 60 else "fail"print(result) # passWhat replaces switch
Python has match instead, since version 3.10. Like the new switch in Java, it compares not only with constants but also with patterns. There is no fall-through between branches, so no break is needed.
String httpName(int code) { return switch (code) { case 200 -> "OK"; case 301, 302 -> "Redirect"; case 404 -> "Not Found"; default -> "Unknown"; };}
System.out.println(httpName(302)); // RedirectSystem.out.println(httpName(500)); // Unknowndef http_name(code): match code: case 200: return "OK"
# The vertical bar means "or". case 301 | 302: return "Redirect" case 404: return "Not Found"
# The underscore matches anything; this is default. case _: return "Unknown"
print(http_name(302)) # Redirectprint(http_name(500)) # UnknownMatching by type works as with pattern matching in Java 21, except that a pattern can also take the value apart. Examples with dataclass and dictionaries come later.
String describe(Object value) { return switch (value) { case Integer n when n > 100 -> "big number"; case Integer n -> "number " + n; case String s -> "text " + s; case List<?> l -> "list of " + l.size(); default -> "other"; };}
System.out.println(describe(500)); // big numberSystem.out.println(describe(List.of(1, 2))); // list of 2def describe(value): match value:
# int(n) checks the type and binds the value to n; if is when. case int(n) if n > 100: return "big number" case int(n): return f"number {n}" case str(s): return f"text {s}" case list(items): return f"list of {len(items)}" case _: return "other"
print(describe(500)) # big numberprint(describe([1, 2])) # list of 2
# And here a pattern takes a value apart: a list of exactly two items.match ["move", 10]: case ["move", distance]: print(distance) # 10How to write a for loop
Python has only one kind of for, the counterpart of for (x : items) in Java. Where Java needs a counter, you use range, a function that produces numbers in order.
var names = List.of("Ann", "Bob");
for (String name : names) { System.out.print(name + " "); // Ann Bob}
for (int i = 0; i < 3; i++) { System.out.print(i + " "); // 0 1 2}
for (int i = 10; i > 0; i -= 3) { System.out.print(i + " "); // 10 7 4 1}names = ["Ann", "Bob"]
for name in names: print(name) # Ann Bob
# range(3) gives 0, 1, 2. The right bound is excluded.for i in range(3): print(i) # 0 1 2
# range(from, to, step)for i in range(10, 0, -3): print(i) # 10 7 4 1You need an index less often than it seems. The common cases have ready-made functions, and for i in range(len(names)) is considered bad style.
var names = List.of("Ann", "Bob");var ages = List.of(30, 25);
// A value and its indexfor (int i = 0; i < names.size(); i++) { System.out.print(i + ":" + names.get(i) + " "); // 0:Ann 1:Bob}
// Two lists in parallelfor (int i = 0; i < names.size(); i++) { System.out.print(names.get(i) + "=" + ages.get(i) + " "); // Ann=30 Bob=25}
// In reverse orderfor (int i = names.size() - 1; i >= 0; i--) { System.out.print(names.get(i) + " "); // Bob Ann}names = ["Ann", "Bob"]ages = [30, 25]
# enumerate yields (index, value) pairs.for i, name in enumerate(names): print(f"{i}:{name}") # 0:Ann 1:Bob
# zip walks two lists at once and yields pairs.for name, age in zip(names, ages): print(f"{name}={age}") # Ann=30 Bob=25
for name in reversed(names): print(name) # Bob AnnHow to write while and leave a loop
while, break and continue work as in Java. There is no do-while loop and no labels for leaving nested loops.
int n = 0;while (n < 10) { n++; if (n % 2 == 0) { continue; } if (n > 5) { break; } System.out.print(n + " "); // 1 3 5}
int tries = 0;do { tries++;} while (tries < 3);System.out.println(tries); // 3n = 0while n < 10: n += 1 if n % 2 == 0: continue if n > 5: break print(n) # 1 3 5
# do-while is written as an endless loop with a check at the end.tries = 0while True: tries += 1 if tries >= 3: breakprint(tries) # 3A loop can have an else branch, which Java lacks. It runs if the loop reached the end without leaving through break. It usually replaces a found flag.
var ids = List.of(1, 2, 3);boolean found = false;
for (int id : ids) { if (id == 7) { found = true; break; }}if (!found) { System.out.println("not found"); // not found}ids = [1, 2, 3]
for id_ in ids: if id_ == 7: breakelse:
# We get here only if break did not fire. print("not found") # not foundThe := operator assigns a value right inside an expression. In Java an assignment is already an expression; Python needs a separate operator for it.
var names = List.of("Ann", "Bob");int count;
if ((count = names.size()) > 1) { System.out.println(count); // 2}names = ["Ann", "Bob"]
# Counted and checked at once. A plain = here would be a SyntaxError.if (count := len(names)) > 1: print(count) # 2How to declare a function
In Java a method always lives in a class. In Python a function can live right in a file, so classes of static methods are not needed here. Parameter and result types can be given as hints, but that is optional.
int add(int a, int b) { return a + b;}
void greet(String name) { System.out.println("Hi, " + name); // Hi, Ann}
System.out.println(add(2, 3)); // 5greet("Ann");def add(a, b): return a + b
# The same with type hints. None here means void.def greet(name: str) -> None: print(f"Hi, {name}") # Hi, Ann
print(add(2, 3)) # 5greet("Ann")
# A function with no return actually returns None.print(greet("Ann")) # NoneWhat replaces method overloading
Python has no overloading: a second def with the same name simply replaces the first. Default parameter values are used instead. Arguments can be named at the call, which also replaces the Builder pattern.
String connect(String host, int port, boolean secure) { return host + ":" + port + " " + secure;}
String connect(String host, int port) { return connect(host, port, false);}
String connect(String host) { return connect(host, 80);}
System.out.println(connect("a")); // a:80 falseSystem.out.println(connect("a", 443, true)); // a:443 truedef connect(host, port=80, secure=False): return f"{host}:{port} {secure}"
print(connect("a")) # a:80 False
# By position, as in Java.print(connect("a", 443, True)) # a:443 True
# By name: port is skipped, and it is clear what True means.print(connect("a", secure=True)) # a:80 TrueHow to pass any number of arguments
int... numbers from Java is written *numbers. Python also has what Java lacks: **options collects named arguments into a dictionary.
int sum(int... numbers) { return Arrays.stream(numbers).sum();}
System.out.println(sum(1, 2, 3)); // 6
int[] values = {1, 2, 3};System.out.println(sum(values)); // 6# numbers receives a tuple (1, 2, 3). A tuple is an immutable list.def total(*numbers): return sum(numbers)
print(total(1, 2, 3)) # 6
# At the call, the star does the opposite: it spreads a list into arguments.values = [1, 2, 3]print(total(*values)) # 6
# options receives a dictionary {"port": 80, "secure": True}.def connect(host, **options): print(host, options) # a {'port': 80, 'secure': True}
connect("a", port=80, secure=True)A star in the parameter list can forbid passing by position. Everything after it can be passed only by name. This is done for flags, to avoid calls such as send(msg, True, False).
def send(message, *, retry=False): return f"{message} {retry}"
print(send("hi", retry=True)) # hi True
# TypeError: this way of passing is forbidden.# send("hi", True)How to return several values
In Java this takes a separate class or a record. In Python a function simply returns several values separated by commas, and they get packed into a tuple.
record MinMax(int min, int max) {}
MinMax minMax(List<Integer> items) { return new MinMax(Collections.min(items), Collections.max(items));}
var result = minMax(List.of(3, 1, 2));System.out.println(result.min() + " " + result.max()); // 1 3def min_max(items): return min(items), max(items)
# The tuple is unpacked straight into two variables.low, high = min_max([3, 1, 2])print(low, high) # 1 3
# An unneeded value traditionally goes into a variable named _ .low, _ = min_max([3, 1, 2])
# The star takes "everything else".head, *rest = [1, 2, 3]print(head, rest) # 1 [2, 3]How to write a lambda and a method reference
A function in Python is an ordinary object: it can be stored in a variable and passed to another function. So functional interfaces such as Function and Comparator are not needed, and instead of .apply() you just call the function. A lambda can hold only one expression.
Function<Integer, Integer> twice = x -> x * 2; # Javatwice = lambda x: x * 2 # Python
twice.apply(4) # Java: 8twice(4) # Python: 8
# A method reference is just the function's name without parentheses.Function<String, Integer> size = String::length; # Javasize = len # Python
size.apply("Ann") # Java: 3size("Ann") # Python: 3
# Several parameters.BinaryOperator<Integer> plus = (a, b) -> a + b; # Javaplus = lambda a, b: a + b # Python
plus.apply(2, 3) # Java: 5plus(2, 3) # Python: 5The most common use of a lambda is a sort key. Instead of a Comparator that compares two objects, Python takes a function that returns, for one object, what to sort by.
var names = new ArrayList<>(List.of("Bob", "Al", "Cindy")); # Javanames = ["Bob", "Al", "Cindy"] # Python
names.sort(Comparator.comparingInt(String::length)); # Javanames.sort(key=len) # Python
names # Java: [Al, Bob, Cindy]names # Python: ['Al', 'Bob', 'Cindy']
names.sort(Comparator.comparing(s -> s.charAt(s.length() - 1))); # Javanames.sort(key=lambda s: s[-1]) # Python
names # Java: [Bob, Al, Cindy]names # Python: ['Bob', 'Al', 'Cindy']How to declare a class
The constructor is called __init__. Fields are not declared in advance: they appear when the constructor assigns to them. There is no new; an object is created by calling the class. Every method's first parameter is self. It is the same as this in Java, only written explicitly.
class User { private String name; private int age;
User(String name, int age) { this.name = name; this.age = age; }
String greet() { return "Hi, " + this.name; }}
User user = new User("Ann", 30);System.out.println(user.greet()); // Hi, Annclass User: def __init__(self, name, age): self.name = name self.age = age
# You do not pass self at the call: Python supplies it. def greet(self): return f"Hi, {self.name}"
user = User("Ann", 30)print(user.greet()) # Hi, Ann
# Exactly the same, with self passed by hand.print(User.greet(user)) # Hi, AnnA method with double underscores on both sides, such as __init__, is called a dunder method (short for double underscore). You do not call it directly: Python itself calls it when it needs to create, print or compare an object. There will be many of these below.
How to make static fields and methods
A variable declared in the class body belongs to the class, like a static field in Java. Methods have two decorators. @staticmethod is a plain static method. @classmethod receives the class itself as its first parameter and is the usual way to write factory methods. A decorator is a mark above a function, similar to a Java annotation. How it works is explained below.
class Money { static final String CURRENCY = "USD"; int cents;
Money(int cents) { this.cents = cents; }
static boolean isValid(int cents) { return cents >= 0; }
static Money ofDollars(int dollars) { return new Money(dollars * 100); }}
System.out.println(Money.CURRENCY); // USDSystem.out.println(Money.isValid(-1)); // falseSystem.out.println(Money.ofDollars(2).cents); // 200class Money: CURRENCY = "USD"
def __init__(self, cents): self.cents = cents
@staticmethod def is_valid(cents): return cents >= 0
# cls is the class Money itself. In a subclass it is the subclass. @classmethod def of_dollars(cls, dollars): return cls(dollars * 100)
print(Money.CURRENCY) # USDprint(Money.is_valid(-1)) # Falseprint(Money.of_dollars(2).cents) # 200How to make a field private and what replaces getters
Python has no access modifiers. A single leading underscore means "this is internal, do not touch it from outside", but nothing forbids it; this is a convention.
| Java | Python | What it means |
|---|---|---|
public int size | size | public |
protected int size | _size | internal, by convention |
private int size | _size | the same; there is no separate level |
Getters and setters are not written in advance; the field is simply public. If a check is needed later, the field is replaced with @property: from outside the code stays account.balance, but a method runs.
class Account { private int balance;
int getBalance() { return balance; }
void setBalance(int balance) { if (balance < 0) { throw new IllegalArgumentException("negative"); } this.balance = balance; }}
var account = new Account();account.setBalance(100);System.out.println(account.getBalance()); // 100class Account: def __init__(self): self._balance = 0
# The getter: read as a field, account.balance, no parentheses. @property def balance(self): return self._balance
# The setter: fires on account.balance = 100. @balance.setter def balance(self, value): if value < 0: raise ValueError("negative") self._balance = value
account = Account()account.balance = 100print(account.balance) # 100What replaces toString, equals and hashCode
These are dunder methods too. There is a difference from Java: there are two string forms. __str__ is for users and is what print uses. __repr__ is for developers, and it is what debuggers and containers show. If only __repr__ is declared, it also serves as __str__, so that is the one usually written.
class Point { int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override public String toString() { return "Point(" + x + ", " + y + ")"; }
@Override public boolean equals(Object other) { return other instanceof Point p && p.x == x && p.y == y; }
@Override public int hashCode() { return Objects.hash(x, y); }}
System.out.println(new Point(1, 2)); // Point(1, 2)System.out.println(new Point(1, 2).equals(new Point(1, 2))); // trueclass Point: def __init__(self, x, y): self.x = x self.y = y
def __repr__(self): return f"Point({self.x}, {self.y})"
# NotImplemented means "I cannot compare with this type". def __eq__(self, other): if not isinstance(other, Point): return NotImplemented return (self.x, self.y) == (other.x, other.y)
def __hash__(self): return hash((self.x, self.y))
print(Point(1, 2)) # Point(1, 2)print(Point(1, 2) == Point(1, 2)) # TrueWhat replaces record
Hardly anyone writes the previous class by hand: there is dataclass for it. This decorator generates __init__, __repr__ and __eq__ from a list of fields. With frozen=True the object becomes immutable, like a record, and gets __hash__.
record Point(int x, int y) { public Point { if (x < 0) { throw new IllegalArgumentException("x"); } }
Point withX(int newX) { return new Point(newX, y); }}
var point = new Point(1, 2);System.out.println(point); // Point[x=1, y=2]System.out.println(point.x()); // 1System.out.println(point.withX(5)); // Point[x=5, y=2]from dataclasses import dataclass, field, replace
# Type hints are required here: the decorator finds the fields by them.@dataclass(frozen=True)class Point: x: int y: int = 0
# The record's compact constructor: runs after __init__. def __post_init__(self): if self.x < 0: raise ValueError("x")
point = Point(1, 2)print(point) # Point(x=1, y=2)print(point.x) # 1
# replace makes a copy with a changed field, like withX.print(replace(point, x=5)) # Point(x=5, y=2)
# point.x = 5 would raise FrozenInstanceError.
# A mutable default goes through field: each object gets its own list.@dataclassclass Cart: items: list = field(default_factory=list)
print(Cart()) # Cart(items=[])How to inherit from a class
The parent goes in parentheses. Calling the parent constructor is optional and does not happen by itself, so super().__init__() is easy to forget. All methods can be overridden, and there is no @Override. A class can have several parents; more on this below.
class Animal { String name;
Animal(String name) { this.name = name; }
String speak() { return name + " makes a sound"; }}
class Dog extends Animal { Dog(String name) { super(name); }
@Override String speak() { return super.speak() + ": woof"; }}
Animal pet = new Dog("Rex");System.out.println(pet.speak()); // Rex makes a sound: woofSystem.out.println(pet instanceof Animal); // trueclass Animal: def __init__(self, name): self.name = name
def speak(self): return f"{self.name} makes a sound"
class Dog(Animal): def __init__(self, name): super().__init__(name)
def speak(self): return f"{super().speak()}: woof"
pet = Dog("Rex")print(pet.speak()) # Rex makes a sound: woofprint(isinstance(pet, Animal)) # TrueWhat replaces an abstract class and an interface
An abstract class is declared through ABC. It cannot be instantiated, and a subclass must implement the abstract methods. The difference from Java is that the error appears not at compile time but when the object is created.
abstract class Shape { abstract double area();
String describe() { return "area " + area(); }}
class Square extends Shape { double side = 2;
@Override double area() { return side * side; }}
System.out.println(new Square().describe()); // area 4.0from abc import ABC, abstractmethod
class Shape(ABC): @abstractmethod def area(self): pass
def describe(self): return f"area {self.area()}"
class Square(Shape): side = 2.0
def area(self): return self.side * self.side
print(Square().describe()) # area 4.0
# Shape() would raise TypeError: an abstract class cannot be created.The closest thing to an interface is Protocol. It differs in one important way: a class does not write implements. Any class with the same methods fits. This is called duck typing: if an object can do what is needed, its type does not matter. It is mypy, not Python, that checks the fit.
interface Notifier { String send(String text);}
class Email implements Notifier { public String send(String text) { return "email: " + text; }}
String alert(Notifier notifier) { return notifier.send("down");}
System.out.println(alert(new Email())); // email: downfrom typing import Protocol
class Notifier(Protocol): def send(self, text: str) -> str: ...
# No implements, no inheritance: matching methods are enough.class Email: def send(self, text: str) -> str: return f"email: {text}"
def alert(notifier: Notifier) -> str: return notifier.send("down")
print(alert(Email())) # email: downHow to declare an enum
An enumeration is a class that inherits from Enum. As in Java, each element exists in one instance, so they can be compared with is. If the values are strings, StrEnum is handy: its elements are strings.
enum Status { NEW("new"), PAID("paid");
final String code;
Status(String code) { this.code = code; }}
System.out.println(Status.PAID.name()); // PAIDSystem.out.println(Status.PAID.code); // paidSystem.out.println(Status.valueOf("NEW").code); // newSystem.out.println(Status.values().length); // 2from enum import Enum, StrEnum
class Status(Enum): NEW = "new" PAID = "paid"
print(Status.PAID.name) # PAIDprint(Status.PAID.value) # paid
# Look up by name and by value.print(Status["NEW"].value) # newprint(Status("paid").name) # PAIDprint(len(Status)) # 2print(Status.PAID is Status("paid")) # True
# A StrEnum element equals a string, handy for JSON and databases.class Color(StrEnum): RED = "red"
print(Color.RED == "red") # TrueWhat replaces anonymous and nested classes
There are no anonymous classes, and usually no need for them, since you can pass a function. Nested classes exist, but they are like a static class in Java: they have no access to an outer object.
Runnable task = new Runnable() { @Override public void run() { System.out.println("run"); // run }};task.run();def run_task(task): task()
# Instead of an anonymous class, a function declared where it is needed.def task(): print("run") # run
run_task(task)How to write a generic
The type hint of a collection goes in square brackets. Since Python 3.12 your own generic classes are declared as in Java, only with different brackets. As in Java, nothing is left of the type parameter at run time. More in the section on type hints.
List<String> names = new ArrayList<>(); # Javanames: list[str] = [] # Python
Map<String, Integer> ages = new HashMap<>(); # Javaages: dict[str, int] = {} # Python
# A value or null.String nickname = null; # Javanickname: str | None = None # Pythonclass Box<T> { private final T value;
Box(T value) { this.value = value; }
T get() { return value; }}
<T extends Comparable<T>> T maxOf(T a, T b) { return a.compareTo(b) > 0 ? a : b;}
System.out.println(new Box<>("x").get()); // xSystem.out.println(maxOf(1, 2)); // 2class Box[T]: def __init__(self, value: T) -> None: self._value = value
def get(self) -> T: return self._value
# T: int | str is a bound, like T extends in Java.def max_of[T: int | str](a: T, b: T) -> T: return a if a > b else b
print(Box("x").get()) # xprint(max_of(1, 2)) # 2How to handle exceptions
catch is called except, and throw is called raise. There are no checked exceptions: the word throws does not exist, and nothing has to be caught.
int parse(String text) { try { return Integer.parseInt(text); } catch (NumberFormatException | NullPointerException e) { System.out.println("bad: " + text); // bad: abc return 0; } finally { System.out.println("done"); // done }}
parse("abc");def parse(text): try: return int(text)
# Several types are listed in parentheses. except (ValueError, TypeError) as e: print(f"bad: {text}") # bad: abc return 0 finally: print("done") # done
parse("abc")Your own exception is declared as a class. The cause, as in Java's cause, is given with from. There is also an else branch: it runs only if try raised no error.
class OrderException extends RuntimeException { OrderException(String message, Throwable cause) { super(message, cause); }}
try { try { Integer.parseInt("abc"); } catch (NumberFormatException e) { throw new OrderException("bad id", e); }} catch (OrderException e) { System.out.println(e.getMessage()); // bad id System.out.println(e.getCause() instanceof NumberFormatException); // true}class OrderError(Exception): pass
try: try: int("abc") except ValueError as e: raise OrderError("bad id") from eexcept OrderError as e: print(e) # bad id
# __cause__ is the same as getCause(). print(isinstance(e.__cause__, ValueError)) # True
try: value = int("42")except ValueError: print("bad")else:
# Only if there was no error. print(value) # 42The standard exceptions have other names, but the mapping is simple:
| Java | Python |
|---|---|
Throwable | BaseException |
Exception | Exception |
IllegalArgumentException, NumberFormatException | ValueError |
ClassCastException | TypeError |
NullPointerException | AttributeError or TypeError |
IndexOutOfBoundsException | IndexError |
missing key in a Map | KeyError |
IOException | OSError |
FileNotFoundException | FileNotFoundError |
ArithmeticException | ZeroDivisionError |
UnsupportedOperationException | NotImplementedError |
StackOverflowError | RecursionError |
TimeoutException | TimeoutError |
What replaces try-with-resources
Python has the with statement instead. An object for it is called a context manager. Instead of close from AutoCloseable it has two methods: __enter__ runs on the way in, and __exit__ on the way out, even after an error. The numbers show the order of execution.
class Resource implements AutoCloseable { Resource() { System.out.println("open"); // 1. open }
@Override public void close() { System.out.println("close"); // 3. close }}
try (var resource = new Resource()) { System.out.println("work"); // 2. work}class Resource: def __enter__(self): print("open") # 1. open
# What __enter__ returns goes into the variable after as. return self
def __exit__(self, exc_type, exc, tb): print("close") # 3. close
with Resource() as resource: print("work") # 2. workYou meet with most often when working with files:
Path path = Files.createTempFile("demo", ".txt");
try (var writer = Files.newBufferedWriter(path)) { writer.write("hello");}
System.out.println(Files.readString(path)); // helloimport tempfilefrom pathlib import Path
path = Path(tempfile.mkdtemp()) / "demo.txt"
# The file closes by itself. Always give the encoding, or the system one is used.with open(path, "w", encoding="utf-8") as file: file.write("hello")
print(path.read_text(encoding="utf-8")) # helloHow imports and packages work
Python has no package line: a file is itself a module, named after the file. A directory of modules is called a package. One file can hold any number of classes and functions, and a separate file for every class is not the custom here. You can import either a module or a single name from it.
# Importing a single name.import java.time.LocalDate; # Javafrom datetime import date # Python
# A static import.import static java.lang.Math.sqrt; # Javafrom math import sqrt # Python
# Importing everything. This is frowned upon in Python.import java.util.*; # Javafrom math import * # Python
# Python only: the whole module, then access through its name.import math # Pythonmath.floor(2.5) # Python: 2
# Python only: your own short name.import datetime as dt # Pythondt.date(2026, 1, 2).year # Python: 2026orders/ a package, like package orders├── __init__.py marks a package, usually empty├── domain.py the module orders.domain└── api/ ├── __init__.py └── routes.py the module orders.api.routes# From another file in the project.from orders.domain import Order
# From a sibling file in the same package: the dot means "this package".from .domain import Order
# One level up.from ..domain import OrderSyntax cheat sheet
| Java | Python |
|---|---|
int a = 1; | a = 1 |
final int MAX = 1; | MAX = 1 |
null | None |
true, false | True, False |
a.equals(b) | a == b |
a == b for references | a is b |
&&, ||, ! | and, or, not |
x > 0 ? a : b | a if x > 0 else b |
7 / 2 | 7 // 2 |
Math.pow(2, 3) | 2 ** 3 |
i++ | i += 1 |
else if | elif |
switch | match |
for (int i = 0; i < n; i++) | for i in range(n): |
for (T x : items) | for x in items: |
do { } while (x); | while True: plus break |
void f(int x) | def f(x): |
int... xs | *xs |
x -> x * 2 | lambda x: x * 2 |
String::length | len |
new User("Ann") | User("Ann") |
this | self |
class B extends A | class B(A): |
super.run() | super().run() |
static int f() | @staticmethod |
record Point(int x) | @dataclass(frozen=True) |
interface | Protocol |
abstract class | class A(ABC): |
List<String> | list[str] |
throw | raise |
catch (E e) | except E as e: |
try (var r = ...) | with ... as r: |
toString() | __repr__, __str__ |
equals(), hashCode() | __eq__, __hash__ |
System.out.println(x) | print(x) |
Numbers: core operations
How integers differ from Java
int in Python does not overflow; it grows as long as there is memory. Division with a minus sign works differently too: Java drops the fractional part, while Python rounds down.
Integer.MAX_VALUE + 1 # Java: -21474836482147483647 + 1 # Python: 2147483648
BigInteger.TWO.pow(70) # Java: 11805916207174113034242 ** 70 # Python: 1180591620717411303424
# Division with a minus: the results differ.-7 / 2 # Java: -3-7 // 2 # Python: -4
-7 % 3 # Java: -1-7 % 3 # Python: 2
# Underscores in numbers, binary and hex literals are as in Java.1_000_000 # Java: 10000001_000_000 # Python: 1000000
0xFF # Java: 2550xFF # Python: 255
0b101 # Java: 50b101 # Python: 5
# Converting to another base. Python adds a prefix.Integer.toBinaryString(10) # Java: 1010bin(10) # Python: 0b1010
Integer.toHexString(255) # Java: ffhex(255) # Python: 0xff
Integer.parseInt("ff", 16) # Java: 255int("ff", 16) # Python: 255
# Bit operations are the same, except >>>: a number has no fixed length.5 & 3 # Java: 15 & 3 # Python: 1
1 << 4 # Java: 161 << 4 # Python: 16Where to find Math
The most common functions are built into the language and need no import; the rest live in the math module.
import math # Python
Math.abs(-5) # Java: 5abs(-5) # Python: 5
Math.max(1, 2) # Java: 2max(1, 2) # Python: 2
# min and max in Python take both several values and a list.Collections.min(List.of(3, 1)) # Java: 1min(3, 1) # Python: 1
# floor and ceil in Python return an integer, not a float.Math.floor(2.7) # Java: 2.0math.floor(2.7) # Python: 2
Math.ceil(2.1) # Java: 3.0math.ceil(2.1) # Python: 3
Math.sqrt(16) # Java: 4.0math.sqrt(16) # Python: 4.0
Math.PI # Java: 3.141592653589793math.pi # Python: 3.141592653589793
# A trap: round in Python rounds a half to the even number.Math.round(2.5) # Java: 3round(2.5) # Python: 2
Math.round(3.5) # Java: 4round(3.5) # Python: 4
# Round to two decimal places.Math.round(3.14159 * 100) / 100.0 # Java: 3.14round(3.14159, 2) # Python: 3.14
# Quotient and remainder at once.divmod(7, 2) # Python: (3, 1)
Double.isNaN(0.0 / 0) # Java: truemath.isnan(float("nan")) # Python: True
# In Java dividing a float by zero gives Infinity; in Python, ZeroDivisionError.1.0 / 0 # Java: Infinityfloat("inf") # Python: infHow to do money arithmetic
float in Python is the same double, with the same rounding errors. For money there is Decimal, the counterpart of BigDecimal. Create it from a string. The usual operators work, so add and multiply are not needed.
from decimal import Decimal, ROUND_HALF_UP # Python
0.1 + 0.2 # Java: 0.300000000000000040.1 + 0.2 # Python: 0.30000000000000004
new BigDecimal("0.1").add(new BigDecimal("0.2")) # Java: 0.3Decimal("0.1") + Decimal("0.2") # Python: 0.3
new BigDecimal("19.99").multiply(new BigDecimal("3")) # Java: 59.97Decimal("19.99") * 3 # Python: 59.97
# Round to cents.new BigDecimal("2.675").setScale(2, RoundingMode.HALF_UP) # Java: 2.68Decimal("2.675").quantize(Decimal("0.01"), ROUND_HALF_UP) # Python: 2.68
# Comparison: Java's equals counts the scale; Python's == only the value.new BigDecimal("1.0").equals(new BigDecimal("1.00")) # Java: falseDecimal("1.0") == Decimal("1.00") # Python: TrueHow to get a random number
Note that randint includes both bounds. The random module is not suitable for passwords and tokens; there is secrets for those, like SecureRandom in Java.
import random # Pythonimport secrets # Python
# A die: from 1 to 6.int dice = ThreadLocalRandom.current().nextInt(1, 7); # Javadice = random.randint(1, 6) # Python
# A float from 0 to 1.double share = Math.random(); # Javashare = random.random() # Python
# A random element and a shuffle.var cards = new ArrayList<>(List.of(1, 2, 3)); # Javacards = [1, 2, 3] # Python
Collections.shuffle(cards); # Javarandom.shuffle(cards) # Python
int card = cards.get(new Random().nextInt(cards.size())); # Javacard = random.choice(cards) # Python
# A security token.var token = new java.security.SecureRandom().nextLong(); # Javatoken = secrets.token_hex(16) # PythonStrings: core methods
How to write a string
Like String in Java, str is immutable and holds Unicode text. Single and double quotes mean the same thing. Besides the plain string there are several kinds of literals: a letter before the quote changes the meaning.
| Literal | What it is | Java counterpart |
|---|---|---|
"text", 'text' | a plain string | "text" |
"""text""" | a multi-line string | """text""" |
f"{name}" | a string with substituted values | String.format |
r"\d+" | a raw string: the backslash does not escape | none |
b"abc" | bytes, not text | byte[] |
t"{name}" | a template: values apart from the text (3.14) | none |
The letters can be combined: rf"\d{count}" or rb"\x00".
"it's" # Java: it's'it\'s' # Python: it's
# Handy when the text holds quotes of the other kind."say \"hi\"" # Java: say "hi"'say "hi"' # Python: say "hi"
# A raw string saves backslashes in regular expressions."\\d+" # Java: \d+r"\d+" # Python: \d+
# A Unicode character by its code."é" # Java: é"é" # Python: éA multi-line string is written like a Java text block, but Python does not strip the indentation itself. textwrap.dedent does that.
String query = """ SELECT * FROM users """;System.out.print(query);from textwrap import dedent
# The backslash after the quotes removes the first line break.query = dedent("""\ SELECT * FROM users """)print(query)Adjacent literals hide a trap. Python joins them with no plus at all. This is handy for long strings, but if you forget a comma in a list, there is no error.
message = ( "first part, " "second part")print(message) # first part, second part
# A forgotten comma: the list has two items, not three.names = [ "Ann", "Bob" "Cid",]print(names) # ['Ann', 'BobCid']What methods a string has
These are the methods you need every day. A string is immutable, so all of them, as in Java, return a new string.
String s = "Hello World"; # Javas = "Hello World" # Python
# Length is a function, not a method.s.length() # Java: 11len(s) # Python: 11
s.charAt(0) # Java: Hs[0] # Python: H
# A negative index counts from the end.s.charAt(s.length() - 1) # Java: ds[-1] # Python: d
s.toUpperCase() # Java: HELLO WORLDs.upper() # Python: HELLO WORLD
s.toLowerCase() # Java: hello worlds.lower() # Python: hello world
s.contains("World") # Java: true"World" in s # Python: True
s.startsWith("He") # Java: trues.startswith("He") # Python: True
s.endsWith("ld") # Java: trues.endswith("ld") # Python: True
s.indexOf("o") # Java: 4s.find("o") # Python: 4
s.lastIndexOf("o") # Java: 7s.rfind("o") # Python: 7
# Not found: -1. The s.index() method raises ValueError here.s.indexOf("z") # Java: -1s.find("z") # Python: -1
s.replace("World", "Ann") # Java: Hello Anns.replace("World", "Ann") # Python: Hello Ann
# Replace only the first occurrence.s.replaceFirst("l", "L") # Java: HeLlo Worlds.replace("l", "L", 1) # Python: HeLlo World
# How many times it occurs. Java has no ready method.s.chars().filter(c -> c == 'l').count() # Java: 3s.count("l") # Python: 3# Whitespace at the edges." a ".strip() # Java: a" a ".strip() # Python: a
" a".stripLeading() # Java: a" a".lstrip() # Python: a
"a ".stripTrailing() # Java: a"a ".rstrip() # Python: a
# In Python strip can also remove given characters."--a--".strip("-") # Python: a
# Remove a prefix, only if it is there."v1.2".removeprefix("v") # Python: 1.2
"ab".repeat(2) # Java: abab"ab" * 2 # Python: abab
# Reverse a string.new StringBuilder("abc").reverse() # Java: cba"abc"[::-1] # Python: cba
# Whether a string is empty."".isEmpty() # Java: truenot "" # Python: True
" ".isBlank() # Java: truenot " ".strip() # Python: True
# Character checks. In Python they work for a whole string too.Character.isDigit('5') # Java: true"5".isdigit() # Python: True
Character.isLetter('a') # Java: true"a".isalpha() # Python: True
"123".chars().allMatch(Character::isDigit) # Java: true"123".isdigit() # Python: True
# A capital first letter."ann".substring(0, 1).toUpperCase() + "ann".substring(1) # Java: Ann"ann".capitalize() # Python: Ann
# Comparison: ordinary operators replace compareTo in Python."a".compareTo("b") < 0 # Java: true"a" < "b" # Python: True
"ANN".equalsIgnoreCase("ann") # Java: true"ANN".casefold() == "ann".casefold() # Python: TrueWhat a string slice is
A slice replaces substring. It is written s[start:end:step]. The right bound is excluded, as in substring. Any part can be omitted. It helps to picture the indexes as numbers under the letters. Slices work the same way on lists.
string: H e l l o index: 0 1 2 3 4 from end: -5 -4 -3 -2 -1String s = "Hello"; # Javas = "Hello" # Python
s.substring(1, 3) # Java: els[1:3] # Python: el
# No start: from the first character.s.substring(0, 2) # Java: Hes[:2] # Python: He
# No end: to the last character.s.substring(2) # Java: llos[2:] # Python: llo
# The last three characters.s.substring(s.length() - 3) # Java: llos[-3:] # Python: llo
# Every second character. Java needs a loop for this.s[::2] # Python: Hlo
# Unlike substring, going out of bounds is not an error.s[3:100] # Python: loHow to split and join strings
This is where most differences are. split in Java takes a regular expression and drops trailing empty strings, while split in Python drops nothing and looks for plain text. With no arguments it splits on any whitespace. In Python join is called on the separator.
Arrays.toString("a,b,,".split(",")) # Java: [a, b]"a,b,,".split(",") # Python: ['a', 'b', '', '']
# In Java the dot is a regex; in Python, a plain character.Arrays.toString("a.b".split("\\.")) # Java: [a, b]"a.b".split(".") # Python: ['a', 'b']
Arrays.toString(" a b ".trim().split("\\s+")) # Java: [a, b]" a b ".split() # Python: ['a', 'b']
# Limit the number of parts. Python counts the cuts, not the parts.Arrays.toString("a=b=c".split("=", 2)) # Java: [a, b=c]"a=b=c".split("=", 1) # Python: ['a', 'b=c']
# By lines."a\nb".lines().toList() # Java: [a, b]"a\nb".splitlines() # Python: ['a', 'b']
# Split into three parts: before, separator, after."key=value".partition("=") # Python: ('key', '=', 'value')
String.join(", ", List.of("a", "b")) # Java: a, b", ".join(["a", "b"]) # Python: a, b
# In Python join takes only strings; numbers must be converted.", ".join(str(n) for n in [1, 2]) # Python: 1, 2
# Iterate over characters and get a list of them."abc".toCharArray().length # Java: 3list("abc") # Python: ['a', 'b', 'c']How to put values into a string
The main way is an f-string: the letter f before the quote and any expressions in curly braces. After a colon comes the format, much like the one in String.format.
String name = "Ann"; # Javaname = "Ann" # Python
int n = 42; # Javan = 42 # Python
double x = 3.14159; # Javax = 3.14159 # Python
String.format("%s is %d", name, n) # Java: Ann is 42f"{name} is {n}" # Python: Ann is 42
# Any expression can go in the braces.String.format("%d", n * 2) # Java: 84f"{n * 2}" # Python: 84
# Decimal places.String.format(Locale.US, "%.2f", x) # Java: 3.14f"{x:.2f}" # Python: 3.14
# Width and zeros.String.format("%05d", n) # Java: 00042f"{n:05d}" # Python: 00042
# Alignment: > right, < left, ^ centre.String.format("[%5s]", name) # Java: [ Ann]f"[{name:>5}]" # Python: [ Ann]
String.format("[%-5s]", name) # Java: [Ann ]f"[{name:<5}]" # Python: [Ann ]
f"[{name:^5}]" # Python: [ Ann ]
# A thousands separator.String.format(Locale.US, "%,d", 1234567) # Java: 1,234,567f"{1234567:,}" # Python: 1,234,567
# Hex and percent.String.format("%x", 255) # Java: fff"{255:x}" # Python: ff
f"{0.25:.0%}" # Python: 25%
# Python only: = prints both the expression and its value. Handy for debugging.f"{n=}" # Python: n=42
# A literal curly brace is written twice.f"{{{n}}}" # Python: {42}Older code has two more ways, worth knowing in order to read it. The % format also lives on in logging: log.info("user %s", name). This way the message is built only if the record actually reaches the log, as with {} in SLF4J. That is why f-strings are not used in logger calls.
name = "Ann"
# Like printf. The oldest way.print("Hi, %s" % name) # Hi, Ann
# Like MessageFormat. Needed when the template is given in advance, say in settings.print("Hi, {}".format(name)) # Hi, Annprint("Hi, {who}".format(who=name)) # Hi, AnnWhat a t-string is
An f-string glues everything into plain text at once, so putting user data into SQL or HTML this way is dangerous: that is how injections happen. The t-string arrived in Python 3.14. It glues nothing and hands over the text and values separately, so you decide how to combine them.
name = "x' OR '1'='1"
# An f-string: the data became part of the SQL.print(f"name = '{name}'") # name = 'x' OR '1'='1'
# A t-string: an object, not a string.template = t"name = {name}"print(template.strings) # ('name = ', '')print(template.values) # ("x' OR '1'='1",)
# The text goes into the query and the values apart, as in PreparedStatement.def to_sql(query): return "?".join(query.strings), query.values
print(to_sql(template)) # ('name = ?', ("x' OR '1'='1",))How to build a string from parts
A string is immutable, so concatenating in a loop creates a new string each time. As in Java, this is slow at volume. Python has no separate StringBuilder: parts go into a list and get joined once with join.
var builder = new StringBuilder();for (int i = 0; i < 3; i++) { builder.append(i).append(",");}System.out.println(builder); // 0,1,2,parts = []for i in range(3): parts.append(f"{i},")print("".join(parts)) # 0,1,2,
# If text is built as a stream, there is io.StringIO, the StringWriter.import io
buffer = io.StringIO()buffer.write("a")buffer.write("b")print(buffer.getvalue()) # abHow __str__ differs from __repr__ when printing
The difference shows well on a string itself. The str function gives the text as is, while repr shows how the value is written in code: with quotes and control characters. Containers print their elements through repr, which is why strings in a list appear with quotes.
text = "a\tb"
print(str(text)) # a bprint(repr(text)) # 'a\tb'
# In an f-string, !r means "call repr". Handy in error messages.print(f"bad value: {text!r}") # bad value: 'a\tb'print(["a", "b"]) # ['a', 'b']How text differs from bytes
str holds text and bytes holds bytes. Python never mixes them: they cannot be added, and going from one to the other is always explicit, through an encoding. There are three types for working with bytes.
| Python | Mutable | What it is | Java counterpart |
|---|---|---|---|
bytes | no | immutable bytes | a byte[] nobody changes |
bytearray | yes | a mutable buffer | byte[] |
memoryview | as its source | a window into other bytes, with no copy | ByteBuffer.wrap |
import base64 # Python
byte[] data = "héllo".getBytes(StandardCharsets.UTF_8); # Javadata = "héllo".encode("utf-8") # Python
new String(data, StandardCharsets.UTF_8) # Java: héllodata.decode("utf-8") # Python: héllo
# Length in bytes: é takes two.data.length # Java: 6len(data) # Python: 6
# A byte: signed in Java, 0 to 255 in Python.data[1] # Java: -61data[1] # Python: 195
HexFormat.of().formatHex(data) # Java: 68c3a96c6c6fdata.hex() # Python: 68c3a96c6c6f
Base64.getEncoder().encodeToString("hi".getBytes()) # Java: aGk=base64.b64encode(b"hi").decode() # Python: aGk=data = b"abc"
# Printed with the letter b, so text and bytes do not get confused.print(data) # b'abc'
# "abc" + b"abc" raises TypeError.
# bytes cannot be changed; bytearray can.buffer = bytearray(b"abc")buffer[0] = 65print(buffer) # bytearray(b'Abc')
# memoryview looks at the same bytes: a write shows in the source.view = memoryview(buffer)view[1:2] = b"B"print(buffer) # bytearray(b'ABc')How string length differs from Java
String in Java counts UTF-16 units, so an emoji takes two chars. In Python len counts Unicode characters. Python itself chooses how many bytes to give a character, so indexing is always fast. But one visible character can still consist of several Unicode characters, and such text is normalized before comparing.
import unicodedata # Python
"😀".length() # Java: 2len("😀") # Python: 1
"😀".codePointCount(0, 2) # Java: 1len("😀") # Python: 1
# é as one character and as e plus an accent mark."é".equals("é") # Java: false"é" == "é" # Python: False
Normalizer.normalize("é", Normalizer.Form.NFC).equals("é") # Java: trueunicodedata.normalize("NFC", "é") == "é" # Python: TrueHow to work with regular expressions
The expression syntax is almost the same, and the methods live in the re module. A pattern is usually written as a raw string. With no match, the methods return None.
import re # Python
# Whether the whole string matches."123".matches("\\d+") # Java: truebool(re.fullmatch(r"\d+", "123")) # Python: True
"a1b2".replaceAll("\\d", "#") # Java: a#b#re.sub(r"\d", "#", "a1b2") # Python: a#b#
Arrays.toString("a1b2c".split("\\d")) # Java: [a, b, c]re.split(r"\d", "a1b2c") # Python: ['a', 'b', 'c']
# All matches.var found = Pattern.compile("\\d").matcher("a1b2").results(); # Javafound.map(r -> r.group()).toList() # Java: [1, 2]re.findall(r"\d", "a1b2") # Python: ['1', '2']Pattern pattern = Pattern.compile("(?<key>\\w+)=(?<value>\\d+)");Matcher matcher = pattern.matcher("size=42");
if (matcher.find()) { System.out.println(matcher.group("key")); // size System.out.println(matcher.group("value")); // 42}import re
# A precompiled pattern, like Pattern. A group name: ?P<name>.pattern = re.compile(r"(?P<key>\w+)=(?P<value>\d+)")
# search looks anywhere in the string, like find().if match := pattern.search("size=42"): print(match["key"]) # size print(match["value"]) # 42Collections: core methods
Which collection corresponds to which
The main collections are built into the language and have their own syntax, so they need no new and no imports. There is no List interface with several implementations either: there is one list, and inside it is a dynamic array, like ArrayList.
| Java | Python | Literal |
|---|---|---|
ArrayList | list | [1, 2] |
List.of | tuple | (1, 2) |
LinkedHashMap | dict | {"a": 1} |
HashSet | set | {1, 2} |
Set.of | frozenset | frozenset({1, 2}) |
ArrayDeque, LinkedList | collections.deque | deque([1, 2]) |
PriorityQueue | heapq | functions over a list |
int[] | list or array.array | [0] * 3 |
TreeMap | none, usually sorted(d) | |
ConcurrentHashMap | dict plus Lock |
How to work with a list
var items = new ArrayList<>(List.of(3, 1, 2)); # Javaitems = [3, 1, 2] # Python
items.size() # Java: 3len(items) # Python: 3
items.get(0) # Java: 3items[0] # Python: 3
# The last element.items.getLast() # Java: 2items[-1] # Python: 2
items.set(0, 9); # Javaitems[0] = 9 # Python
items.add(4); # Javaitems.append(4) # Python
# Insert at an index.items.add(0, 7); # Javaitems.insert(0, 7) # Python
items.addAll(List.of(5, 6)); # Javaitems.extend([5, 6]) # Python
items # Java: [7, 9, 1, 2, 4, 5, 6]items # Python: [7, 9, 1, 2, 4, 5, 6]
# Remove at an index and get the value.items.remove(0) # Java: 7items.pop(0) # Python: 7
# Remove by value.items.remove(Integer.valueOf(9)); # Javaitems.remove(9) # Python
items.contains(1) # Java: true1 in items # Python: True
# If the value is missing, index raises ValueError, not -1.items.indexOf(2) # Java: 1items.index(2) # Python: 1
items.isEmpty() # Java: falsenot items # Python: False
Collections.max(items) # Java: 6max(items) # Python: 6
items.stream().mapToInt(i -> i).sum() # Java: 18sum(items) # Python: 18
# Reverse in place.Collections.reverse(items); # Javaitems.reverse() # Python
items # Java: [6, 5, 4, 2, 1]items # Python: [6, 5, 4, 2, 1]
# Part of a list. In Java it is a view of the list; in Python, a copy.items.subList(0, 2) # Java: [6, 5]items[:2] # Python: [6, 5]
# A copy of the whole list.var copy = new ArrayList<>(items); # Javacopy = items.copy() # Python
# Joining and repeating.[1] + [2] # Python: [1, 2][0] * 3 # Python: [0, 0, 0]
items.clear(); # Javaitems.clear() # PythonA slice also works on the left of an assignment, and multiplication hides a trap with nested lists.
items = [1, 2, 3, 4]
# Replace part of a list.items[1:3] = [20, 30]print(items) # [1, 20, 30, 4]
# Delete part of a list.del items[1:3]print(items) # [1, 4]
# A trap: two references to the same inner list.grid = [[0] * 2] * 2grid[0][0] = 1print(grid) # [[1, 0], [1, 0]]
# Correct: each inner list is created separately.grid = [[0] * 2 for _ in range(2)]grid[0][0] = 1print(grid) # [[1, 0], [0, 0]]What a tuple is
A tuple is an immutable list, the closest thing to List.of. It is used for a fixed set of values, such as coordinates or a database row. It can also be a dictionary key, unlike a list.
var point = List.of(10, 20); # Javapoint = (10, 20) # Python
point.get(0) # Java: 10point[0] # Python: 10
# point.set(0, 1) in Java and point[0] = 1 in Python both fail.
# A one-element tuple: the comma is required.var single = List.of(1); # Javasingle = (1,) # Python
# Without the comma it is just a number in parentheses.type((1)).__name__ # Python: inttype((1,)).__name__ # Python: tuple
# Unpacking into variables.var x = point.get(0); # Javax, y = point # Python
x # Java: 10y # Python: 20
# Tuples compare element by element.(1, 2) < (1, 3) # Python: TrueHow to work with a dictionary
A dict is a Map. It keeps insertion order, like LinkedHashMap. The key difference from Java is that ages["Bob"] with a missing key raises KeyError rather than returning null. Java's get behaviour comes from the get method.
var ages = new LinkedHashMap<String, Integer>(); # Javaages = {} # Python
ages.put("Ann", 30); # Javaages["Ann"] = 30 # Python
ages.put("Bob", 25); # Javaages["Bob"] = 25 # Python
ages.get("Ann") # Java: 30ages["Ann"] # Python: 30
# No such key.ages.get("Cid") # Java: nullages.get("Cid") # Python: None
ages.getOrDefault("Cid", 0) # Java: 0ages.get("Cid", 0) # Python: 0
ages.containsKey("Ann") # Java: true"Ann" in ages # Python: True
ages.containsValue(25) # Java: true25 in ages.values() # Python: True
ages.size() # Java: 2len(ages) # Python: 2
ages.keySet() # Java: [Ann, Bob]list(ages) # Python: ['Ann', 'Bob']
ages.values() # Java: [30, 25]list(ages.values()) # Python: [30, 25]
# Write only if the key is not there yet.ages.putIfAbsent("Cid", 40); # Javaages.setdefault("Cid", 40) # Python: 40
# Remove and get the value.ages.remove("Cid") # Java: 40ages.pop("Cid") # Python: 40
# Remove, and if the key is missing, return a default.ages.pop("Cid", None) # Python: None
# Keys in order, instead of TreeMap.new TreeMap<>(ages).firstKey() # Java: Annsorted(ages)[0] # Python: AnnIteration goes through items, which yields key and value pairs. It replaces entrySet.
var ages = new LinkedHashMap<>(Map.of("Ann", 30));
for (var entry : ages.entrySet()) { System.out.print(entry.getKey() + "=" + entry.getValue()); // Ann=30}
for (String name : ages.keySet()) { System.out.print(name); // Ann}
ages.forEach((name, age) -> System.out.print(name + age));ages = {"Ann": 30}
for name, age in ages.items(): print(f"{name}={age}") # Ann=30
# Iterating over the dict itself goes over the keys.for name in ages: print(name) # Ann
# Merging: on equal keys, the right one wins.print({"a": 1} | {"a": 2, "b": 3}) # {'a': 2, 'b': 3}
# Two stars spread one dictionary inside another.print({**{"a": 1}, "b": 2}) # {'a': 1, 'b': 2}A key can only be an immutable value: a string, a number, a tuple, a frozen dataclass. Formally it needs a __hash__ method, the same as hashCode. A list would raise TypeError: unhashable type.
How to accumulate values per key
Java has computeIfAbsent and merge for this. Python has setdefault and two special dictionaries. defaultdict creates the value for a new key by itself. Counter counts how many times each element occurs.
var byCity = new TreeMap<String, List<String>>();byCity.computeIfAbsent("Kyiv", k -> new ArrayList<>()).add("Ann");byCity.computeIfAbsent("Kyiv", k -> new ArrayList<>()).add("Bob");System.out.println(byCity); // {Kyiv=[Ann, Bob]}
var counts = new TreeMap<String, Integer>();for (String word : List.of("a", "b", "a")) { counts.merge(word, 1, Integer::sum);}System.out.println(counts); // {a=2, b=1}from collections import Counter, defaultdict
# list here is a function that creates the value for a new key.by_city = defaultdict(list)by_city["Kyiv"].append("Ann")by_city["Kyiv"].append("Bob")print(dict(by_city)) # {'Kyiv': ['Ann', 'Bob']}
counts = Counter(["a", "b", "a"])print(dict(counts)) # {'a': 2, 'b': 1}print(counts["a"]) # 2
# The most common elements: top 1.print(counts.most_common(1)) # [('a', 2)]How to work with a set
The methods resemble Java's, and ordinary operators replace retainAll and removeAll. They return a new set and do not change the originals.
var seen = new TreeSet<>(List.of(1, 2)); # Javaseen = {1, 2} # Python
seen.add(3); # Javaseen.add(3) # Python
seen.contains(3) # Java: true3 in seen # Python: True
seen.remove(3); # Javaseen.remove(3) # Python
# In Python remove raises KeyError if the value is missing; discard stays quiet.seen.discard(100) # Python
# An empty set: {} would create a dictionary.var empty = new HashSet<Integer>(); # Javaempty = set() # Python
# Union, intersection, difference.{1, 2} | {2, 3} # Python: {1, 2, 3}{1, 2} & {2, 3} # Python: {2}{1, 2} - {2, 3} # Python: {1}
# Whether all elements are in, like containsAll.seen.containsAll(List.of(1)) # Java: true{1} <= seen # Python: TrueHow to make a queue and a priority queue
list.pop(0) takes O(n) because it shifts the whole array, so a queue uses deque. The priority queue is not a class but functions from the heapq module. They work on a plain list, and the smallest element is always on top.
from collections import deque # Pythonimport heapq # Python
var queue = new ArrayDeque<>(List.of(1, 2)); # Javaqueue = deque([1, 2]) # Python
queue.addLast(3); # Javaqueue.append(3) # Python
queue.addFirst(0); # Javaqueue.appendleft(0) # Python
queue.pollFirst() # Java: 0queue.popleft() # Python: 0
queue.pollLast() # Java: 3queue.pop() # Python: 3
# Peek without removing.queue.peekFirst() # Java: 1queue[0] # Python: 1
# A priority queue.var tasks = new PriorityQueue<>(List.of(3, 1)); # Javatasks = [1, 3] # Python
tasks.add(2); # Javaheapq.heappush(tasks, 2) # Python
tasks.poll() # Java: 1heapq.heappop(tasks) # Python: 1
# With a priority: a tuple compares by its first element.jobs = [(2, "later"), (1, "urgent")]; heapq.heapify(jobs) # Pythonheapq.heappop(jobs) # Python: (1, 'urgent')
# The N largest at once.heapq.nlargest(2, [1, 5, 3]) # Python: [5, 3]How to sort
list.sort() changes the list itself, while sorted() returns a new one and takes any collection. The ordering comes from the key function. To sort by several fields it simply returns a tuple.
record User(String name, int age) {}
var users = new ArrayList<>(List.of( new User("Ann", 30), new User("Bob", 25), new User("Cid", 30)));
// By age descending, then by name.users.sort( Comparator.comparingInt(User::age).reversed() .thenComparing(User::name));
System.out.println(users.getFirst().name()); // Annfrom dataclasses import dataclass
@dataclassclass User: name: str age: int
users = [ User("Ann", 30), User("Bob", 25), User("Cid", 30),]
# By age descending, then by name. The minus reverses the order for numbers.users.sort(key=lambda u: (-u.age, u.name))print(users[0].name) # Ann
# sorted does not change the original, and reverse=True flips the order.print(sorted([2, 3, 1], reverse=True)) # [3, 2, 1]print(sorted("bca")) # ['a', 'b', 'c']What happens if you change a collection while iterating
In Java this raises ConcurrentModificationException. A dictionary and a set in Python behave the same way, but a list silently skips elements.
items = [1, 2, 2, 3]
# No error, yet the second 2 remains.for item in items: if item == 2: items.remove(item)print(items) # [1, 2, 3]
# Correct: build a new list. The syntax is explained in the next section.items = [1, 2, 2, 3]items = [item for item in items if item != 2]print(items) # [1, 3]
ages = {"a": 1}try: for key in ages: ages["b"] = 2except RuntimeError as e: print(e) # dictionary changed size during iterationWhat the complexity of the operations is
The values are the same as for the corresponding Java collections.
| Operation | list | dict, set | deque |
|---|---|---|---|
| Read by index or key | O(1) | O(1) | O(n) |
x in c | O(n) | O(1) | O(n) |
| Append | O(1) | O(1) | O(1) |
| Add or remove at the front | O(n) | – | O(1) |
sorted | O(n log n) | O(n log n) | O(n log n) |
Stream API: what replaces it
What a list comprehension is
Instead of a chain of filter and map, Python has a special notation called a list comprehension. It reads like a phrase: what to put, where to take it from, under what condition. It replaces most of the Stream API.
numbers = [1, 2, 3, 4]
# [ what to put where to take it condition ]squares = [n * n for n in numbers if n % 2 == 0]print(squares) # [4, 16]
# The same as a plain loop.squares = []for n in numbers: if n % 2 == 0: squares.append(n * n)print(squares) # [4, 16]What replaces stream operations
Here are the main operations with their results. Note that the combination of map and filter is a single expression, with no separate calls.
import functools # Python
var items = List.of(3, 1, 2, 3); # Javaitems = [3, 1, 2, 3] # Python
# filteritems.stream().filter(i -> i > 1).toList() # Java: [3, 2, 3][i for i in items if i > 1] # Python: [3, 2, 3]
# mapitems.stream().map(i -> i * 2).toList() # Java: [6, 2, 4, 6][i * 2 for i in items] # Python: [6, 2, 4, 6]
# sum, count, min, maxitems.stream().mapToInt(i -> i).sum() # Java: 9sum(items) # Python: 9
items.stream().filter(i -> i > 1).count() # Java: 3sum(1 for i in items if i > 1) # Python: 3
items.stream().max(Integer::compare).get() # Java: 3max(items) # Python: 3
# The average.items.stream().mapToInt(i -> i).average().getAsDouble() # Java: 2.25sum(items) / len(items) # Python: 2.25
# anyMatch, allMatch, noneMatchitems.stream().anyMatch(i -> i > 2) # Java: trueany(i > 2 for i in items) # Python: True
items.stream().allMatch(i -> i > 2) # Java: falseall(i > 2 for i in items) # Python: False
items.stream().noneMatch(i -> i > 5) # Java: truenot any(i > 5 for i in items) # Python: True
# findFirstitems.stream().filter(i -> i < 3).findFirst().orElse(null) # Java: 1next((i for i in items if i < 3), None) # Python: 1
# sorted, limit, skipitems.stream().sorted().toList() # Java: [1, 2, 3, 3]sorted(items) # Python: [1, 2, 3, 3]
items.stream().limit(2).toList() # Java: [3, 1]items[:2] # Python: [3, 1]
items.stream().skip(2).toList() # Java: [2, 3]items[2:] # Python: [2, 3]
# distinct keeping the order: dictionary keys are unique.items.stream().distinct().toList() # Java: [3, 1, 2]list(dict.fromkeys(items)) # Python: [3, 1, 2]
# Without order, a set is enough.items.stream().collect(Collectors.toCollection(TreeSet::new)) # Java: [1, 2, 3]{i for i in items} # Python: {1, 2, 3}
# flatMapStream.of(List.of(1), List.of(2)).flatMap(List::stream).toList() # Java: [1, 2][x for row in [[1], [2]] for x in row] # Python: [1, 2]
# joiningvar words = items.stream().map(String::valueOf); # Javawords.collect(Collectors.joining(",")) # Java: 3,1,2,3",".join(str(i) for i in items) # Python: 3,1,2,3
# reduce lives in functools.items.stream().reduce(1, (a, b) -> a * b) # Java: 18functools.reduce(lambda a, b: a * b, items) # Python: 18Parentheses instead of square brackets in expressions like sum(1 for i in items if i > 1) create a generator. It builds no list in memory and yields values one at a time, like a lazy stream. More on this below.
How to build a dictionary and group values
There are comprehensions for dictionaries and sets too. But groupingBy has no simple counterpart: grouping is usually written as a loop.
record User(String name, String city, int age) {}
var users = List.of( new User("Ann", "Kyiv", 30), new User("Bob", "Lviv", 25), new User("Cid", "Kyiv", 40));
var agesByName = users.stream() .collect(Collectors.toMap(User::name, User::age));System.out.println(agesByName.get("Ann")); // 30
var byCity = users.stream() .collect(Collectors.groupingBy(User::city));System.out.println(byCity.get("Kyiv").size()); // 2
var countByCity = users.stream() .collect(Collectors.groupingBy(User::city, Collectors.counting()));System.out.println(countByCity.get("Kyiv")); // 2
var adults = users.stream() .collect(Collectors.partitioningBy(u -> u.age() >= 30));System.out.println(adults.get(true).size()); // 2from collections import Counter, defaultdictfrom dataclasses import dataclass
@dataclassclass User: name: str city: str age: int
users = [ User("Ann", "Kyiv", 30), User("Bob", "Lviv", 25), User("Cid", "Kyiv", 40),]
# toMapages_by_name = {u.name: u.age for u in users}print(ages_by_name["Ann"]) # 30
# groupingByby_city = defaultdict(list)for user in users: by_city[user.city].append(user)print(len(by_city["Kyiv"])) # 2
# groupingBy + countingcount_by_city = Counter(u.city for u in users)print(count_by_city["Kyiv"]) # 2
# partitioningByadults = [u for u in users if u.age >= 30]print(len(adults)) # 2What replaces Optional
Python does not use Optional. A missing value is None, and the type is written as User | None. Chains give way to ordinary checks.
record User(String name) {}
Optional<User> findUser(int id) { return id == 1 ? Optional.of(new User("Ann")) : Optional.empty();}
String name = findUser(2) .map(User::name) .orElse("guest");System.out.println(name); // guestfrom dataclasses import dataclass
@dataclassclass User: name: str
def find_user(user_id: int) -> User | None: return User("Ann") if user_id == 1 else None
user = find_user(2)name = user.name if user is not None else "guest"print(name) # guestFrequently needed standard library classes
How to work with date and time
The datetime module replaces java.time. One important detail: a datetime may or may not have a time zone. Without one it is like LocalDateTime; with one, like ZonedDateTime. These cannot be compared with each other, and datetime.now() with no arguments gives a value without a zone. In services always write datetime.now(UTC).
from datetime import UTC, date, datetime, timedelta # Pythonfrom zoneinfo import ZoneInfo # Python
import java.time.format.DateTimeFormatter; # Javaimport java.time.*; # Java
var day = LocalDate.of(2026, 11, 6); # Javaday = date(2026, 11, 6) # Python
# Add and subtract: ordinary operators replace plusDays.day.plusDays(30) # Java: 2026-12-06day + timedelta(days=30) # Python: 2026-12-06
day.getDayOfWeek().getValue() # Java: 5day.isoweekday() # Python: 5
# The difference between dates.Duration.ofHours(36).toDays() # Java: 1timedelta(hours=36).days # Python: 1
# To a string and back.day.toString() # Java: 2026-11-06day.isoformat() # Python: 2026-11-06
LocalDate.parse("2026-01-02").getYear() # Java: 2026date.fromisoformat("2026-01-02").year # Python: 2026
day.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) # Java: 06.11.2026day.strftime("%d.%m.%Y") # Python: 06.11.2026
# The current moment with a zone.var now = ZonedDateTime.now(ZoneOffset.UTC); # Javanow = datetime.now(UTC) # Python
var kyiv = ZoneId.of("Europe/Kyiv"); # Javavar at = ZonedDateTime.of(2026, 1, 2, 12, 0, 0, 0, kyiv); # Javaat = datetime(2026, 1, 2, 12, tzinfo=ZoneInfo("Europe/Kyiv")) # Python
at.toEpochSecond() # Java: 1767348000int(at.timestamp()) # Python: 1767348000How to work with files
Path and Files are combined in one Python class, pathlib.Path. Path parts are joined with the / operator.
import tempfile # Pythonfrom pathlib import Path # Python
Path dir = Files.createTempDirectory("demo"); # Javadir = Path(tempfile.mkdtemp()) # Python
Path file = dir.resolve("logs").resolve("a.txt"); # Javafile = dir / "logs" / "a.txt" # Python
file.getFileName() # Java: a.txtfile.name # Python: a.txt
file.getParent().getFileName() # Java: logsfile.parent.name # Python: logs
Files.exists(file) # Java: falsefile.exists() # Python: False
# Create directories with their parents.Files.createDirectories(file.getParent()); # Javafile.parent.mkdir(parents=True, exist_ok=True) # Python
Files.writeString(file, "one\ntwo\n"); # Javafile.write_text("one\ntwo\n", encoding="utf-8") # Python
Files.readAllLines(file) # Java: [one, two]file.read_text(encoding="utf-8").splitlines() # Python: ['one', 'two']
# Walk a directory with all its subdirectories.Files.walk(dir).filter(Files::isRegularFile).count() # Java: 1sum(1 for f in dir.rglob("*.txt")) # Python: 1
Files.delete(file); # Javafile.unlink() # PythonA large file is read line by line. A file object in Python can be iterated in a loop, and only one line at a time goes into memory.
Path path = Files.createTempFile("big", ".txt");Files.writeString(path, "1\n2\n");
try (var lines = Files.lines(path)) { System.out.println(lines.mapToInt(Integer::parseInt).sum()); // 3}import tempfilefrom pathlib import Path
path = Path(tempfile.mkdtemp()) / "big.txt"path.write_text("1\n2\n", encoding="utf-8")
with open(path, encoding="utf-8") as file: print(sum(int(line) for line in file)) # 3How to work with JSON
The json module is in the standard library. Unlike Jackson, it works only with dictionaries and lists and does not map to your classes. Real projects use Pydantic for that. A dataclass object can be turned into a dictionary with asdict.
ObjectMapper mapper = new ObjectMapper();
String text = mapper.writeValueAsString(new User("Ann", 30));User user = mapper.readValue(text, User.class);Map<String, Object> raw = mapper.readValue(text, Map.class);import jsonfrom dataclasses import asdict, dataclass
@dataclassclass User: name: str age: int
# An object to JSON: first a dictionary, then a string.text = json.dumps(asdict(User("Ann", 30)))print(text) # {"name": "Ann", "age": 30}
# JSON to a dictionary.raw = json.loads(text)print(raw["name"]) # Ann
# A dictionary to an object: two stars spread it into named arguments.print(User(**raw)) # User(name='Ann', age=30)
# json does not know types like datetime and Decimal; it needs a default function.from decimal import Decimal
print(json.dumps({"sum": Decimal("1.5")}, default=str)) # {"sum": "1.5"}How to write logs
The logging module is SLF4J and Logback rolled into one. A logger is created in each module and configured once at startup. The __name__ variable holds the current module's name. Messages use %s, like {} in SLF4J.
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
log.info("order {} paid", id);
try { pay(id);} catch (Exception e) { log.error("payment failed", e);}import logging
log = logging.getLogger(__name__)
# Setup at program start, instead of logback.xml.logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s",)
log.info("order %s paid", 42)
try: 1 / 0except ZeroDivisionError:
# exception writes the message and the whole stack, like log.error("...", e). log.exception("payment failed")Where to find the rest
import hashlib # Pythonimport os # Pythonimport sys # Pythonimport time # Pythonimport uuid # Python
# An environment variable or a default.String port = System.getenv().getOrDefault("NO_SUCH_VAR", "80"); # Javaport = os.environ.get("NO_SUCH_VAR", "80") # Python
# Command-line arguments. sys.argv[0] is the file name.String[] args = new String[0]; # Javaargs = sys.argv[1:] # Python
# Measure time.long start = System.nanoTime(); # Javastart = time.perf_counter() # Python
Thread.sleep(1); # Javatime.sleep(0.001) # Python
# A unique identifier.UUID.randomUUID().toString().length() # Java: 36len(str(uuid.uuid4())) # Python: 36
# The first 8 characters of a SHA-256 hash.var sha = java.security.MessageDigest.getInstance("SHA-256"); # JavaHexFormat.of().formatHex(sha.digest(new byte[0])).substring(0, 8) # Java: e3b0c442hashlib.sha256(b"").hexdigest()[:8] # Python: e3b0c442| Java | Python |
|---|---|
System.exit(1) | sys.exit(1) |
System.err.println(x) | print(x, file=sys.stderr) |
new Scanner(System.in) | input() |
HttpClient | urllib.request, in practice httpx |
URLEncoder.encode | urllib.parse.quote |
Properties | configparser, tomllib |
@Cacheable | functools.lru_cache |
Thread, ExecutorService | threading, concurrent.futures |
CompletableFuture | asyncio |
JUnit | unittest, in practice pytest |
How Python works differently from Java
How code runs
The main implementation of the language is called CPython. Like javac with the JVM, it compiles source to bytecode and runs it on a virtual machine, except that both steps are one command. The bytecode is cached in the __pycache__ directory, like .class files. The main difference from the JVM is that the bytecode is usually interpreted. The JIT compiler, which on the JVM turns hot code into machine code, is only just arriving in CPython and is still experimental. That explains the difference in speed.
| Java | Python | |
|---|---|---|
| Compilation | javac in advance | at start |
| A type error shows | at compile time | when the line runs |
| JIT | C2, Graal | experimental |
| Program start | slower | faster |
| Garbage collection | tracing | reference counts plus a cycle collector |
| Parallel threads | yes | usually no, because of the GIL |
Declaring a class or a function in Python is an ordinary command that runs when its turn comes. In Java the JVM loads classes; here the file simply goes from top to bottom. The numbers show the order.
print("start") # 1. start
# Here the function is only created. The body does not run.def greet(): print("hi") # 4. hi
# But a class body runs immediately.class Config: print("class body") # 2. class body
print("end") # 3. endgreet()This explains a lot of what follows. Importing a file also just runs it from top to bottom, so side-effect code at the top level of a module fires on first import. That is why the if __name__ == "__main__" check is needed.
What a variable is
All variables in Python work like reference variables in Java. There are no primitives, so a variable always holds a reference to an object, even for a number. The assignment b = a copies only the reference. It helps to picture a variable as a label stuck on an object.
a = [1, 2] a ──► [1, 2]
b = a a ──► [1, 2] ◄── b
b.append(3) a ──► [1, 2, 3] ◄── b
b = [9] a ──► [1, 2, 3] b ──► [9]var a = new ArrayList<>(List.of(1, 2));var b = a;b.add(3);System.out.println(a); // [1, 2, 3]
b = new ArrayList<>(List.of(9));System.out.println(a); // [1, 2, 3]a = [1, 2]b = ab.append(3)print(a) # [1, 2, 3]
# The label b moved to another object. The object a did not change.b = [9]print(a) # [1, 2, 3]
# id() is the object's address, like System.identityHashCode.c = aprint(id(c) == id(a)) # TrueHow arguments are passed to a function
The same way as objects in Java: a function receives a copy of the reference. It can change the object's contents, but it cannot replace the caller's variable.
void change(List<Integer> items) { items.add(1); items = new ArrayList<>(); items.add(99);}
var data = new ArrayList<Integer>();change(data);System.out.println(data); // [1]def change(items):
# 1. Changing the shared object: visible outside. items.append(1)
# 2. Moving the local label: not visible outside. items = [] items.append(99)
data = []change(data)print(data) # [1]Which types are mutable and which are not
| Immutable | Mutable |
|---|---|
int, float, bool, None | list |
str, bytes | dict |
tuple, frozenset | set, bytearray |
@dataclass(frozen=True) | ordinary objects |
This leads to an important trap with +=. On a mutable object it changes the object itself; on an immutable one it creates a new object and moves the label.
# A list: one object for both, the change shows through both labels.a = [1]b = aa += [2]print(b) # [1, 2]
# A string: a now points to a new string, and b to the old one.a = "x"b = aa += "y"print(b) # xHow to copy an object
list(x) or x.copy() is a shallow copy, like new ArrayList<>(x): the list itself is copied, while nested objects stay shared. The copy module can copy a whole tree of objects. In Java this would take hand-written code or serialization.
import copy
original = [[1], [2]]
# A shallow copy: a new outer list, the same inner ones.shallow = original.copy()shallow[0].append(9)print(original) # [[1, 9], [2]]
# A deep copy: everything is new.deep = copy.deepcopy(original)deep[0].append(7)print(original) # [[1, 9], [2]]Why numbers must not be compared with is
This is the same story as Integer in Java. There, values from -128 to 127 are cached, so == sometimes works and sometimes does not. CPython caches small numbers too, and the bound depends on the version. So is is used only with None, True, False and enum elements.
Integer small1 = 127, small2 = 127;System.out.println(small1 == small2); // true
Integer big1 = 1000, big2 = 1000;System.out.println(big1 == big2); // falseSystem.out.println(big1.equals(big2)); // truesmall1 = int("256")small2 = int("256")print(small1 is small2) # True
big1 = int("100000")big2 = int("100000")print(big1 is big2) # Falseprint(big1 == big2) # TrueWhere a variable is visible
In Java a variable lives inside its block of curly braces. Python has no block scope: if and for do not create one. Only functions, classes and modules draw boundaries. A variable is looked up in four places in order, known as LEGB: Local (its own function), Enclosing (the outer function), Global (the file), Built-in (built-in names).
for (int i = 0; i < 3; i++) { int square = i * i;}
// Compile error: i and square do not exist here.// System.out.println(i);for i in range(3): square = i * i
# Both variables are alive: a loop creates no scope of its own.print(i, square) # 2 4
# An exception: a variable inside a comprehension is not visible outside.squares = [n * n for n in range(3)]Reading an outer variable just works. But an assignment inside a function creates a local variable. To write to an outer one, you must say so with global or nonlocal.
count = 0
def broken():
# The assignment below makes count local in the whole function, # so the read fails: UnboundLocalError. count += 1
def works(): global count count += 1
works()print(count) # 1
def counter(): total = 0
def add():
# nonlocal is a variable of the outer function. nonlocal total total += 1 return total
return add
add = counter()add()print(add()) # 2What counts as true in a condition
Empty and zero values count as false, and everything else as true. A class of your own controls this through the __bool__ or __len__ methods.
print(bool(0), bool(0.0), bool(""), bool(None)) # False False False Falseprint(bool([]), bool({}), bool(set()), bool(())) # False False False Falseprint(bool(1), bool("0"), bool([0])) # True True True
# bool is a subclass of int, so True equals 1. This is how matches are counted.print(True + True) # 2print(sum(n > 1 for n in [1, 2, 3])) # 2What "ask forgiveness, not permission" means
In Java you usually check first and act afterwards. In Python you more often act right away and handle the error. This style is called EAFP (easier to ask forgiveness than permission). It also removes the race between the check and the action. Exceptions are not considered a costly rarity here; they are routine.
var config = new HashMap<>(Map.of("port", "80"));int port = 0;
if (config.containsKey("port")) { port = Integer.parseInt(config.get("port"));}System.out.println(port); // 80config = {"port": "80"}
try: port = int(config["port"])except (KeyError, ValueError): port = 0print(port) # 80How your own class plugs into the language
In Java for works with Iterable, and + works only with strings and numbers. In Python every operator and built-in function has a corresponding dunder method. Declare it in your class, and the operator works. This set of rules is called the data model.
| What you write | What gets called | Java counterpart |
|---|---|---|
Cart() | __init__ | constructor |
len(x) | __len__ | size() |
x[i] | __getitem__ | get(i) |
x[i] = v | __setitem__ | set(i, v) |
v in x | __contains__ | contains(v) |
for v in x | __iter__ | iterator() |
a + b | __add__ | an add method |
a == b | __eq__ | equals |
a < b | __lt__ | compareTo |
hash(x) | __hash__ | hashCode |
str(x), repr(x) | __str__, __repr__ | toString |
x() | __call__ | a functional interface method |
with x: | __enter__, __exit__ | AutoCloseable |
class Cart implements Iterable<String> { private final List<String> items = new ArrayList<>();
void add(String item) { items.add(item); }
int size() { return items.size(); }
String get(int index) { return items.get(index); }
public Iterator<String> iterator() { return items.iterator(); }}
var cart = new Cart();cart.add("tea");System.out.println(cart.size()); // 1System.out.println(cart.get(0)); // teaclass Cart: def __init__(self): self._items = []
def add(self, item): self._items.append(item)
def __len__(self): return len(self._items)
def __getitem__(self, index): return self._items[index]
def __iter__(self): return iter(self._items)
def __contains__(self, item): return item in self._items
cart = Cart()cart.add("tea")
# The object behaves like a built-in collection.print(len(cart)) # 1print(cart[0]) # teaprint("tea" in cart) # Trueprint(list(cart)) # ['tea']
# An empty cart is false because its len is 0.print(bool(Cart())) # FalseOperators are overloaded the same way. Comparisons are conveniently derived from one method: __lt__ replaces Comparable, and the total_ordering decorator fills in <=, > and >=.
record Money(int cents) implements Comparable<Money> { Money add(Money other) { return new Money(cents + other.cents); }
public int compareTo(Money other) { return Integer.compare(cents, other.cents); }}
var a = new Money(100);var b = new Money(50);System.out.println(a.add(b)); // Money[cents=150]System.out.println(a.compareTo(b) > 0); // truefrom dataclasses import dataclassfrom functools import total_ordering
@total_ordering@dataclass(frozen=True)class Money: cents: int
def __add__(self, other): return Money(self.cents + other.cents)
def __lt__(self, other): return self.cents < other.cents
a = Money(100)b = Money(50)print(a + b) # Money(cents=150)print(a > b) # True
# min, max and sorted now just work.print(min(a, b)) # Money(cents=50)Functions: what Java does not have
Why a list must not be a default value
A default value is computed once, when the function is declared, and is stored in it. If it is a list, all calls share the same object. This is Python's best-known trap.
# 1. A list is created here, once.def add_bad(item, items=[]): items.append(item) return items
# 2. The first call puts 1 in it.print(add_bad(1)) # [1]
# 3. The second call gets the same list.print(add_bad(2)) # [1, 2]
# Correct: None by default, and a new list inside.def add_good(item, items=None): if items is None: items = [] items.append(item) return items
print(add_good(1)) # [1]print(add_good(2)) # [2]What a closure is
A closure is a function that remembers variables from the place where it was created. Java lambdas can do this too, but capture only final values. Python captures the variable itself, so it can be changed, and its latest value is what is seen.
Function<Integer, Integer> makeAdder(int base) { return x -> x + base;}
var addTen = makeAdder(10);System.out.println(addTen.apply(5)); // 15
var tasks = new ArrayList<Supplier<Integer>>();for (int i = 0; i < 3; i++) {
// Does not compile without this copy: i changes. int copy = i; tasks.add(() -> copy);}System.out.println(tasks.get(0).get()); // 0def make_adder(base): return lambda x: x + base
add_ten = make_adder(10)print(add_ten(5)) # 15
# A trap: all lambdas see the same variable i, and the loop is over.tasks = [lambda: i for i in range(3)]print(tasks[0]()) # 2
# The fix: a default value is computed at once and remembered.tasks = [lambda i=i: i for i in range(3)]print(tasks[0]()) # 0What a decorator is
The @something notation above a function looks like a Java annotation but works differently. An annotation is a mark that someone else reads later, such as Spring. A decorator is an ordinary function. It receives your function and returns another one that replaces it. This resembles the proxies Spring creates for @Transactional. Here is how it works, step by step.
def logged(func):
# 1. When add is declared, logged(add) is called. # 2. It creates wrapper and returns it in place of add. def wrapper(*args, **kwargs): print("before") # 3. before result = func(*args, **kwargs) print("after") # 5. after return result
return wrapper
@loggeddef add(a, b): print("add") # 4. add return a + b
# The name add now points to wrapper.print(add(2, 3)) # 6. 5The @logged form is only a short way of writing an assignment. Here are the same two variants:
@loggeddef add(a, b): return a + b
# Exactly the same:def add(a, b): return a + b
add = logged(add)A real decorator has one more detail. wrapper replaces the function, so its name and documentation are lost. functools.wraps brings them back. A decorator can also take parameters; this needs one more level of nesting: @retry(times=3) first calls retry(times=3), and the result is what acts as the decorator.
from functools import wraps
# 1. retry(times=3) creates and returns decorator.def retry(times):
# 2. decorator(flaky) creates and returns wrapper. def decorator(func):
# 3. Calling flaky() now runs wrapper. @wraps(func) def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except ConnectionError: if attempt == times - 1: raise
return wrapper
return decorator
calls = []
@retry(times=3)def flaky(): calls.append(1) if len(calls) < 3: raise ConnectionError return "ok"
print(flaky()) # okprint(len(calls)) # 3
# Without wraps this would be wrapper.print(flaky.__name__) # flakyWhich ready-made decorators exist
Result caching, like @Cacheable in Spring, is in the standard library. Dispatch on the type of the first argument partly replaces overloading.
from functools import lru_cache, singledispatch
# Stores call results by arguments: at most the last 128.@lru_cache(maxsize=128)def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(50)) # 12586269025print(fib.cache_info().hits) # 48
# Chooses the implementation by the type of the first argument.@singledispatchdef show(value): return "other"
@show.registerdef _(value: int): return "number"
@show.registerdef _(value: str): return "text"
print(show(1)) # numberprint(show("a")) # textprint(show(1.5)) # otherClasses: what else you need to know
How a class field differs from an object field
When reading user.role, Python looks for the attribute first on the object and then on the class. A write always creates the attribute on the object. Because of this, a class field holding a list ends up shared by all objects.
class User: role = "guest" tags = []
a = User()b = User()
# Reading the class field through the object.print(a.role) # guest
# The write created a field on a; the class and b are as before.a.role = "admin"print(b.role) # guest
# A trap: this is not a write but a change to the shared list.a.tags.append("x")print(b.tags) # ['x']
# An object's fields live in a dictionary, so they can be added on the fly.a.city = "Kyiv"print(a.__dict__) # {'role': 'admin', 'city': 'Kyiv'}A dictionary per object takes memory. If there are millions of objects, fields are declared in advance through __slots__. As a bonus, a stray field with a typo can no longer be added. A dataclass has the parameter slots=True for this.
class Point: __slots__ = ("x", "y")
def __init__(self, x, y): self.x = x self.y = y
point = Point(1, 2)try: point.z = 3except AttributeError as e: print(type(e).__name__) # AttributeErrorHow multiple inheritance works
In Java class inheritance is single, and shared behaviour moves to default interface methods. In Python a class can have several parents. The order in which a method is looked up is called the MRO (Method Resolution Order). It is linear, and you can inspect it. An important point: super() calls not the parent but the next class in this order.
class Base: def run(self): print("Base") # 3. Base
class Left(Base): def run(self): print("Left") # 1. Left super().run()
class Right(Base): def run(self): print("Right") # 2. Right super().run()
class Both(Left, Right): pass
# The order: Both, Left, Right, Base, object. After Left comes Right, not Base.names = [c.__name__ for c in Both.__mro__]print(names) # ['Both', 'Left', 'Right', 'Base', 'object']Both().run()In practice multiple inheritance is used for mixins. A mixin is a small class with a single capability that is mixed into the main one. It is the closest thing to default interface methods.
interface Jsonable { Map<String, Object> fields();
default String toJson() { return fields().toString(); }}
record User(String name) implements Jsonable { public Map<String, Object> fields() { return Map.of("name", name); }}
System.out.println(new User("Ann").toJson()); // {name=Ann}import json
class JsonMixin: def to_json(self): return json.dumps(self.__dict__)
class User(JsonMixin): def __init__(self, name): self.name = name
print(User("Ann").to_json()) # {"name": "Ann"}How to access a field by name
Reflection in Python is simple and built into the language. To access an attribute by name there are the functions getattr, setattr and hasattr.
class User { public String name = "Ann";
public String greet() { return "Hi, " + name; }}
var user = new User();var field = User.class.getField("name");System.out.println(field.get(user)); // Ann
var method = User.class.getMethod("greet");System.out.println(method.invoke(user)); // Hi, Annclass User: name = "Ann"
def greet(self): return f"Hi, {self.name}"
user = User()print(getattr(user, "name")) # Annprint(getattr(user, "age", 0)) # 0print(hasattr(user, "greet")) # True
# A method is an ordinary attribute: get it and call it.print(getattr(user, "greet")()) # Hi, Ann
# Methods can be replaced at run time. This is monkey patching.User.greet = lambda self: "Hello"print(user.greet()) # HelloThe __getattr__ method is called when an attribute is not found the normal way. With it a proxy takes a couple of lines, where Java needs java.lang.reflect.Proxy and an interface.
class Logged: def __init__(self, target): self._target = target
# 1. Logged has no append attribute, so __getattr__ is called. # 2. It takes the attribute from the real object. def __getattr__(self, name): print(f"access {name}") # access append return getattr(self._target, name)
items = Logged([])
# 3. The method obtained is called with the argument 1.items.append(1)What else you will see in other people's code
These are rarely needed, but framework code has them.
| What | What it does | Java counterpart |
|---|---|---|
__new__ | creates an object, while __init__ fills it | none |
| Descriptor | an object with __get__ and __set__ in a class field. This is how property and methods work | none |
| Metaclass | a class that creates classes. This is how Django builds models from their fields | annotation processing and bytecode generation |
__init_subclass__ | called when a subclass is declared, handy for registration | ServiceLoader |
__del__ | a finalizer you cannot rely on; use with instead | finalize |
Type hints
Does Python check types
No. Annotations are stored but have no effect at run time. A separate program checks them, most often mypy or pyright, run like a linter in CI. It amounts to the Java compiler, only optional. In services annotations are written everywhere, and checking runs in strict mode.
def double(value: int) -> int: return value * 2
# Python runs this with no error. Only mypy reports it.print(double("ab")) # abab
# Annotations can be read. Pydantic and FastAPI are built on this.print(double.__annotations__["value"].__name__) # intHow types are written
| Java | Python |
|---|---|
int, String | int, str |
List<String> | list[str] |
Map<String, Integer> | dict[str, int] |
Set<Long> | set[int] |
int[] | list[int] |
| a pair of values | tuple[str, int] |
| an immutable list | tuple[str, ...] |
@Nullable String | str | None |
Object | object |
| no checking at all | Any |
Collection<? extends T> | Iterable[T], Sequence[T] |
Function<Integer, String> | Callable[[int], str] |
Class<T> | type[T] |
void | None |
sealed interface with variants | A | B |
| a limited set of strings | Literal["asc", "desc"] |
| returns this | Self |
| never returns | NoReturn |
typealias: not in Java | type UserId = int |
In older code types look different. When you meet these, know that they mean the same thing.
| Old form | New form | Since version |
|---|---|---|
List[str], Dict[str, int] | list[str], dict[str, int] | 3.9 |
Optional[str] | str | None | 3.10 |
Union[int, str] | int | str | 3.10 |
T = TypeVar("T"), Generic[T] | class Box[T]: | 3.12 |
A Sequence[T] parameter accepts both a list and a tuple and cannot be changed. This is the same idea as List<? extends T>: to accept subtypes, a parameter must be read-only. So function parameters are better with broad types such as Iterable and Sequence, and results with a concrete list.
from collections.abc import Callable, Iterable
def total(items: Iterable[int]) -> int: return sum(items)
# Any iterable collection fits.print(total([1, 2])) # 3print(total({3, 4})) # 7
def apply(func: Callable[[int], int], value: int) -> int: return func(value)
print(apply(lambda x: x + 1, 1)) # 2Iterators and generators
How the for loop works
These are the same Iterable and Iterator as in Java. The __iter__ method gives an iterator. The __next__ method gives the next value, and the end is signalled not through hasNext but with a StopIteration exception.
var iterator = List.of("a", "b").iterator();
while (iterator.hasNext()) { System.out.print(iterator.next()); // ab}# 1. for calls iter(items).iterator = iter(["a", "b"])
# 2. On each step it calls next(iterator).print(next(iterator)) # aprint(next(iterator)) # b
# 3. StopIteration ends the for loop; nobody sees it.print(next(iterator, "end")) # endWhat a generator is
Writing an iterator in Java means a class with fields holding the state and two methods. Python has a generator for this: a function with yield instead of return. It gives out a value and freezes on that line, keeping all its variables, and on the next request continues from the same place. The numbers show the order of execution.
def numbers(): print("start") # 2. start yield 1 print("resume") # 4. resume yield 2 print("end") # 6. end
# 1. The call only creates a generator; the body does not run yet.gen = numbers()
# 2. next starts the body and stops at the first yield.print(next(gen)) # 3. 1
# 4. The next next resumes from where it stopped.print(next(gen)) # 5. 2
# 6. The body reached its end: that is StopIteration, so no more values.print(next(gen, "no more")) # 7. no moreCompare the same task: yielding the numbers from start to end.
class Range implements Iterable<Integer> { final int from, to;
Range(int from, int to) { this.from = from; this.to = to; }
public Iterator<Integer> iterator() { return new Iterator<>() { int current = from;
public boolean hasNext() { return current < to; }
public Integer next() { return current++; } }; }}
for (int n : new Range(1, 3)) { System.out.print(n); // 12}def count_range(start, end): current = start while current < end: yield current current += 1
for n in count_range(1, 3): print(n) # 1 2What generators are for
They are lazy, like Java streams: a value appears only when requested. So you can describe an infinite sequence, and a large file can pass through a chain of functions without being loaded into memory.
var firstFive = Stream.iterate(0, n -> n + 2) .limit(5) .toList();System.out.println(firstFive); // [0, 2, 4, 6, 8]from itertools import islice
# An endless loop is safe here: the code waits on yield until asked.def evens(): n = 0 while True: yield n n += 2
# islice is limit.print(list(islice(evens(), 5))) # [0, 2, 4, 6, 8]var lines = Stream.of("1", "x", "2");
int total = lines .filter(line -> line.matches("\\d+")) .mapToInt(Integer::parseInt) .sum();System.out.println(total); // 3lines = ["1", "x", "2"]
# Parentheses: a generator expression. Each step is lazy, as in a stream.digits = (line for line in lines if line.isdigit())numbers = (int(line) for line in digits)
# The work starts only here.print(sum(numbers)) # 3The difference between square and round brackets is the main thing to remember here. The first form builds the whole list in memory at once; the second builds nothing.
import sys
as_list = [n for n in range(1_000_000)]as_gen = (n for n in range(1_000_000))
print(sys.getsizeof(as_list) > 1_000_000) # Trueprint(sys.getsizeof(as_gen) < 1_000) # TrueA generator, like a stream, can be walked only once. A second pass raises no error; it is simply empty. The same goes for the results of map, filter and zip.
var stream = Stream.of(1, 2);stream.toList();
// Again: IllegalStateException.// stream.toList();gen = (n for n in [1, 2])print(list(gen)) # [1, 2]
# No error, just empty.print(list(gen)) # []
doubled = map(lambda n: n * 2, [1, 2])print(sum(doubled)) # 6print(sum(doubled)) # 0What else generators can do
yield from passes on the values of another generator. The send method pushes a value inside, into the spot stopped at yield. Asynchrony used to be built on this mechanism; today you rarely meet it.
def chain_all(first, second):
# Replaces a loop with a yield for each value. yield from first yield from second
print(list(chain_all([1], [2]))) # [1, 2]
def averager(): total = 0 count = 0 average = None while True:
# First gives a value out, then receives a new one from send. value = yield average total += value count += 1 average = total / count
avg = averager()
# The first next brings the generator to the first yield.next(avg)print(avg.send(10)) # 10.0print(avg.send(20)) # 15.0Ready-made lazy operations live in the itertools module.
from itertools import batched, chain, groupby, islice, takewhile # Python
Stream.of(1, 2, 3).limit(2).toList() # Java: [1, 2]list(islice([1, 2, 3], 2)) # Python: [1, 2]
Stream.concat(Stream.of(1), Stream.of(2)).toList() # Java: [1, 2]list(chain([1], [2])) # Python: [1, 2]
Stream.of(1, 2, 3).takeWhile(i -> i < 3).toList() # Java: [1, 2]list(takewhile(lambda i: i < 3, [1, 2, 3])) # Python: [1, 2]
# Split into batches of 2.list(batched([1, 2, 3], 2)) # Python: [(1, 2), (3,)]
# Groups only consecutive values, so sort first.[k for k, _ in groupby(sorted("abab"))] # Python: ['a', 'b']Threads and processes
What the GIL is
The GIL (Global Interpreter Lock) is one shared lock for the whole process. Only the thread holding it can run Python code, so only one thread works at a time, however many cores there are. The threads themselves are real, system threads, as in Java. While waiting for the network, the disk or sleep, a thread releases the lock and others run.
The takeaway is simple: threads speed up waiting but not computation. Computation needs processes, each of which has its own GIL.
| Task | Java | Python |
|---|---|---|
| Lots of waiting on network or database | threads, virtual threads | asyncio or threads |
| Computation on all cores | threads | processes |
| A blocking library | threads | threads |
How to start a thread
The Thread class is almost the same. Instead of implementing Runnable, you pass it a function and its arguments.
void work(String name) { System.out.println("work " + name); // work a}
var thread = new Thread(() -> work("a"), "worker");thread.setDaemon(true);thread.start();thread.join();
System.out.println(thread.getName()); // workerSystem.out.println(thread.isAlive()); // falseimport threading
def work(name): print(f"work {name}") # work a
# args is a tuple of arguments, so the comma is required.thread = threading.Thread(target=work, args=("a",), name="worker", daemon=True)thread.start()thread.join()
print(thread.name) # workerprint(thread.is_alive()) # FalseHere is what "threads speed up waiting" means. Four threads sleep for 0.1 seconds each, and the total time is also about 0.1 seconds. Replace sleep with counting in a loop, and they run strictly in turn.
import threadingimport time
def wait(): time.sleep(0.1)
threads = [threading.Thread(target=wait) for _ in range(4)]start = time.perf_counter()
for thread in threads: thread.start()for thread in threads: thread.join()
print(time.perf_counter() - start < 0.3) # TrueIs synchronization needed when there is a GIL
Yes. The GIL protects the interpreter's own internals, not your code. The operation counter += 1 has several steps, and a thread can lose the GIL between them. As in Java, a lock solves this. The form with lock: corresponds to synchronized: the lock is released on leaving the block, even after an error.
Thread A Thread B1. read counter = 5 2. read counter = 5 3. wrote counter = 64. wrote counter = 6Two increments, yet the counter grew by 1.class Counter { private int value;
synchronized void add() { value++; }
synchronized int get() { return value; }}
var counter = new Counter();var threads = new ArrayList<Thread>();for (int i = 0; i < 4; i++) { threads.add(Thread.ofPlatform().start(() -> { for (int j = 0; j < 10_000; j++) { counter.add(); } }));}for (var thread : threads) { thread.join();}System.out.println(counter.get()); // 40000import threading
class Counter: def __init__(self): self.value = 0 self._lock = threading.Lock()
def add(self): with self._lock: self.value += 1
def run(counter): for _ in range(10_000): counter.add()
counter = Counter()threads = [threading.Thread(target=run, args=(counter,)) for _ in range(4)]for thread in threads: thread.start()for thread in threads: thread.join()print(counter.value) # 40000Careful: Lock in Python is not reentrant, meaning the same thread cannot take it twice. synchronized and ReentrantLock in Java allow this. The exact counterpart is RLock.
import threading
# A plain Lock: a second acquire in the same thread would hang forever.lock = threading.Lock()lock.acquire()print(lock.acquire(blocking=False)) # Falselock.release()
# An RLock can be taken again.rlock = threading.RLock()with rlock: with rlock: print("inside") # insideWhat replaces ExecutorService
A thread pool works the same way. submit returns a Future, and result() is get(). Instead of try with shutdown, with is used: on leaving the block the pool waits for all tasks and closes.
int square(int n) { return n * n;}
try (var pool = Executors.newFixedThreadPool(4)) { Future<Integer> future = pool.submit(() -> square(3)); System.out.println(future.get()); // 9
var tasks = new ArrayList<Callable<Integer>>(); for (int n : List.of(1, 2)) { tasks.add(() -> square(n)); } for (var done : pool.invokeAll(tasks)) { System.out.print(done.get() + " "); // 1 4 }}from concurrent.futures import ThreadPoolExecutor, as_completed
def square(n): return n * n
with ThreadPoolExecutor(max_workers=4) as pool: future = pool.submit(square, 3) print(future.result()) # 9
# map: results in the order of the arguments. print(list(pool.map(square, [1, 2]))) # [1, 4]
# as_completed: in order of completion, like CompletionService. futures = [pool.submit(square, n) for n in [1, 2]] print(sorted(f.result() for f in as_completed(futures))) # [1, 4]An exception from a task behaves as in Java: it is stored in the Future and raised when result() is called. If nobody asks for the result, the error is silently lost.
What corresponds to what in java.util.concurrent
| Java | Python |
|---|---|
Thread | threading.Thread |
synchronized, ReentrantLock | threading.RLock |
Semaphore | threading.Semaphore |
CountDownLatch | threading.Event, Barrier |
CyclicBarrier | threading.Barrier |
Condition | threading.Condition |
BlockingQueue | queue.Queue |
ConcurrentHashMap | dict plus Lock |
AtomicInteger | int plus Lock |
ThreadLocal | threading.local |
ExecutorService | ThreadPoolExecutor |
ForkJoinPool | ProcessPoolExecutor |
Future | concurrent.futures.Future |
CompletableFuture | asyncio.Task |
volatile | not needed |
How to make a producer and a consumer
queue.Queue is a BlockingQueue: put waits if the queue is full, and get waits if it is empty. The numbers show the order of work. To finish, a special value goes into the queue; in Java it is called a poison pill.
var tasks = new ArrayBlockingQueue<Integer>(10);var results = new ArrayList<Integer>();
var consumer = Thread.ofPlatform().start(() -> { try { int item; while ((item = tasks.take()) != -1) { results.add(item * 2); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); }});
tasks.put(1);tasks.put(2);tasks.put(-1);consumer.join();System.out.println(results); // [2, 4]import queueimport threading
tasks = queue.Queue(maxsize=10)results = []
def consume():
# 2. Take from the queue; if empty, the thread waits. while (item := tasks.get()) is not None: results.append(item * 2)
consumer = threading.Thread(target=consume)consumer.start()
# 1. Put the tasks.tasks.put(1)tasks.put(2)
# 3. None says there is no more work.tasks.put(None)consumer.join()print(results) # [2, 4]How to stop a thread
Python has no interrupt method, and a thread cannot be stopped from outside. The thread itself checks a flag, and Event is there for that. Its wait method also replaces sleep, waking up as soon as the flag is set.
var worker = Thread.ofPlatform().start(() -> { while (!Thread.currentThread().isInterrupted()) { try { Thread.sleep(10_000); } catch (InterruptedException e) { return; } }});
worker.interrupt();worker.join();System.out.println(worker.isAlive()); // falseimport threading
stop = threading.Event()
def worker():
# Wait 10 seconds or for the stop signal, whichever comes first. while not stop.wait(timeout=10): pass
thread = threading.Thread(target=worker)thread.start()
stop.set()thread.join()print(thread.is_alive()) # FalseHow to compute on all cores
ProcessPoolExecutor looks like a thread pool but starts separate processes. Each has its own interpreter, memory and GIL, so computation runs in parallel. The price: arguments and results are copied between processes through serialization (called pickle), so a lambda cannot be passed and there are no shared variables. The startup code must live inside if __name__ == "__main__", because a new process imports the file again.
var result = IntStream.of(1, 2, 3, 4) .parallel() .map(n -> n * n) .sum();System.out.println(result); // 30from concurrent.futures import ProcessPoolExecutor
def square(n): return n * n
if __name__ == "__main__": with ProcessPoolExecutor() as pool: print(sum(pool.map(square, [1, 2, 3, 4]))) # 30The GIL is gradually going away. Since 3.13 there is a separate interpreter build without it; since 3.14 that build is officially supported but not the default, and some libraries are not ready for it yet. In it, threads run in parallel, as in Java. Since 3.14 there is also InterpreterPoolExecutor, where each thread has its own isolated interpreter and GIL.
Asynchrony: asyncio
What asyncio is
It is a way to serve thousands of waiting operations on one thread. A thread in Python is costly and runs into the GIL, so services are usually built on asyncio. In Java Netty and WebFlux solve the same task, and now virtual threads too. You will need four terms:
- A coroutine is a function declared with
async def. It can pause and then continue working. awaitis a pause point. The coroutine says: "I am waiting, deal with the others for now".- The event loop is a dispatcher, one per thread. It picks which coroutine works next.
- A task is a coroutine scheduled on the event loop. It is the counterpart of
CompletableFuture.
Compare with virtual threads. The code does the same thing: two waiting operations run at the same time.
String fetch(String name) throws InterruptedException { Thread.sleep(100); return name + " done";}
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { var user = pool.submit(() -> fetch("user")); var orders = pool.submit(() -> fetch("orders")); System.out.println(user.get()); // user done System.out.println(orders.get()); // orders done}import asyncio
async def fetch(name): await asyncio.sleep(0.1) return f"{name} done"
async def main():
# gather starts both coroutines and waits for both. user, orders = await asyncio.gather(fetch("user"), fetch("orders")) print(user) # user done print(orders) # orders done
# asyncio.run creates an event loop and runs main in it.asyncio.run(main())How it works, step by step
There is one thread, and all the work takes turns. Switching happens only at await. The numbers show the actual order of output.
import asyncio
async def first(): print("first starts") # 1. first starts
# first gives control to the loop, and it starts second. await asyncio.sleep(0.2) print("first ends") # 4. first ends
async def second(): print("second starts") # 2. second starts
# Now both wait, and the thread is free. await asyncio.sleep(0.1) print("second ends") # 3. second ends
async def main(): await asyncio.gather(first(), second())
asyncio.run(main())Why calling an async def does nothing
Calling fetch() does not run the code; it only creates a coroutine object. The code runs through await or create_task. The second trap is that await alone creates no concurrency: two awaits in a row run one after the other. For operations to run at once, you need tasks.
CompletableFuture<String> fetch(String name) { return CompletableFuture.supplyAsync(() -> name);}
var user = fetch("user");var orders = fetch("orders");System.out.println(user.join() + " " + orders.join()); // user ordersimport asyncioimport time
async def fetch(name): await asyncio.sleep(0.1) return name
async def one_by_one():
# The second starts only after the first: 0.2 seconds. start = time.perf_counter() await fetch("user") await fetch("orders") print(round(time.perf_counter() - start, 1)) # 0.2
async def at_once(): start = time.perf_counter()
# create_task queues the coroutine at once, like supplyAsync. user = asyncio.create_task(fetch("user")) orders = asyncio.create_task(fetch("orders"))
# await is join: wait for the result. print(await user, await orders) # user orders print(round(time.perf_counter() - start, 1)) # 0.1
asyncio.run(one_by_one())asyncio.run(at_once())What happens if you call a blocking function
This is the main mistake in async code. A plain time.sleep, a database driver or requests has no await, so it does not give up control. The event loop stands still, and all other requests wait. In WebFlux the same happens with a blocking call on a Netty thread. Such calls must go to a separate thread through asyncio.to_thread.
import asyncioimport time
async def bad():
# The whole event loop stands still: other tasks do not run for 0.1 seconds. time.sleep(0.1)
async def good():
# The function goes to another thread, and the loop keeps working. await asyncio.to_thread(time.sleep, 0.1)
async def measure(work): start = time.perf_counter() await asyncio.gather(work(), work()) return round(time.perf_counter() - start, 1)
print(asyncio.run(measure(bad))) # 0.2print(asyncio.run(measure(good))) # 0.1How to run a group of tasks properly
TaskGroup arrived in 3.11 and is the counterpart of StructuredTaskScope in Java. The rule is simple: tasks do not outlive the async with block. If one fails, the others are cancelled, and the errors arrive together in an exception group. A special form, except*, handles them. gather has none of this: on an error the other tasks keep running on their own.
import asyncio
async def ok(): await asyncio.sleep(0.2) print("never printed")
async def fail(): await asyncio.sleep(0.1) raise ValueError("boom")
async def main(): try: async with asyncio.TaskGroup() as tasks:
# 1. Start two tasks. tasks.create_task(ok()) tasks.create_task(fail())
# 2. fail failed, so ok is cancelled. # 3. except* catches the given type from the error group. except* ValueError as group: print(group.exceptions) # (ValueError('boom'),)
asyncio.run(main())How to set a timeout and cancel a task
Cancellation works like this: task.cancel() raises CancelledError inside the coroutine at its await. This resembles InterruptedException. It must not be caught and swallowed, or the task cannot stop, so cleanup goes in finally. CancelledError does not inherit from Exception, so a plain except Exception does not catch it.
var slow = CompletableFuture.supplyAsync(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { } return "done";});
try { slow.orTimeout(50, TimeUnit.MILLISECONDS).join();} catch (CompletionException e) { System.out.println(e.getCause() instanceof TimeoutException); // true}import asyncio
async def slow(): try: await asyncio.sleep(1) finally: print("cleanup") # 2. cleanup
async def main(): try:
# 1. After 0.05 seconds the task is cancelled. async with asyncio.timeout(0.05): await slow()
# 3. Outside, the cancellation becomes TimeoutError. except TimeoutError: print("timed out") # 3. timed out
asyncio.run(main())Why a task can disappear
The event loop keeps only weak references to tasks. If you start a task and do not keep what create_task returned, the garbage collector can delete it mid-run. Also, an error in such a task is seen by nobody. So keep the reference, or start tasks in a TaskGroup.
import asyncio
background = set()
def fire_and_forget(coroutine): task = asyncio.create_task(coroutine)
# Keep a reference while the task runs, then remove it. background.add(task) task.add_done_callback(background.discard)
async def notify(): print("sent") # sent
async def main(): fire_and_forget(notify()) await asyncio.sleep(0)
asyncio.run(main())How to limit the number of concurrent tasks
Creating a hundred thousand tasks is easy, but a database cannot take that many requests. As with virtual threads in Java, the limit comes from a semaphore.
var limit = new Semaphore(10);
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 100; i++) { pool.submit(() -> { limit.acquire(); try { return 1; } finally { limit.release(); } }); }}import asyncio
async def fetch(limit, n):
# No more than 10 tasks inside the block at once. async with limit: await asyncio.sleep(0) return n
async def main(): limit = asyncio.Semaphore(10) results = await asyncio.gather(*(fetch(limit, n) for n in range(100))) print(len(results)) # 100
asyncio.run(main())Are locks needed in asyncio
Between two awaits code runs without interruption, since there is one thread. So counter += 1 is safe here. A race appears only if there is an await between the read and the write. asyncio.Lock exists for that case, but a plain threading.Lock must not be used here: it would stop the whole event loop.
import asyncio
balance = 100
async def withdraw(lock, amount): global balance async with lock:
# 1. Read the balance. current = balance
# 2. Without the lock a second task gets here with the same balance. await asyncio.sleep(0)
# 3. Wrote the new balance. balance = current - amount
async def main(): lock = asyncio.Lock() await asyncio.gather(withdraw(lock, 10), withdraw(lock, 10)) print(balance) # 80
asyncio.run(main())What async with and async for are
These are the same with and for, except that their inner steps contain await themselves: opening a connection, say, or getting the next batch from the network. An async generator is written with yield, like a normal one, only inside async def. It resembles a Flux in WebFlux.
import asyncio
class Connection: async def __aenter__(self): await asyncio.sleep(0) print("open") # 1. open return self
async def __aexit__(self, exc_type, exc, tb): await asyncio.sleep(0) print("close") # 3. close
async def rows(): for row in [1, 2]: await asyncio.sleep(0) yield row
async def main(): async with Connection(): async for row in rows(): print(row) # 2. 1 2
asyncio.run(main())What replaces ThreadLocal
A plain threading.local will not do here: there is one thread, and all tasks would see one value. ContextVar exists for this. Each task gets its own copy of the context. This is the counterpart of ScopedValue in Java and MDC in logging: a request id need not be passed through every call.
var requestId = new ThreadLocal<String>();requestId.set("a");System.out.println(requestId.get());import asynciofrom contextvars import ContextVar
request_id = ContextVar("request_id", default="-")
def log(message): print(request_id.get(), message) # a work b work
async def handle(rid): request_id.set(rid) await asyncio.sleep(0) log("work")
async def main(): await asyncio.gather(handle("a"), handle("b"))
# In main itself the value did not change. print(request_id.get()) # -
asyncio.run(main())How to connect threads and asyncio
| From | To | How |
|---|---|---|
| Regular code | a coroutine | asyncio.run(main()) |
| A coroutine | a blocking function | await asyncio.to_thread(func) |
| Another thread | a coroutine | asyncio.run_coroutine_threadsafe(coro, loop) |
| Another thread | a plain function on the loop | loop.call_soon_threadsafe(func) |
How asyncio differs from virtual threads
The goal is the same: cheap waiting. The difference is that virtual threads hide this inside the JVM, and ordinary blocking code keeps working. In asyncio the asynchrony shows in the code: an async function can be called only from another such function. This is called "function colouring".
| Virtual threads | asyncio | |
|---|---|---|
| CPU cores | all | one |
| Where they switch | at any blocking call | only at await |
| Blocking code | works | stops everything |
| Races in code | as with ordinary threads | only across await |
| Cancellation | interrupt | CancelledError |
| Libraries | ordinary | must be async |
Dependencies and tests
What replaces Maven and Gradle
In Java dependencies sit in a shared ~/.m2 cache, and each project gets its versions through the classpath. In Python an installed library is visible to the whole interpreter copy, so each project has its own copy in a .venv directory. Such a directory is called a virtual environment. The project description lives in pyproject.toml and the exact versions in a lock file. Today the uv tool usually manages all of this.
| Java | Python |
|---|---|
pom.xml, build.gradle | pyproject.toml |
gradle.lockfile | uv.lock |
| Maven Central | PyPI |
mvn dependency:tree | uv tree |
gradle dependencies add | uv add httpx |
gradle build | uv build |
gradle test | uv run pytest |
| Checkstyle, Spotless | ruff |
javac as a type check | mypy |
.jar | .whl |
An import in Python looks for modules along the sys.path directory list, like the classpath in Java. Unlike in Java, a circular import is dangerous here: importing runs the file, so if a.py imports b.py and that imports a.py again, the second sees a half-run module and fails with ImportError. The cure is moving shared code to a third module.
What replaces JUnit
The de facto standard is pytest. A test is a plain function whose name starts with test_, and a plain assert replaces the assertEquals family. On failure, pytest itself shows what was compared with what. Instead of @BeforeEach there is a fixture: a function whose name the test gives as a parameter. Everything after yield in a fixture runs after the test, like @AfterEach.
class CartTest { private List<String> cart;
@BeforeEach void setUp() { cart = new ArrayList<>(); }
@Test void addsItem() { cart.add("tea"); assertEquals(1, cart.size()); }
@ParameterizedTest @CsvSource({"2, 4", "3, 9"}) void squares(int input, int expected) { assertEquals(expected, input * input); }
@Test void rejectsBadNumber() { assertThrows(NumberFormatException.class, () -> Integer.parseInt("x")); }}import pytest
# Replaces @BeforeEach: pytest calls it before each test.@pytest.fixturedef cart(): return []
# The parameter name matches the fixture name.def test_adds_item(cart): cart.append("tea") assert len(cart) == 1
@pytest.mark.parametrize("value, expected", [(2, 4), (3, 9)])def test_squares(value, expected): assert value * value == expected
def test_rejects_bad_number(): with pytest.raises(ValueError): int("x")What replaces Mockito
Mocks are in the standard library, in the unittest.mock module. A Mock accepts any call and remembers it. The patch function replaces a name in a given module for the duration of a test. An important rule: replace the name where it is used, not where it is declared.
var repository = mock(UserRepository.class);when(repository.find(1)).thenReturn("Ann");
assertEquals("Ann", repository.find(1));verify(repository).find(1);from unittest.mock import Mock
repository = Mock()repository.find.return_value = "Ann"
print(repository.find(1)) # Annrepository.find.assert_called_once_with(1)
# Make a method raise an exception: thenThrow.repository.save.side_effect = ConnectionError("down")Although patching works, code is easier to test as in Java: through dependencies passed to the constructor. The service at the end of the article is written this way.
Memory and performance
How Python manages memory
In the JVM objects live until the garbage collector finds them. In CPython every object has a reference count, and an object is deleted as soon as the count reaches zero. Circular references cannot be detected this way, so there is an additional cycle collector in the gc module.
| JVM | CPython | |
|---|---|---|
| When an object is deleted | when the collector gets there | at once, when no references remain |
| Moving objects | yes, the heap compacts | no |
| Size settings | -Xmx | none; the container limits it |
Size of int | 4 bytes | about 28 bytes |
What to do if it is slow
Measure first. cProfile is a simple profiler, and for a running service there is py-spy, which attaches from outside. What usually helps fastest is the right data structure and built-in functions written in C: sum, sorted, set. Heavy computation is done in C libraries such as NumPy, which release the GIL while working.
Service architecture in Python
What replaces Tomcat and the Servlet API
The scheme is the same: a server accepts connections, and a standard interface lets it call your application. There are two interfaces: WSGI for synchronous code and ASGI for asynchronous.
| Java | Python | What it is |
|---|---|---|
| Servlet API | WSGI | a synchronous server-application interface |
WebFlux HttpHandler | ASGI | an asynchronous interface |
| Tomcat, Jetty | gunicorn | a WSGI server |
| Netty | uvicorn | an ASGI server |
| Spring MVC | Django, Flask | synchronous frameworks |
| WebFlux | FastAPI | an asynchronous framework |
How the working model differs from Java
A Java service is one process with a thread pool and shared memory. Because of the GIL, a Python service is several processes, called workers. It follows that:
- Scaling. In Kubernetes you usually keep one worker per pod and add replicas.
- An in-memory cache is separate per worker. Shared data goes in Redis or the database.
- A connection pool is also separate per worker: 4 workers with 10 connections each make 40 database connections.
- A scheduler would start in every worker, so it moves to a separate process.
- Long computation in a request handler is unacceptable. It goes to a task queue such as Celery.
How to do without Spring
A dependency container is almost never needed. Dependencies come through the constructor, and instead of the Spring context there is one function at startup that creates objects and passes them to each other. This place is called the composition root. The layers are the same, only they run through modules rather than packages of classes.
| Spring | Python module | What it knows |
|---|---|---|
@Entity | domain.py | only the standard library |
| repository interfaces | ports.py | the domain |
@Service | service.py | the domain and ports |
@Repository | storage.py | the domain and the database |
@RestController | http_api.py | the service and HTTP |
@Configuration, main | __main__.py | everything |
Which Java patterns are not needed here
Many Java patterns work around language limits that Python lacks. For example, a strategy in Java is an interface and a class, while in Python it is just a function.
interface Discount { double apply(double price);}
class Seasonal implements Discount { public double apply(double price) { return price * 0.9; }}
double checkout(double price, Discount discount) { return discount.apply(price);}
System.out.println(checkout(100, new Seasonal())); // 90.0def seasonal(price): return price * 0.9
def checkout(price, discount): return discount(price)
print(checkout(100, seasonal)) # 90.0| Java | Python |
|---|---|
| Singleton | an object at module level |
| Factory | a function or the class itself |
| Builder | named arguments and replace |
| Strategy, Command | pass a function |
| Observer | a list of functions |
| Visitor | match, singledispatch |
| Decorator | @decorator |
| Iterator | a generator |
| Proxy | __getattr__ |
| Utility class | functions in a module |
| Getters and setters | a field, later @property |
Code written with Java habits is instantly visible. Here is what gives it away:
# Badfor i in range(len(items)): print(items[i])
# Goodfor item in items: print(item)
# Badif len(items) == 0: pass
# Goodif not items: pass
# Badif value == None or flag == True: pass
# Goodif value is None or flag: pass
# Baduser.get_name()
# Gooduser.nameA microservice on the standard library
What we are building
Let us put it all together and write an orders service. It has no third-party libraries, so that the language itself is visible. In a real project you would hand the HTTP server and routes to FastAPI, and PostgreSQL would replace SQLite, but only the files at the edges change. The service cancels unpaid orders older than an hour in the background.
| Request | What it does | Response |
|---|---|---|
GET /health | checks that the service is alive | 200 |
POST /orders | create an order | 201, 422 |
GET /orders?status=paid | list orders | 200 |
GET /orders/{id} | show an order | 200, 404 |
POST /orders/{id}/pay | pay | 200, 409 |
POST /orders/{id}/cancel | cancel | 200, 409 |
A request goes through the files from top to bottom, and each file knows only about its lower neighbour. The logic in the centre does not depend on the outside world. It talks to it through interfaces, called ports, and their implementations for a given technology are called adapters. In Spring this is a service working with a repository interface, unaware that JDBC is behind it.
http_server.py reads bytes, builds a Request object │ ▼http_api.py picks a route, parses JSON │ ▼service.py creates, pays, cancels an order │ │ ▼ ▼domain.py storage.pyrules SQLite queriesorders-service/├── pyproject.toml├── Dockerfile├── src/orders/│ ├── __init__.py an empty file marking a package│ ├── __main__.py startup and wiring of objects│ ├── config.py settings from the environment│ ├── domain.py the order and its rules│ ├── ports.py what the service needs from outside│ ├── service.py business logic│ ├── storage.py the SQLite adapter│ ├── http_server.py an HTTP server on asyncio│ ├── http_api.py routes and errors│ └── logs.py JSON logs└── tests/ ├── test_service.py └── test_api.pyHow to describe the project
The pyproject.toml file is the counterpart of pom.xml.
[project]name = "orders-service"version = "0.1.0"requires-python = ">=3.12"
# No third-party libraries are needed to run.dependencies = []
# Creates an orders-service command that calls main.[project.scripts]orders-service = "orders.__main__:main"
# Like testImplementation in Gradle: they stay out of the image.[dependency-groups]dev = ["mypy>=1.13", "pytest>=8", "ruff>=0.8"]
[build-system]requires = ["hatchling"]build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]packages = ["src/orders"]
[tool.pytest.ini_options]testpaths = ["tests"]
[tool.mypy]strict = truefiles = ["src", "tests"]
[tool.ruff]line-length = 88
[tool.ruff.lint]select = ["E", "F", "I", "UP", "B", "ASYNC", "G"]How to describe an order
Let us start from the centre. This file replaces an Order class with business methods. It knows nothing of HTTP or the database.
from dataclasses import dataclass, replacefrom datetime import datetimefrom decimal import Decimalfrom enum import StrEnumfrom typing import Self
class OrderStatus(StrEnum): NEW = "new" PAID = "paid" CANCELLED = "cancelled"
# A common parent: the web layer catches all errors with one except.class DomainError(Exception): pass
class OrderNotFound(DomainError): pass
class InvalidOrder(DomainError): pass
class IllegalTransition(DomainError): pass
# An immutable record: pay and cancel return a new order.@dataclass(frozen=True, slots=True)class Order: id: str customer: str amount: Decimal status: OrderStatus created_at: datetime
# A check on creation. NaN and Infinity are valid Decimals too. def __post_init__(self) -> None: if not self.customer.strip(): raise InvalidOrder("customer must not be empty") if not self.amount.is_finite() or self.amount <= 0: raise InvalidOrder("amount must be a positive number")
def pay(self) -> Self: if self.status is not OrderStatus.NEW: raise IllegalTransition(f"cannot pay an order that is {self.status}") return replace(self, status=OrderStatus.PAID)
def cancel(self) -> Self: if self.status is not OrderStatus.NEW: raise IllegalTransition(f"cannot cancel an order that is {self.status}") return replace(self, status=OrderStatus.CANCELLED)How to separate logic from the database
This is the counterpart of an OrderRepository interface. An implementation does not write implements, so tests can use a simple fake that inherits from nothing.
from collections.abc import Sequencefrom typing import Protocol
from .domain import Order, OrderStatus
# The methods are async: while the database answers, the loop serves others.class OrderRepository(Protocol): async def add(self, order: Order) -> None: ...
async def get(self, order_id: str) -> Order | None: ...
async def find( self, status: OrderStatus | None, limit: int ) -> Sequence[Order]: ...
# Saves the new status only if the database still holds expected. async def update_status(self, order: Order, expected: OrderStatus) -> bool: ...How to write the business logic
This is the counterpart of a class with @Service. In Java the clock would be a Clock, and identifiers an interface with an implementation. Here passing a function is enough, and a test easily supplies another one.
from collections.abc import Callable, Sequencefrom datetime import UTC, datetime, timedeltafrom decimal import Decimalfrom uuid import uuid4
from .domain import IllegalTransition, Order, OrderNotFound, OrderStatusfrom .ports import OrderRepository
def _utc_now() -> datetime: return datetime.now(UTC)
def _new_id() -> str: return uuid4().hex
class OrderService:
# Parameters after * are passed by name only. def __init__( self, repository: OrderRepository, *, clock: Callable[[], datetime] = _utc_now, new_id: Callable[[], str] = _new_id, ) -> None: self._repository = repository self._clock = clock self._new_id = new_id
async def create(self, customer: str, amount: Decimal) -> Order: order = Order( self._new_id(), customer, amount, OrderStatus.NEW, self._clock() ) await self._repository.add(order) return order
async def get(self, order_id: str) -> Order: order = await self._repository.get(order_id) if order is None: raise OrderNotFound(f"no order {order_id}") return order
async def find( self, status: OrderStatus | None, limit: int = 100 ) -> Sequence[Order]: return await self._repository.find(status, limit)
# Order.pay is an ordinary function whose first parameter is self. async def pay(self, order_id: str) -> Order: return await self._change(order_id, Order.pay)
async def cancel(self, order_id: str) -> Order: return await self._change(order_id, Order.cancel)
async def expire_unpaid(self, older_than: timedelta) -> int: cutoff = self._clock() - older_than expired = 0 for order in await self._repository.find(OrderStatus.NEW, limit=1000): if order.created_at < cutoff:
# bool is an int, so True adds 1. expired += await self._repository.update_status( order.cancel(), OrderStatus.NEW ) return expired
async def _change( self, order_id: str, transition: Callable[[Order], Order] ) -> Order:
# 1. Read the order. order = await self.get(order_id)
# 2. Checked the rules and got a new order. changed = transition(order)
# 3. A conditional write: another request may have changed it during the await. if not await self._repository.update_status(changed, expected=order.status): raise IllegalTransition(f"order {order_id} was changed concurrently") return changedStep 3 of _change is called optimistic locking: the record is not locked in advance, and on saving, it is checked for changes. In JPA @Version solves the same task. Here is what would happen without it on two simultaneous payments:
Request A Request B1. reads the order: new 2. reads the order: new3. UPDATE ... WHERE status='new' 1 row updated, success 4. UPDATE ... WHERE status='new' 0 rows updated, error 409How to call a database from async code
This is the counterpart of a class with @Repository. The sqlite3 driver blocks, so each call goes to a separate thread. For PostgreSQL you would take an async driver, and to_thread would vanish while the port stays unchanged.
import asyncioimport sqlite3from collections.abc import Callable, Sequencefrom contextlib import closingfrom datetime import datetimefrom decimal import Decimalfrom pathlib import Path
from .domain import Order, OrderStatus
_SCHEMA = """ CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, customer TEXT NOT NULL, amount TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS orders_status ON orders (status);"""
def _to_order(row: sqlite3.Row) -> Order: return Order( id=row["id"], customer=row["customer"], amount=Decimal(row["amount"]), status=OrderStatus(row["status"]), created_at=datetime.fromisoformat(row["created_at"]), )
class SqliteOrderRepository: def __init__(self, path: Path) -> None: self._path = path
async def migrate(self) -> None: await self._run(lambda c: c.executescript(_SCHEMA))
async def add(self, order: Order) -> None: values = ( order.id, order.customer, str(order.amount), order.status, order.created_at.isoformat(), ) query = "INSERT INTO orders VALUES (?, ?, ?, ?, ?)" await self._run(lambda c: c.execute(query, values))
async def get(self, order_id: str) -> Order | None: query = "SELECT * FROM orders WHERE id = ?" row = await self._run(lambda c: c.execute(query, (order_id,)).fetchone()) return _to_order(row) if row else None
async def find(self, status: OrderStatus | None, limit: int) -> Sequence[Order]: where, args = ("WHERE status = ?", [status]) if status else ("", []) query = f"SELECT * FROM orders {where} ORDER BY created_at LIMIT ?" rows = await self._run(lambda c: c.execute(query, (*args, limit)).fetchall()) return [_to_order(row) for row in rows]
async def update_status(self, order: Order, expected: OrderStatus) -> bool: query = "UPDATE orders SET status = ? WHERE id = ? AND status = ?" values = (order.status, order.id, expected) return await self._run(lambda c: c.execute(query, values).rowcount) == 1
# Like TransactionTemplate: open, run, commit or roll back. async def _run[T](self, work: Callable[[sqlite3.Connection], T]) -> T: def in_thread() -> T: connection = sqlite3.connect(self._path, timeout=5.0) connection.row_factory = sqlite3.Row
# with connection commits or rolls back, and closing closes it. with closing(connection), connection: return work(connection)
return await asyncio.to_thread(in_thread)How to add a request id to logs
This is the role of Logback and MDC. Each request is handled in its own task, so it has its own ContextVar value. The code writing to the log knows nothing about the id.
import jsonimport loggingfrom contextvars import ContextVar
request_id: ContextVar[str] = ContextVar("request_id", default="-")
# One log record is one JSON line, easy for log collectors to parse.class JsonFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: entry = { "time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), "level": record.levelname, "logger": record.name, "request_id": request_id.get(), "message": record.getMessage(), } if record.exc_info: entry["exception"] = self.formatException(record.exc_info) return json.dumps(entry, ensure_ascii=False)
def configure_logging(level: str) -> None: handler = logging.StreamHandler() handler.setFormatter(JsonFormatter()) logging.basicConfig(level=level, handlers=[handler], force=True)How to write an HTTP server on asyncio
In a real project uvicorn does this work. It is written here to show asyncio on a real task. An HTTP request is plain text: a request line, headers, an empty line and a body.
POST /orders HTTP/1.1Host: localhost:8080Content-Length: 38
{"customer": "ann", "amount": "19.99"}import asyncioimport jsonimport loggingfrom collections.abc import Awaitable, Callablefrom contextlib import suppressfrom dataclasses import dataclassfrom http import HTTPStatusfrom typing import Anyfrom urllib.parse import parse_qsl, urlsplit
log = logging.getLogger(__name__)
MAX_BODY = 1024 * 1024
@dataclass(frozen=True, slots=True)class Request: method: str path: str query: dict[str, str] headers: dict[str, str] body: bytes
@dataclass(frozen=True, slots=True)class Response: status: HTTPStatus payload: Any = None
def encode(self) -> bytes: body = ( b"" if self.payload is None else json.dumps(self.payload).encode("utf-8") ) head = ( f"HTTP/1.1 {self.status.value} {self.status.phrase}\r\n" "Content-Type: application/json\r\n" f"Content-Length: {len(body)}\r\n" "Connection: close\r\n\r\n" )
# The protocol requires latin-1 for headers. return head.encode("latin-1") + body
# A type alias: any async function from Request to Response.type Handler = Callable[[Request], Awaitable[Response]]
async def read_request(reader: asyncio.StreamReader) -> Request:
# 1. Read bytes up to the empty line: these are the headers. head = await reader.readuntil(b"\r\n\r\n") request_line, *header_lines = head.decode("latin-1").split("\r\n") method, target, _version = request_line.split(" ")
# 2. Parse the headers into a dictionary. headers = {} for line in header_lines: name, separator, value = line.partition(":") if separator: headers[name.strip().lower()] = value.strip()
# 3. Learn the body length from Content-Length and read it. length = int(headers.get("content-length", "0")) if length > MAX_BODY: raise ValueError("body is too large") body = await reader.readexactly(length)
url = urlsplit(target) return Request(method, url.path, dict(parse_qsl(url.query)), headers, body)
class HttpServer: def __init__( self, handler: Handler, host: str, port: int, *, max_concurrent: int = 100, read_timeout: float = 5.0, handler_timeout: float = 30.0, ) -> None: self._handler = handler self._host = host self._port = port self._slots = asyncio.Semaphore(max_concurrent) self._read_timeout = read_timeout self._handler_timeout = handler_timeout
# The loop keeps only weak references to tasks, so we hold our own. self._connections: set[asyncio.Task[Any]] = set() self._server: asyncio.Server | None = None
# For each connection asyncio creates a task running _on_connect. async def start(self) -> int: self._server = await asyncio.start_server( self._on_connect, self._host, self._port ) return int(self._server.sockets[0].getsockname()[1])
async def stop(self, grace: float) -> None: assert self._server is not None
# 1. Stop accepting new connections. self._server.close()
# 2. Give the current ones time to finish, and cancel the rest. if self._connections: _, unfinished = await asyncio.wait(self._connections, timeout=grace) for task in unfinished: task.cancel() await asyncio.gather(*unfinished, return_exceptions=True) await self._server.wait_closed()
async def _on_connect( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: task = asyncio.current_task() assert task is not None self._connections.add(task) task.add_done_callback(self._connections.discard) try: response = await self._respond(reader) if response is not None: writer.write(response.encode())
# If the client reads slowly, the task waits here. await writer.drain() except ConnectionError: log.info("client disconnected") finally: writer.close() with suppress(ConnectionError): await writer.wait_closed()
async def _respond(self, reader: asyncio.StreamReader) -> Response | None:
# The timeout guards against a client that connects and sends nothing. try: async with asyncio.timeout(self._read_timeout): request = await read_request(reader) except asyncio.IncompleteReadError: return None except (ValueError, asyncio.LimitOverrunError): return Response(HTTPStatus.BAD_REQUEST, {"error": "malformed request"}) except TimeoutError: return Response( HTTPStatus.REQUEST_TIMEOUT, {"error": "request not read in time"} )
# The semaphore guards against overload, the timeout against a stuck call. async with self._slots: try: async with asyncio.timeout(self._handler_timeout): return await self._handler(request) except TimeoutError: log.warning("timed out: %s %s", request.method, request.path) return Response(HTTPStatus.GATEWAY_TIMEOUT, {"error": "timed out"})
# CancelledError does not land here: cancellation must pass through. except Exception: log.exception("failed: %s %s", request.method, request.path) return Response( HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal error"} )How to make routes and error handling
This is the role of @RestController and @ControllerAdvice. Routes are conveniently chosen with match: the pattern "GET", ["orders", order_id] fits GET /orders/42 and immediately puts 42 in the order_id variable.
import jsonimport loggingfrom decimal import Decimal, InvalidOperationfrom http import HTTPStatusfrom uuid import uuid4
from .domain import ( DomainError, IllegalTransition, InvalidOrder, Order, OrderNotFound, OrderStatus,)from .http_server import Request, Responsefrom .logs import request_idfrom .service import OrderService
log = logging.getLogger(__name__)
_ERROR_STATUS: dict[type[DomainError], HTTPStatus] = { OrderNotFound: HTTPStatus.NOT_FOUND, InvalidOrder: HTTPStatus.UNPROCESSABLE_ENTITY, IllegalTransition: HTTPStatus.CONFLICT,}
def _to_json(order: Order) -> dict[str, str]: return { "id": order.id, "customer": order.customer, "amount": str(order.amount), "status": order.status, "created_at": order.created_at.isoformat(), }
def _parse_new_order(body: bytes) -> tuple[str, Decimal]: try: payload = json.loads(body) except ValueError as e: raise InvalidOrder("body is not valid JSON") from e
# The pattern checks both fields and types. # The amount is a string: a JSON number loses precision. match payload: case {"customer": str(customer), "amount": str(amount)}: try: return customer, Decimal(amount) except InvalidOperation as e: raise InvalidOrder(f"bad amount: {amount!r}") from e case _: raise InvalidOrder("expected strings: customer, amount")
class OrdersApi: def __init__(self, service: OrderService) -> None: self._service = service
# The object can be called as a function, so it fits Handler. async def __call__(self, request: Request) -> Response: request_id.set(request.headers.get("x-request-id") or uuid4().hex) try: response = await self._route(request)
# Like @ControllerAdvice: a business error becomes a response code. except DomainError as e: response = Response(_ERROR_STATUS[type(e)], {"error": str(e)}) log.info("%s %s -> %d", request.method, request.path, response.status) return response
async def _route(self, request: Request) -> Response: match request.method, request.path.strip("/").split("/"): case "GET", ["health"]: return Response(HTTPStatus.OK, {"status": "ok"}) case "POST", ["orders"]: customer, amount = _parse_new_order(request.body) order = await self._service.create(customer, amount) return Response(HTTPStatus.CREATED, _to_json(order)) case "GET", ["orders"]: return await self._list(request) case "GET", ["orders", order_id]: return Response( HTTPStatus.OK, _to_json(await self._service.get(order_id)) ) case "POST", ["orders", order_id, "pay"]: return Response( HTTPStatus.OK, _to_json(await self._service.pay(order_id)) ) case "POST", ["orders", order_id, "cancel"]: return Response( HTTPStatus.OK, _to_json(await self._service.cancel(order_id)) ) case _: return Response(HTTPStatus.NOT_FOUND, {"error": "no such route"})
async def _list(self, request: Request) -> Response: raw = request.query.get("status") try: status = OrderStatus(raw) if raw else None except ValueError as e: raise InvalidOrder(f"unknown status: {raw!r}") from e orders = await self._service.find(status) return Response(HTTPStatus.OK, [_to_json(order) for order in orders])How to read settings
This is the role of @ConfigurationProperties: an immutable object read once from environment variables. A test can pass a plain dictionary instead of the environment.
import osfrom collections.abc import Mappingfrom dataclasses import dataclassfrom datetime import timedeltafrom pathlib import Path
@dataclass(frozen=True)class Settings: host: str = "0.0.0.0" port: int = 8080 database: Path = Path("orders.db") log_level: str = "INFO" unpaid_ttl: timedelta = timedelta(hours=1) shutdown_grace: float = 10.0
@classmethod def from_env(cls, env: Mapping[str, str] = os.environ) -> "Settings": return cls( host=env.get("ORDERS_HOST", cls.host), port=int(env.get("ORDERS_PORT", cls.port)), database=Path(env.get("ORDERS_DATABASE", cls.database)), log_level=env.get("ORDERS_LOG_LEVEL", cls.log_level), )Where all the objects are put together
This file is the whole dependency container. You can read it from top to bottom and see what connects to what. The numbers show how the service shuts down: Docker sends the SIGTERM signal, and the service finishes the current requests.
import asyncioimport loggingimport signalfrom contextlib import suppressfrom datetime import timedelta
from .config import Settingsfrom .http_api import OrdersApifrom .http_server import HttpServerfrom .logs import configure_loggingfrom .service import OrderServicefrom .storage import SqliteOrderRepository
log = logging.getLogger("orders")
# A background job, like @Scheduled(fixedDelay = ...).async def expire_loop( service: OrderService, stop: asyncio.Event, ttl: timedelta, every: float = 60.0) -> None: while not stop.is_set(): try: if expired := await service.expire_unpaid(ttl): log.info("expired %d unpaid orders", expired)
# One failure must not stop the job forever. except Exception: log.exception("expiration job failed")
# Wait a minute or for the stop signal. with suppress(TimeoutError): async with asyncio.timeout(every): await stop.wait()
async def run(settings: Settings) -> None: repository = SqliteOrderRepository(settings.database) await repository.migrate() service = OrderService(repository) server = HttpServer(OrdersApi(service), settings.host, settings.port)
# 1. Docker sends SIGTERM, and the handler sets the stop event. stop = asyncio.Event() loop = asyncio.get_running_loop() for sig in (signal.SIGINT, signal.SIGTERM): signal.signal(sig, lambda *_: loop.call_soon_threadsafe(stop.set))
port = await server.start() log.info("listening on %s:%d", settings.host, port)
# TaskGroup guarantees the background job does not outlive run. async with asyncio.TaskGroup() as tasks: tasks.create_task(expire_loop(service, stop, settings.unpaid_ttl))
# 2. Here run wakes up. await stop.wait() log.info("shutting down")
# 3. The server finishes the current requests and closes. await server.stop(settings.shutdown_grace) log.info("stopped")
def main() -> None: settings = Settings.from_env() configure_logging(settings.log_level) asyncio.run(run(settings))
if __name__ == "__main__": main()How to test the service
Unit tests work with a fake instead of a database. The InMemoryOrders class keeps orders in a dictionary. It nowhere inherits from OrderRepository, yet mypy checks that it fits.
import asynciofrom collections.abc import Sequencefrom datetime import UTC, datetime, timedeltafrom decimal import Decimal
import pytest
from orders.domain import ( IllegalTransition, InvalidOrder, Order, OrderNotFound, OrderStatus,)from orders.service import OrderService
NOW = datetime(2026, 11, 5, 12, 0, tzinfo=UTC)
class InMemoryOrders: def __init__(self) -> None: self.orders: dict[str, Order] = {}
async def add(self, order: Order) -> None: self.orders[order.id] = order
async def get(self, order_id: str) -> Order | None: return self.orders.get(order_id)
async def find(self, status: OrderStatus | None, limit: int) -> Sequence[Order]: found = [ o for o in self.orders.values() if status is None or o.status is status ] return found[:limit]
async def update_status(self, order: Order, expected: OrderStatus) -> bool: if self.orders[order.id].status is not expected: return False self.orders[order.id] = order return True
# The clock is frozen, so tests do not depend on the current time.@pytest.fixturedef service() -> OrderService: return OrderService(InMemoryOrders(), clock=lambda: NOW, new_id=lambda: "id-1")
def test_new_order_can_be_paid(service: OrderService) -> None: async def scenario() -> None: order = await service.create("ann", Decimal("10.50")) assert (await service.pay(order.id)).status is OrderStatus.PAID assert (await service.get(order.id)).status is OrderStatus.PAID
asyncio.run(scenario())
def test_paid_order_cannot_be_cancelled(service: OrderService) -> None: async def scenario() -> None: order = await service.create("ann", Decimal("1")) await service.pay(order.id) with pytest.raises(IllegalTransition): await service.cancel(order.id)
asyncio.run(scenario())
@pytest.mark.parametrize( "customer, amount", [("", "1"), ("ann", "0"), ("ann", "-1"), ("ann", "NaN"), ("ann", "Infinity")],)def test_invalid_orders_are_rejected( service: OrderService, customer: str, amount: str) -> None: with pytest.raises(InvalidOrder): asyncio.run(service.create(customer, Decimal(amount)))
def test_missing_order(service: OrderService) -> None: with pytest.raises(OrderNotFound): asyncio.run(service.get("nope"))
def test_old_unpaid_orders_expire() -> None: now = [NOW] service = OrderService( InMemoryOrders(), clock=lambda: now[0], new_id=lambda: "id-1" )
async def scenario() -> None: order = await service.create("ann", Decimal("1")) assert await service.expire_unpaid(timedelta(hours=1)) == 0 now[0] += timedelta(hours=2) assert await service.expire_unpaid(timedelta(hours=1)) == 1 assert (await service.get(order.id)).status is OrderStatus.CANCELLED
asyncio.run(scenario())The integration test starts a real server on a free port (port 0 means "pick one yourself") with a real database in a temporary directory.
import asyncioimport jsonfrom pathlib import Pathfrom typing import Any
from orders.http_api import OrdersApifrom orders.http_server import HttpServerfrom orders.service import OrderServicefrom orders.storage import SqliteOrderRepository
async def call( port: int, method: str, path: str, body: Any = None) -> tuple[int, Any]: payload = b"" if body is None else json.dumps(body).encode() head = ( f"{method} {path} HTTP/1.1\r\n" f"Host: test\r\nContent-Length: {len(payload)}\r\n\r\n" ) reader, writer = await asyncio.open_connection("127.0.0.1", port) writer.write(head.encode() + payload) await writer.drain() response = await reader.read() writer.close() await writer.wait_closed() status_line, _, content = response.partition(b"\r\n\r\n") return int(status_line.split()[1]), json.loads(content) if content else None
def test_order_lifecycle_over_http(tmp_path: Path) -> None: async def scenario() -> None: repository = SqliteOrderRepository(tmp_path / "test.db") await repository.migrate() server = HttpServer(OrdersApi(OrderService(repository)), "127.0.0.1", 0) port = await server.start() try: assert (await call(port, "GET", "/health"))[0] == 200
order = {"customer": "ann", "amount": "19.99"} status, created = await call(port, "POST", "/orders", order) assert status == 201 assert created["amount"] == "19.99" assert created["status"] == "new" pay = f"/orders/{created['id']}/pay"
status, paid = await call(port, "POST", pay) assert (status, paid["status"]) == (200, "paid") assert (await call(port, "POST", pay))[0] == 409
_, listed = await call(port, "GET", "/orders?status=paid") assert [o["id"] for o in listed] == [created["id"]] assert (await call(port, "GET", "/orders?status=lost"))[0] == 422
assert (await call(port, "GET", "/orders/nope"))[0] == 404 assert (await call(port, "POST", "/orders", {"amount": 5}))[0] == 422 assert (await call(port, "DELETE", "/orders"))[0] == 404 finally: await server.stop(grace=1.0)
asyncio.run(scenario())
def test_malformed_request_gets_400(tmp_path: Path) -> None: async def scenario() -> None: repository = SqliteOrderRepository(tmp_path / "test.db") await repository.migrate() server = HttpServer(OrdersApi(OrderService(repository)), "127.0.0.1", 0) port = await server.start() try: reader, writer = await asyncio.open_connection("127.0.0.1", port) writer.write(b"nonsense\r\n\r\n") await writer.drain() assert b"400 Bad Request" in await reader.read() writer.close() await writer.wait_closed() finally: await server.stop(grace=1.0)
asyncio.run(scenario())How to package the service in Docker
The build has two stages, as with Gradle and a JRE. Each step is explained in a comment.
# Stage 1: uv installs only runtime dependencies from the lock file.FROM python:3.15-slim AS buildRUN pip install --no-cache-dir uvWORKDIR /appCOPY pyproject.toml uv.lock ./COPY src ./srcRUN uv sync --frozen --no-dev --no-editable
# Stage 2: the finished environment is copied to a clean image.FROM python:3.15-slimRUN useradd --create-home app && mkdir /data && chown app /dataCOPY --from=build /app/.venv /app/.venvUSER appENV ORDERS_DATABASE=/data/orders.db PATH=/app/.venv/bin:$PATHEXPOSE 8080VOLUME /dataHEALTHCHECK CMD python -c "import urllib.request as r; \ r.urlopen('http://127.0.0.1:8080/health', timeout=2)"
# The array form: the service becomes PID 1, and SIGTERM goes straight to it.CMD ["orders-service"]How to run the service
# Create the environment and uv.lockuv sync
# Testsuv run pytest
# Types in strict modeuv run mypy
# The linteruv run ruff check
# Run the serviceuv run python -m orders
# Or in a containerdocker build -t orders-service .docker run --rm -p 8080:8080 orders-serviceLet us create an order, pay for it, try to pay again and send bad data.
$ curl -s -X POST localhost:8080/orders \ -d '{"customer": "ann", "amount": "19.99"}' | python -m json.tool{ "id": "2165d2a6a1ee4cbb966203d6099d49c9", "customer": "ann", "amount": "19.99", "status": "new", "created_at": "2026-11-05T18:44:05.717023+00:00"}
$ ID=2165d2a6a1ee4cbb966203d6099d49c9$ curl -s -X POST localhost:8080/orders/$ID/pay | python -m json.tool{ "id": "2165d2a6a1ee4cbb966203d6099d49c9", "customer": "ann", "amount": "19.99", "status": "paid", "created_at": "2026-11-05T18:44:05.717023+00:00"}
$ curl -s -X POST localhost:8080/orders/$ID/pay{"error": "cannot pay an order that is paid"}
$ curl -s -X POST localhost:8080/orders \ -d '{"customer": "a", "amount": "NaN"}'{"error": "amount must be a positive number"}What frameworks will replace
This code is handwritten only to reveal the mechanisms. In a real project libraries take over most of it. The service.py written here does not change.
| Written here | What a project would use | Java counterpart |
|---|---|---|
http_server.py | uvicorn, gunicorn | Tomcat, Netty |
http_api.py | FastAPI, Flask, Django | Spring MVC |
| JSON parsing | Pydantic | Jackson, Bean Validation |
storage.py | SQLAlchemy, asyncpg | Hibernate, JDBC |
CREATE TABLE | Alembic | Flyway, Liquibase |
Settings | pydantic-settings | @ConfigurationProperties |
expire_loop | Celery, APScheduler | @Scheduled, Quartz |
retry | tenacity | Resilience4j |
logs.py | structlog | Logback, MDC |
| none | prometheus-client, OpenTelemetry | Micrometer |
| none | httpx | WebClient |
| none | aiokafka | Spring Kafka |
What to read next
Primary sources: the data model, the asyncio documentation, the free-threading guide, the mypy documentation and what is new in each version. For comparison with Java this site has articles on Java basics, multithreading, Project Loom and Spring.