Story Opening

Arjun’s first contribution to Project Sentinel was forty lines of Python that he was rather proud of. It loaded a batch of transactions, kept a “baseline” copy for comparison, and normalised the amounts in the working copy.

Priya’s review comment was one line: “Your baseline is being normalised too. Run it.”

baseline = [120.0, 75.5, 3000.0]
working = baseline # "keep a copy" — or so Arjun thought
for i in range(len(working)):
working[i] = working[i] / 1000
print(baseline) # -> [0.12, 0.0755, 3.0]

He stared at it. In Java, he’d never write List<Double> working = baseline; and expect a copy — he knew that was a reference. So why had he expected one here?

Because Python looks like a scripting language, his brain had quietly switched to “values” mode. This part is about switching it back — and about the handful of places where Python truly differs from the JVM.


Java → Python: The Quick Map

Java / C#PythonNote
javac + javapython file.pyCompiles to bytecode on the fly; no separate build step
public static void mainTop-level code + if __name__ == "__main__":A file runs top to bottom
int, long, double, booleanint, float, boolNo primitives — everything is an object
nullNoneA singleton object; test with is None
a == b (reference)a is bIdentity
a.equals(b)a == bEquality, via __eq__
{ ... } blocksIndentation4 spaces, by convention (PEP 8)
cond ? a : ba if cond else bConditional expression
&&, ||, !and, or, notThey return operands, not just booleans
i++i += 1There is no ++
Maven / Gradleuv (or pip + venv)Per-project virtual environments
private_name conventionNo enforced access modifiers

How Python Actually Runs Your Code

There is no compile phase you invoke. When you run python sentinel.py, CPython (the reference interpreter):

  1. Parses the source and compiles it to bytecode (cached as .pyc files in __pycache__/ for imported modules).
  2. Executes that bytecode on a stack-based virtual machine — conceptually like the JVM, but without a JIT in the default build (an experimental JIT exists since 3.13, but don’t count on it).
  3. Runs the file top to bottom. def and class are statements that execute and create objects; they are not declarations.
# sentinel.py — a file is a module; its top-level code runs on import or execution.
print("Module loading...") # runs immediately
def score(amount: float) -> float: # 'def' executes: creates a function object
return min(amount / 10_000, 1.0) # named 'score' in this module
# This guard is Python's 'main'. __name__ is "__main__" only when the file
# is executed directly, not when another module imports it.
if __name__ == "__main__":
print(score(2_500))

Run it with python sentinel.py and you see both lines. Import it from another module with import sentinel and you only see Module loading... — the guard keeps script-only code from running during imports.

Tip — Because everything happens at runtime, a typo in a rarely executed branch is only discovered when that branch runs. That is why Python teams lean heavily on tests, linters (ruff) and type checkers (mypy) — Part 6 covers all three.


Deep Dive: Names, Not Boxes

This is the single most important mental model in the series. Get it right and half of Python’s “surprises” disappear.

In Java you have two worlds: primitives (int x = 5 — the box contains 5) and references (List<X> l = ... — the box holds a pointer). Python has only one world: every value is an object on the heap, and every variable is a name bound to an object.

