All Posts

Python Basics: Variables, Types, and Control Flow

Why Python variables work nothing like Java — and why that makes them more powerful

Abstract AlgorithmsAbstract Algorithms
··20 min read

AI-assisted content.

TLDR: Python variables are labels that point at objects — not typed boxes. The type lives with the object, not the variable. Master truthiness, f-strings, for/while loops, and the handful of pitfalls that trip up every developer coming from Java or JavaScript, and you'll write idiomatic Python from day one.


📖 Why Python Variables Work Nothing Like Java: A Developer's First Day Rethinking the Basics

Maria has five years of Java experience. She sits down to write her first Python script and produces this:

# Maria's first Python — it runs, but it's not Pythonic
int x = 5           # SyntaxError — this is line 1, and it already fails
String name = "Alice"
boolean flag = True

The first line throws a SyntaxError. She removes the type declarations:

x = 5
name = "Alice"
flag = True

It runs. But then Maria tries to "be safe" and checks types manually everywhere, avoids reassigning variables to different types, and writes if name != null: — which fails, because Python doesn't have null. It has None.

Three hours later she has 80 lines of Python that reads like Java without semicolons. It works, but every Python developer who reviews it will wince.

The root issue is not syntax. It's a mental model mismatch. Java variables are typed containers — once you declare int x, that box can only hold integers. Python variables work completely differently, and once you internalize the actual model, everything else — truthiness, None, reassignment, dynamic behavior — snaps into place.

This post builds that mental model from scratch.


🔍 The Label Model: How Python Actually Stores Variables in Memory

In Java, a variable is a named container with a fixed type. When you write int x = 5, you reserve a box labelled x that holds only integers. The type is a property of the box.

In Python, a variable is a label (reference) that points at an object. The type is a property of the object, not the label. You can move the label to a different object at any time — even one of a different type.

Think of it like sticky notes on physical objects: the note says x, and it's stuck to the number 5. You can peel the note off and stick it on the string "hello" and Python won't complain.

x = 5           # The label 'x' points at the integer object 5
print(type(x))  # <class 'int'>

x = "hello"     # The label 'x' now points at the string object "hello"
print(type(x))  # <class 'str'>

x = [1, 2, 3]   # Now 'x' points at a list
print(type(x))  # <class 'list'>

You can observe this with two built-in tools: type() returns the type of the object a variable points at, and id() returns the memory address of that object.

a = 42
b = 42

print(id(a))        # e.g., 140234567890
print(id(b))        # same address — Python caches small integers!
print(a is b)       # True — they point at the SAME object

a = 1000
b = 1000
print(a is b)       # False — large integers are NOT cached; different objects

This is integer caching: CPython pre-allocates integer objects from -5 to 256. Any variable assigned a value in that range will point to the same shared object, making is comparisons return True. Outside that range, each assignment creates a new object.

The diagram below shows the Python object model for a simple variable reassignment. The variable x is a reference in the local namespace — it doesn't hold a value directly, it points to an object in the heap. Reassigning x moves the arrow; the original object 5 is not modified. When no variable points to an object, the garbage collector reclaims it.

flowchart TD
    A[Variable x in namespace] -->|points to| B[Object: int 5 at 0xA1]
    A2[Variable x after reassignment] -->|points to| C[Object: str hello at 0xB2]
    B -.->|no references - GC eligible| D[Garbage Collected]

The key takeaway: moving the label never modifies the original object. This is why Python strings and integers are immutable — you can only point your label somewhere new; you cannot change the underlying object in place.


⚙️ Python's Built-in Types: int, float, str, bool, None and Their Surprising Behaviors

Python has a compact set of built-in scalar types. Here are the six you'll reach for constantly — including the surprising behaviors of each.

int — Arbitrary Precision by Default

Python integers never overflow. There's no int vs long distinction. Python will happily compute 2 ** 10000 without overflow.

big = 2 ** 100
print(big)          # 1267650600228229401496703205376
print(type(big))    # <class 'int'>

float — IEEE 754, with All Its Surprises

print(0.1 + 0.2)         # 0.30000000000000004 — classic float imprecision
print(0.1 + 0.2 == 0.3)  # False!

# Use math.isclose() for float comparisons
import math
print(math.isclose(0.1 + 0.2, 0.3))  # True

str — Immutable Unicode Sequences

greeting = "Hello, World!"
print(type(greeting))   # <class 'str'>
greeting[0] = "h"       # TypeError: 'str' object does not support item assignment

bool — A Subclass of int

True and False are instances of bool, which is a subclass of int. This means True == 1 and False == 0 — they participate in arithmetic.

