"The Culture Shock" — Names, Objects and the Runtime
Arjun's first Python code review goes badly. We cover how Python executes code, why variables are names bound to objects, mutability and aliasing, is vs ==, truthiness, numeric surprises, modules, and a proper project setup with uv.
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# | Python | Note |
|---|---|---|
javac + java | python file.py | Compiles to bytecode on the fly; no separate build step |
public static void main | Top-level code + if __name__ == "__main__": | A file runs top to bottom |
int, long, double, boolean | int, float, bool | No primitives — everything is an object |
null | None | A singleton object; test with is None |
a == b (reference) | a is b | Identity |
a.equals(b) | a == b | Equality, via __eq__ |
{ ... } blocks | Indentation | 4 spaces, by convention (PEP 8) |
cond ? a : b | a if cond else b | Conditional expression |
&&, ||, ! | and, or, not | They return operands, not just booleans |
i++ | i += 1 | There is no ++ |
| Maven / Gradle | uv (or pip + venv) | Per-project virtual environments |
private | _name convention | No 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):
- Parses the source and compiles it to bytecode (cached as
.pycfiles in__pycache__/for imported modules). - 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).
- Runs the file top to bottom.
defandclassare 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.
[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 = 10b = a # b and a name the SAME int objectb += 1 # ints are immutable: += creates a NEW object (11) and rebinds b
print(a, b) # -> 10 11print(a is b) # -> FalseIntegers, 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 = numsnums += [3] # list.__iadd__ mutates in placeprint(alias) # -> [1, 2, 3]
nums = nums + [4] # '+' builds a NEW list; only 'nums' is reboundprint(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 — preferredworking2 = list(baseline) # constructor copyworking3 = baseline[:] # full slice (idiomatic in older code)
working[0] = 0.12print(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] * 2creates one inner list referenced twice. Mutatinggrid[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 = Noneprint(result is None) # -> TrueThe 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 = 256y = int("256") # built at runtimeprint(x is y) # -> True (cached small int)
x = 1000y = 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 conversionexcept TypeError as e: print(e) # -> can only concatenate str (not "int") to str
print(float(amount) + 1) # -> 251.0print(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) == 0if 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) # -> UNKNOWNprint(0 or None or [] or "first truthy") # -> first truthyprint(3 and 5) # -> 5Gotcha —
value or defaulttreats0,0.0and""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)) # -> lowprint(risk_label(None)) # -> unscoredNumbers: 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.5print(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_000print(limit) # -> 1000000For 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.30000000000000004print(Decimal("0.1") + Decimal("0.2")) # -> 0.3
fee = Decimal("19.995").quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)print(fee) # -> 20.00Tip — In data science you’ll mostly use floats anyway (NumPy and pandas are float-native). Keep
Decimalfor 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.5risk = 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 methodsprint(" TXN-001 ".strip().lower().replace("-", "_")) # -> txn_001print("a,b,,c".split(",")) # -> ['a', 'b', '', 'c']print("txn_42".startswith("txn")) # -> TrueSyntax 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, 20low, high = high, lowprint(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
.pyfile. A package is a directory of modules (traditionally containing__init__.py). importexecutes the module once and caches it insys.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.sqrtfrom statistics import mean, stdev # import specific namesimport datetime as dt # alias; very common: import numpy as np
print(math.sqrt(16)) # -> 4.0print(mean([10, 20, 30])) # -> 20print(round(stdev([10, 20, 30]), 2)) # -> 10.0print(dt.date(2026, 10, 2).isoformat()) # -> 2026-10-02Gotcha — Never name your own file
random.py,math.py,pandas.pyortest.py. Your local file shadows the real module and you get bafflingAttributeErrors.
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.
uv init sentinel # creates pyproject.toml, .python-version, main.pycd sentineluv python pin 3.13 # pin the interpreter (like .sdkmanrc / toolchains)uv add pandas scikit-learn # adds to pyproject.toml AND writes uv.lockuv add --dev pytest ruff # dev-only dependencies (test scope)uv run python main.py # runs inside .venv — no manual 'activate' neededuv sync # recreate the env exactly from uv.lock (CI does this)| Maven concept | uv / Python equivalent |
|---|---|
pom.xml | pyproject.toml |
| Resolved dependency tree | uv.lock (commit it) |
~/.m2/repository | uv’s global cache (hard-linked into each .venv) |
| Maven Central | PyPI |
<scope>test</scope> | uv add --dev |
mvn exec:java | uv run |
Tip — You will still see
pip install -r requirements.txtand Conda in the wild — especially in data science, where Conda manages non-Python libraries like CUDA. Know they exist; useuvfor 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_casefor functions and variables,PascalCasefor classes,UPPER_SNAKEfor constants. Runningruff formatmakes formatting a non-discussion.
Gotcha — shadowing built-ins:
list = [1, 2],id = 7,type = "card"silently replace built-in functions in that scope. Laterlist(...)calls fail with'list' object is not callable.
Gotcha — comparing to
Nonewith==: works most of the time, but classes can override__eq__(NumPy arrays and pandas columns do!).x is Noneis 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,fororwithblocks 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
| Concept | Remember |
|---|---|
| Execution | Files run top to bottom; def/class are executable statements |
| Variables | Names bound to objects; assignment never copies |
| Mutability | int, str, tuple immutable; list, dict, set mutable |
| Copying | .copy() is shallow; copy.deepcopy for nested structures |
is vs == | is = identity (Java ==), == = equality (Java equals) |
| Truthiness | Empty and zero are falsy; use is None when 0 is valid |
| Numbers | Unbounded ints, / vs //, floor semantics for negatives |
| Environments | One 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.”