graph LR subgraph Names A[baseline] B[working] end subgraph Heap L["list object
[120.0, 75.5, 3000.0]"] end A --> L B --> L

Assignment never copies. working = baseline simply attaches a second name to the same list. That is identical to Java references — the trap is that Python applies it to everything, including numbers and strings.

So why doesn’t this break with integers?

a = 10
b = a # b and a name the SAME int object
b += 1 # ints are immutable: += creates a NEW object (11) and rebinds b
print(a, b) # -> 10 11
print(a is b) # -> False

Integers, floats, strings, tuples and frozenset are immutable. You cannot change the object, so any “modification” produces a new object and rebinds the name. Lists, dicts, sets and most user-defined objects are mutable: operations change the object in place, and every name bound to it sees the change.

Rebinding vs mutating

The distinction matters most when you pass things to functions. Python’s calling convention is “call by sharing”: the parameter becomes a new name for the caller’s object — exactly like Java passing a reference by value.

def add_flag(txn_flags: list[str]) -> None:
txn_flags.append("HIGH_VALUE") # MUTATES the caller's list — visible outside
def reset_flags(txn_flags: list[str]) -> None:
txn_flags = [] # REBINDS the local name — caller unaffected
txn_flags.append("RESET")
flags = ["NEW_DEVICE"]
add_flag(flags)
print(flags) # -> ['NEW_DEVICE', 'HIGH_VALUE']
reset_flags(flags)
print(flags) # -> ['NEW_DEVICE', 'HIGH_VALUE']

The += trap

For mutable types, += mutates in place. For immutable types, it rebinds. Same operator, different semantics:

nums = [1, 2]
alias = nums
nums += [3] # list.__iadd__ mutates in place
print(alias) # -> [1, 2, 3]
nums = nums + [4] # '+' builds a NEW list; only 'nums' is rebound
print(alias) # -> [1, 2, 3]
print(nums) # -> [1, 2, 3, 4]

Fixing Arjun’s bug: shallow vs deep copies

import copy
baseline = [120.0, 75.5, 3000.0]
# Three equivalent shallow copies of a list:
working = baseline.copy() # explicit and readable — preferred
working2 = list(baseline) # constructor copy
working3 = baseline[:] # full slice (idiomatic in older code)
working[0] = 0.12
print(baseline[0]) # -> 120.0
# Shallow copies copy the OUTER container only. Nested objects stay shared:
batches = [[1, 2], [3, 4]]
shallow = batches.copy()
shallow[0].append(99)
print(batches) # -> [[1, 2, 99], [3, 4]]
# deepcopy recursively copies everything (slower; use when nesting is real).
deep = copy.deepcopy(batches)
deep[1].append(42)
print(batches) # -> [[1, 2, 99], [3, 4]]

Gotcha — [[0] * 3] * 2 creates one inner list referenced twice. Mutating grid[0][0] changes both rows. Use a comprehension instead: [[0] * 3 for _ in range(2)] (Part 2).


Identity vs Equality: is and ==

In Python, == is Java’s equals() (it calls __eq__), and is is Java’s == (same object). Java developers get this backwards for about a week.

a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # -> True (same contents: like a.equals(b))
print(a is b) # -> False (different objects: like a == b in Java)
# Use 'is' for singletons: None, True, False.
result = None
print(result is None) # -> True

The integer cache — you’ve seen this movie

Java caches Integer values from -128 to 127, so Integer.valueOf(127) == Integer.valueOf(127) is true but 128 is not. CPython does the same for -5 to 256. Never rely on it:

x = 256
y = int("256") # built at runtime
print(x is y) # -> True (cached small int)
x = 1000
y = int("1000")
print(x is y) # -> False (two distinct objects)
print(x == y) # -> True (always compare values with ==)

Dynamic, But Strongly Typed

Python is dynamically typed — names have no type, objects do. But it is strongly typed — it won’t silently coerce unrelated types the way JavaScript does.

amount = 250 # 'amount' names an int...
amount = "250.00" # ...now it names a str. Legal, but confusing — avoid.
print(type(amount)) # -> <class 'str'>
try:
total = amount + 1 # str + int: no implicit conversion
except TypeError as e:
print(e) # -> can only concatenate str (not "int") to str
print(float(amount) + 1) # -> 251.0
print(isinstance(amount, str)) # -> True (the 'instanceof' equivalent)

Truthiness: Everything Has a Boolean Value

Any object can be used in a condition. Empty and zero-like values are falsy: None, False, 0, 0.0, "", [], {}, set(), range(0). Everything else is truthy (unless a class defines __bool__ or __len__ otherwise).

pending = []
# Idiomatic: test the collection directly instead of len(pending) == 0
if not pending:
print("Nothing to review") # -> Nothing to review
# 'and' / 'or' return one of their OPERANDS, not a bool.
# 'or' is often used for defaults:
merchant_name = "" or "UNKNOWN"
print(merchant_name) # -> UNKNOWN
print(0 or None or [] or "first truthy") # -> first truthy
print(3 and 5) # -> 5

Gotcha — value or default treats 0, 0.0 and "" as missing. If zero is a legitimate value (a transaction amount, a risk score), test explicitly: x if x is not None else default.

def risk_label(score: float | None) -> str:
# WRONG: 'if not score' would label a perfectly valid 0.0 as "unscored"
if score is None:
return "unscored"
return "high" if score > 0.8 else "low"
print(risk_label(0.0)) # -> low
print(risk_label(None)) # -> unscored

Numbers: Three Surprises

# 1. Integers have ARBITRARY precision. No overflow, no long/BigInteger split.
print(2 ** 100) # -> 1267650600228229401496703205376
# 2. '/' is ALWAYS true division; '//' is floor division.
print(7 / 2) # -> 3.5
print(7 // 2) # -> 3
# 3. '//' and '%' floor toward NEGATIVE infinity (Java truncates toward zero).
print(-7 // 2) # -> -4 (Java: -7 / 2 == -3)
print(-7 % 2) # -> 1 (Java: -7 % 2 == -1)
# Underscores make big literals readable — same as Java 7+.
limit = 1_000_000
print(limit) # -> 1000000

For money, floats are as wrong in Python as double is in Java. Use Decimal (the BigDecimal equivalent) — and construct it from strings:

from decimal import Decimal, ROUND_HALF_EVEN
print(0.1 + 0.2) # -> 0.30000000000000004
print(Decimal("0.1") + Decimal("0.2")) # -> 0.3
fee = Decimal("19.995").quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
print(fee) # -> 20.00

Tip — In data science you’ll mostly use floats anyway (NumPy and pandas are float-native). Keep Decimal for ledger logic; convert to float at the boundary where you build features.


Strings Without StringBuilder

Strings are immutable, like Java’s. Single and double quotes are equivalent; triple quotes span lines.

merchant = "Café Mumbai"
amount = 1234.5
risk = 0.87654
# f-strings: the default formatting tool. Expressions and format specs inline.
print(f"{merchant}: ₹{amount:,.2f} (risk {risk:.1%})") # -> Café Mumbai: ₹1,234.50 (risk 87.7%)
# '=' specifier — wonderful for debugging: prints expression AND value.
print(f"{amount=}") # -> amount=1234.5
# Joining: str.join replaces StringBuilder loops.
codes = ["NEW_DEVICE", "VELOCITY", "GEO_MISMATCH"]
print(" | ".join(codes)) # -> NEW_DEVICE | VELOCITY | GEO_MISMATCH
# Raw strings: backslashes are literal — use for regexes and Windows paths.
pattern = r"\d{4}-\d{2}"
print(len(pattern)) # -> 11
# Common methods
print(" TXN-001 ".strip().lower().replace("-", "_")) # -> txn_001
print("a,b,,c".split(",")) # -> ['a', 'b', '', 'c']
print("txn_42".startswith("txn")) # -> True

Syntax Odds and Ends

amount = 4_200
# Chained comparisons read like maths (and evaluate 'amount' once).
if 1_000 <= amount < 10_000:
print("medium") # -> medium
# Conditional expression instead of ?:
tier = "gold" if amount > 4_000 else "standard"
print(tier) # -> gold
# 'pass' is a no-op placeholder where a block is syntactically required.
def not_implemented_yet():
pass
# Multiple assignment and swapping without a temp variable.
low, high = 10, 20
low, high = high, low
print(low, high) # -> 20 10
# 'match' (3.10+) is structural pattern matching — think Java 21 switch patterns.
event = {"type": "chargeback", "amount": 500}
match event:
case {"type": "chargeback", "amount": amt} if amt > 100:
print(f"escalate chargeback of {amt}") # -> escalate chargeback of 500
case {"type": "refund"}:
print("refund")
case _:
print("ignore")

Modules, Packages and the Missing private

  • A module is a .py file. A package is a directory of modules (traditionally containing __init__.py).
  • import executes the module once and caches it in sys.modules; later imports reuse it.
  • There are no access modifiers. A leading underscore (_helper) means “internal — don’t touch”, and tools respect it. It’s a convention, not enforcement.
# Different import styles — all standard library, so this block runs as-is.
import math # access as math.sqrt
from statistics import mean, stdev # import specific names
import datetime as dt # alias; very common: import numpy as np
print(math.sqrt(16)) # -> 4.0
print(mean([10, 20, 30])) # -> 20
print(round(stdev([10, 20, 30]), 2)) # -> 10.0
print(dt.date(2026, 10, 2).isoformat()) # -> 2026-10-02

Gotcha — Never name your own file random.py, math.py, pandas.py or test.py. Your local file shadows the real module and you get baffling AttributeErrors.


Project Setup: Virtual Environments with uv

Maven downloads JARs into a shared ~/.m2 and builds a classpath per project. Python installs packages into an interpreter environment. If every project installs into the system Python, versions collide immediately. The fix is a virtual environment per project — an isolated directory (.venv/) with its own site-packages.

uv (from Astral, written in Rust) manages Python versions, virtual environments and dependencies in one fast tool. It’s the closest thing to Maven + SDKMAN that Python has.

Terminal window
uv init sentinel # creates pyproject.toml, .python-version, main.py
cd sentinel
uv python pin 3.13 # pin the interpreter (like .sdkmanrc / toolchains)
uv add pandas scikit-learn # adds to pyproject.toml AND writes uv.lock
uv add --dev pytest ruff # dev-only dependencies (test scope)
uv run python main.py # runs inside .venv — no manual 'activate' needed
uv sync # recreate the env exactly from uv.lock (CI does this)
Maven conceptuv / Python equivalent
pom.xmlpyproject.toml
Resolved dependency treeuv.lock (commit it)
~/.m2/repositoryuv’s global cache (hard-linked into each .venv)
Maven CentralPyPI
<scope>test</scope>uv add --dev
mvn exec:javauv run

Tip — You will still see pip install -r requirements.txt and Conda in the wild — especially in data science, where Conda manages non-Python libraries like CUDA. Know they exist; use uv for new projects unless your team standardises otherwise.


REPL Survival Kit

The interactive interpreter (python, or uv run python) is far more useful than JShell. Use it constantly.

txn = {"id": "T-1", "amount": 99.5}
print(type(txn)) # -> <class 'dict'>
print("keys" in dir(txn)) # -> True (dir() lists attributes and methods)
print(callable(txn.keys)) # -> True
# help(dict.get) # full docs in the terminal — try it interactively
# In the REPL, '_' holds the last evaluated result.

Tips, Tricks & Gotchas

Tip — PEP 8 naming: snake_case for functions and variables, PascalCase for classes, UPPER_SNAKE for constants. Running ruff format makes formatting a non-discussion.

Gotcha — shadowing built-ins: list = [1, 2], id = 7, type = "card" silently replace built-in functions in that scope. Later list(...) calls fail with 'list' object is not callable.

Gotcha — comparing to None with ==: works most of the time, but classes can override __eq__ (NumPy arrays and pandas columns do!). x is None is always correct.

Tip — id() is your debugger for aliasing: id(a) == id(b) tells you whether two names share one object — handy when you suspect Arjun’s bug.

Gotcha — no block scope: variables assigned inside if, for or with blocks remain visible after the block ends. Only functions, classes and modules create scopes (Part 3).

for i in range(3):
last_seen = i * 10
print(i, last_seen) # -> 2 20 (loop variables leak — unlike Java)

Key Takeaways

ConceptRemember
ExecutionFiles run top to bottom; def/class are executable statements
VariablesNames bound to objects; assignment never copies
Mutabilityint, str, tuple immutable; list, dict, set mutable
Copying.copy() is shallow; copy.deepcopy for nested structures
is vs ==is = identity (Java ==), == = equality (Java equals)
TruthinessEmpty and zero are falsy; use is None when 0 is valid
NumbersUnbounded ints, / vs //, floor semantics for negatives
EnvironmentsOne virtual env per project; uv manages it

Story Closing

Arjun fixed his bug with a single .copy(), then spent the evening deliberately breaking things in the REPL — aliasing lists, mutating tuples’ inner lists, watching is lie about large integers. By midnight the “names, not boxes” model had clicked.

The next morning Priya dropped a notebook on him: three hundred lines of data wrangling with barely a for loop in sight. Everything was brackets — [t for t in txns if ...], {m: ... for m in ...}. It looked like line noise.

“Those are comprehensions,” she said. “They’re your Streams API. You’ll love them by Friday.”

In Part 2, Arjun translates his Collectors muscle memory into lists, dicts, sets — and comprehensions.


This is Part 1 of a 10-part series: “Python for Java Developers: From Streams to Tensors.”