print(True + True)    # 2
print(False * 100)    # 0
print(isinstance(True, int))  # True

None — Python's Null, But Not Quite

None is a singleton — there is exactly one None object in a Python process. It represents the absence of a value. Always compare it with is, not ==:

result = None
print(result is None)    # True  (correct — identity check)
print(result == None)    # True  (works but can be overridden by __eq__)

Truthiness: What Python Considers False

Every Python object has a truth value, not just booleans. This is one of the most powerful (and most surprising) aspects of the language.

ValueTruthy?Why
TrueYesBoolean True
1, 42, -1YesNon-zero number
"hello"YesNon-empty string
[1, 2]YesNon-empty list
FalseNoBoolean False
0NoZero integer
0.0NoZero float
""NoEmpty string
[]NoEmpty list
{}NoEmpty dict
NoneNoNull sentinel

This lets you write expressive conditionals without explicit comparisons:

name = input("Enter your name: ")
if name:                   # True if non-empty string
    print(f"Hello, {name}!")
else:
    print("No name provided.")

items = []
if not items:              # True if empty list
    print("The list is empty.")

🌍 Strings in Real Python Code: f-strings, Slicing, and the Five Methods You'll Use Every Day

Strings in Python are rich, immutable sequences. Here's what you'll actually use in production code.

f-strings: The Modern Standard (Python 3.6+)

Forget % formatting and .format(). f-strings are the standard:

name = "Alice"
age = 30
score = 98.567

# Old ways (still valid, but verbose)
print("Hello, %s. You are %d years old." % (name, age))
print("Hello, {}. You are {} years old.".format(name, age))

# Modern: f-string
print(f"Hello, {name}. You are {age} years old.")
print(f"Score: {score:.2f}")        # Format spec: 2 decimal places -> 98.57
print(f"Double age: {age * 2}")     # Expressions are evaluated inline

String Slicing: [start:stop:step]

Strings support Python's full slice syntax. The rule: start is inclusive, stop is exclusive.

text = "Python Programming"
print(text[0:6])    # "Python"       — chars at index 0,1,2,3,4,5
print(text[7:])     # "Programming"  — from index 7 to end
print(text[:6])     # "Python"       — from start to index 5
print(text[-11:])   # "Programming"  — last 11 characters
print(text[::2])    # "Pto rgamn"    — every other character
print(text[::-1])   # "gnimmargorP nohtyP" — reversed string

Five String Methods You'll Use Every Single Day

raw = "  hello, world!  "

# 1. strip() — remove leading/trailing whitespace
print(raw.strip())          # "hello, world!"

# 2. split() — split into a list by delimiter
csv_line = "Alice,30,Engineer"
parts = csv_line.split(",")
print(parts)                # ['Alice', '30', 'Engineer']

# 3. join() — assemble a list back into a string
words = ["Python", "is", "readable"]
print(" ".join(words))      # "Python is readable"

# 4. replace() — substitute substrings
print("I love Java".replace("Java", "Python"))  # "I love Python"

# 5. lower() / upper() / title() — case normalisation
email = "  Alice@Example.COM  "
print(email.strip().lower())   # "alice@example.com"

Raw Strings and Multi-line Strings

# Raw string: backslashes are literal (great for regex and file paths)
path = r"C:\Users\Alice\Documents"
print(path)       # C:\Users\Alice\Documents

# Multi-line string with triple quotes
message = """
Dear Alice,

Your order has shipped.
"""
print(message.strip())

📊 Control Flow Visualized: if, elif, else, Ternary, and the match Statement

Python uses indentation to define blocks. No braces. No semicolons. The colon at the end of if, elif, else, for, while, and def is mandatory.

score = 85

if score >= 90:
    grade = "A"
elif score >= 80:
    grade = "B"
elif score >= 70:
    grade = "C"
else:
    grade = "F"

print(f"Grade: {grade}")   # Grade: B

Ternary Expression: Inline if/else

age = 20
status = "adult" if age >= 18 else "minor"
print(status)    # "adult"

The match Statement (Python 3.10+)

Python's match statement is pattern matching — far more powerful than a Java switch. It matches against values, types, sequences, and even attribute patterns:

command = "quit"

match command:
    case "quit":
        print("Exiting...")
    case "help":
        print("Available commands: quit, help, status")
    case "status":
        print("All systems operational.")
    case _:
        print(f"Unknown command: {command}")

Short-Circuit Evaluation

Python and and or are short-circuit operators — they stop evaluating as soon as the result is determined. This is useful and intentional:

user = None

# Without short-circuit, this would raise AttributeError
name = user and user.get("name")   # user is None (falsy) → stops; name = None

# or returns the first truthy value
display = name or "Anonymous"
print(display)   # "Anonymous"

The diagram below shows how Python evaluates an if / elif / else chain. Each condition is tested in order; the first branch that evaluates to truthy executes and the rest are skipped entirely. If no condition is truthy, the optional else block runs as the fallback. This top-down short-circuit evaluation is why the order of your elif branches matters for both correctness and performance.

flowchart TD
    A[Evaluate if condition] -->|True| B[Execute if block - done]
    A -->|False| C[Evaluate elif condition]
    C -->|True| D[Execute elif block - done]
    C -->|False| E{More elif?}
    E -->|Yes| C
    E -->|No| F[Execute else block]

🧪 Loops and Functions: Progressive Examples from Range to First-Class Objects

for Loops Over Iterables — Not Indices

Python's for loop iterates over any iterable directly. You never need an index to traverse a list:

fruits = ["apple", "banana", "cherry"]

# Java-style (works, but un-Pythonic)
for i in range(len(fruits)):
    print(fruits[i])

# Pythonic
for fruit in fruits:
    print(fruit)

range() for Numeric Sequences

for i in range(5):        # 0, 1, 2, 3, 4
    print(i)

for i in range(1, 6):     # 1, 2, 3, 4, 5
    print(i)

for i in range(0, 10, 2): # 0, 2, 4, 6, 8 — step of 2
    print(i)

enumerate() — Index and Value Together

Never write range(len(items)) to get an index. Use enumerate():

languages = ["Python", "Java", "Go", "Rust"]

# Un-Pythonic
for i in range(len(languages)):
    print(f"{i}: {languages[i]}")

# Pythonic — enumerate gives you (index, value) tuples
for i, lang in enumerate(languages):
    print(f"{i}: {lang}")

# Output:
# 0: Python
# 1: Java
# 2: Go
# 3: Rust

zip() — Iterate Two Lists in Lockstep

names  = ["Alice", "Bob", "Carol"]
scores = [92, 87, 95]

for name, score in zip(names, scores):
    print(f"{name}: {score}")

# Alice: 92
# Bob: 87
# Carol: 95

while with break and continue

n = 10
total = 0
i = 0

while i < n:
    if i % 2 == 0:    # skip even numbers
        i += 1
        continue
    total += i
    i += 1

print(total)   # 25 (sum of 1+3+5+7+9)

The Loop-Else Clause: Underused, Often Surprising

Python's for and while loops have an optional else clause that runs only if the loop completed normally — meaning it was NOT exited via break. This is perfect for search patterns:

def find_prime_factor(n):
    for i in range(2, n):
        if n % i == 0:
            print(f"{n} is divisible by {i}")
            break
    else:
        # Only runs if loop finished without break
        print(f"{n} is prime!")

find_prime_factor(17)   # 17 is prime!
find_prime_factor(18)   # 18 is divisible by 2

The flow through loops and their branches is worth visualising. The diagram below shows how for/while loops evaluate their condition or iterable, execute the body, optionally break early, and — crucially — only reach the else block when no break was hit. This is the mechanism that makes the prime-factor search idiom clean and explicit.

flowchart TD
    A[Start loop] --> B{More items or condition true?}
    B -->|Yes| C[Execute loop body]
    C --> D{break encountered?}
    D -->|Yes| E[Exit loop - skip else]
    D -->|No| B
    B -->|No - exhausted| F[Run else block]
    F --> G[Continue after loop]
    E --> G

Functions as First-Class Citizens: Defining, Returning, and Passing Behavior

Functions in Python are objects — you can assign them to variables, pass them as arguments, and return them from other functions. For now, let's cover the fundamentals.

def greet(name, greeting="Hello"):
    """Return a formatted greeting string."""
    return f"{greeting}, {name}!"

print(greet("Alice"))            # "Hello, Alice!"
print(greet("Bob", "Hi"))        # "Hi, Bob!"
print(greet(greeting="Hey", name="Carol"))  # keyword arguments

A function with no return statement implicitly returns None:

def print_twice(msg):
    print(msg)
    print(msg)

result = print_twice("hello")
print(result)    # None

args and *kwargs: Variable-Length Arguments (Preview)

def total(*args):
    """Sum any number of arguments."""
    return sum(args)

print(total(1, 2, 3))        # 6
print(total(10, 20, 30, 40)) # 100

def describe(**kwargs):
    """Print key=value pairs."""
    for key, value in kwargs.items():
        print(f"  {key}: {value}")

describe(name="Alice", age=30, role="Engineer")
# name: Alice
# age: 30
# role: Engineer

Functions are first-class: you can store them in variables and pass them around:

def square(x):
    return x * x

def apply(func, value):
    return func(value)

print(apply(square, 5))   # 25

⚠️ Pitfalls That Trip Up Java and JavaScript Developers Learning Python

These are not edge cases. Every developer coming from a typed language will hit at least three of these in their first week.

1. Mutable Default Arguments Are a Trap

This is one of the most common Python bugs for newcomers:

# BUG: the default list [] is created ONCE when the function is defined
def append_to(element, to=[]):
    to.append(element)
    return to

print(append_to(1))   # [1]
print(append_to(2))   # [1, 2]  ← NOT [2] — the list persists across calls!
print(append_to(3))   # [1, 2, 3]

# FIX: use None as sentinel and create a fresh list inside
def append_to_safe(element, to=None):
    if to is None:
        to = []
    to.append(element)
    return to

print(append_to_safe(1))   # [1]
print(append_to_safe(2))   # [2]  — fresh list each time

2. == vs is: Equality vs Identity

== checks if two objects have the same value. is checks if they are the same object in memory. For None, always use is:

a = [1, 2, 3]
b = [1, 2, 3]

print(a == b)   # True  — same values
print(a is b)   # False — different objects

# Only correct way to check for None
result = None
print(result is None)     # True  (correct)
print(result == None)     # True  (works but fragile — a custom __eq__ can break it)

3. Integer Division Always Returns float in Python 3

# Python 3 — / always returns float
print(7 / 2)    # 3.5   (not 3!)

# Use // for floor division (integer result)
print(7 // 2)   # 3

# This is a common source of off-by-one bugs when porting from Python 2 or Java

4. Integers Don't Overflow in Python

# Java: Integer.MAX_VALUE + 1 wraps to a negative number
# Python: integers grow without bound
x = 2 ** 1000
print(x)         # A 302-digit number — no overflow, no exception

5. The LEGB Scope Rule (Preview)

Python resolves names in four scopes in this order: Local → Enclosing → Global → Built-in. If you assign a name inside a function, Python treats it as a local variable for that entire function — even lines before the assignment:

x = 10

def bad():
    print(x)   # UnboundLocalError: referenced before assignment
    x = 20     # This assignment makes x local to the entire function

# Fix: use 'global' keyword (sparingly) or restructure to avoid global state
def good():
    global x
    print(x)   # 10
    x = 20

🛠️ Python Type Tooling: How mypy and Type Hints Bring Discipline to Dynamic Code

Python is dynamically typed, but that doesn't mean you have to fly blind. Since Python 3.5, you can add type hints — optional annotations that document expected types. They don't change runtime behavior, but they enable static analysis tools to catch bugs before you run the code.

mypy is the leading open-source static type checker for Python. You install it once and run it against your codebase.

# Without type hints — valid Python, but mypy can't help you here
def add(a, b):
    return a + b

# With type hints — mypy can now verify callers
def add_typed(a: int, b: int) -> int:
    return a + b

result: int = add_typed(3, 4)    # OK
wrong  = add_typed("hello", 4)   # mypy error: Argument 1 to "add_typed" has incompatible type "str"; expected "int"

You can annotate variables, function parameters, return types, and collection contents:

from typing import Optional, list

def find_user(user_id: int) -> Optional[str]:
    """Returns the username if found, None otherwise."""
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)

# Runtime type checking with isinstance()
def process(value: int | str) -> str:
    if isinstance(value, int):
        return f"Number: {value}"
    return f"Text: {value}"

print(process(42))        # "Number: 42"
print(process("hello"))   # "Text: hello"

To run mypy, install it and point it at your file:

pip install mypy
mypy my_script.py

mypy will report type mismatches, missing return type annotations, and incorrect argument types — all without executing the code. It integrates with VS Code, PyCharm, and CI pipelines. For a growing Python codebase, adding type hints incrementally (start with function signatures) gives you the safety of static typing without abandoning Python's flexibility.

For a full deep-dive on type hints and mypy configurations, see the Python Type Safety companion post (planned).


📚 Lessons Learned from Teaching Python to Java Developers

After watching many experienced developers learn Python, the same friction points appear every time:

The label mental model takes 48 hours to actually land. Developers understand it intellectually in ten minutes, but then continue writing Java-style null checks and type assertions for days. The model only sticks after seeing a mutable default argument bug in their own code.

Truthiness is either loved or hated — never neutral. Java developers initially resist if items: because it feels ambiguous. After two weeks, they won't go back. But skipping the truthiness table and jumping straight to examples causes bugs — "what does an empty dict evaluate to again?"

is vs == creates a specific class of bugs. Developers from null-safe languages naturally write if result == None and it works fine until a library object overrides __eq__. Teaching is None as a rule, not a preference, prevents the bug class entirely.

The loop-else clause surprises everyone. Even experienced Pythonistas encounter it as if for the first time. The example of using it for a "found" / "not found" search loop makes it immediately useful rather than a curiosity.

f-strings should be taught on day one. Developers who learn % formatting or .format() first will use those for years. Showing f-strings before either alternative prevents technical debt in new codebases.

Type hints pay dividends in team codebases. Developers initially resist annotations as "extra typing." Once they see mypy catch a None dereference in a code review before it hits staging, they add annotations proactively.


📌 TLDR: Python Basics in 10 Key Takeaways

  1. Variables are labels, not typed boxes. The type lives with the object, not the variable. You can reassign a variable to any type at any time.
  2. type() reveals the object's type; id() reveals its memory address. CPython caches integers from -5 to 256 — is comparisons on small ints are True but don't rely on it.
  3. Every object has a truth value. Falsy: 0, "", [], {}, None, False. All others are truthy. Use this to write cleaner conditionals.
  4. f-strings are the modern string formatting standard. Use f"{variable:.2f}" for inline expressions and format specs.
  5. String slicing is [start:stop:step] — start inclusive, stop exclusive. [::-1] reverses a string.
  6. for iterates over iterables directly. Use enumerate() for index + value, zip() for two lists in lockstep. Never write range(len(items)).
  7. Loop-else runs only when no break was hit. It's the cleanest pattern for "search and report not found" logic.
  8. Never use mutable objects as default arguments. Use None as the sentinel and create a fresh collection inside the function.
  9. is checks identity (same object); == checks equality (same value). Always use is for None comparisons.
  10. Type hints + mypy give you static analysis without losing dynamic flexibility. Add annotations to function signatures first, then propagate inward.

📝 Practice Quiz: Test What You Actually Learned

  1. What does the following code print?
a = 256
b = 256
print(a is b)

c = 1000
d = 1000
print(c is d)
Answer Correct Answer: True then False. CPython caches integers from -5 to 256 — so a and b point to the same object (identity check passes). The integer 1000 is outside the cache range, so CPython creates two separate objects, making c is d return False. This is why you should never use is to compare arbitrary integers — use == for value equality.

  1. A function is called three times. What does it print each time, and why?
def add_item(item, cart=[]):
    cart.append(item)
    return cart

print(add_item("apple"))
print(add_item("banana"))
print(add_item("cherry"))
Answer Correct Answer: ['apple'] ['apple', 'banana'] ['apple', 'banana', 'cherry'] The default list [] is created once when the function is defined, not on each call. Every call shares the same list object. Fix: use cart=None as the default and create cart = [] inside the function body when cart is None.

  1. Which of the following values are falsy in Python?

0 · "" · "False" · None · [] · [0] · 0.0 · {}

Answer Correct Answer: 0, "", None, [], 0.0, {} are falsy. Truthy: "False" (non-empty string), [0] (non-empty list — even though it contains zero). The string "False" is truthy because it is a non-empty string. [0] is truthy because the list itself is non-empty, regardless of what it contains.

  1. What is the output of this code?
result = 7 / 2
print(result)
print(type(result))

result2 = 7 // 2
print(result2)
print(type(result2))
Answer Correct Answer: 3.5 <class 'float'> 3 <class 'int'> In Python 3, / always performs true division and returns a float. // performs floor (integer) division and returns an int. This is a breaking change from Python 2, where / between two integers returned an integer.

  1. What does the loop-else clause print, and under what condition does the else block execute?
target = 7
numbers = [2, 4, 6, 8, 10]

for n in numbers:
    if n == target:
        print(f"Found {target}")
        break
else:
    print(f"{target} not found in list")
Answer Correct Answer: It prints 7 not found in list. The else block of a for loop runs only when the loop completes without hitting a break. Because 7 is not in the list, the loop exhausts all items without breaking, and the else block executes. If target were 6, the loop would break and the else block would be skipped entirely.

  1. Rewrite this un-Pythonic loop using enumerate():
items = ["pen", "notebook", "ruler"]
for i in range(len(items)):
    print(f"{i}: {items[i]}")
Answer Correct Answer: python items = ["pen", "notebook", "ruler"] for i, item in enumerate(items): print(f"{i}: {item}") enumerate(items) yields (index, value) tuples, eliminating the need for range(len(...)) and the manual index lookup. You can also start the counter at 1: enumerate(items, start=1).
Share
Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms