Python Basic Syntax: Indentation Errors, Truthiness, Floor Division, f-strings and match

Key takeaways

Python's syntax looks simple, but indentation is part of the grammar, `//` floors toward negative infinity, `or` returns operands rather than booleans, and `match` can silently capture instead of compare. This article walks through the rules with the actual error messages and outputs.

Python has a reputation for being “executable pseudocode”, and for the most part that holds up. But a handful of its syntax rules behave differently from C, Java or JavaScript, and those differences produce bugs that do not look like bugs when you read the code. This article covers the core syntax (variables, operators, conditionals, loops) with a focus on those differences: what the interpreter actually does, the exact error message you will see, and how to write the code so the problem cannot happen.

All outputs below were produced with CPython 3.11. Where newer versions print a slightly different message, that is noted.

Indentation is grammar, not style

In most languages indentation is for humans and braces are for the compiler. In Python the tokenizer turns changes in leading whitespace into INDENT and DEDENT tokens, so indentation is the block structure. That design removes the “braces say one thing, indentation says another” class of bugs, but it means whitespace errors are syntax errors.

There are four messages you will meet:

def greet(name):
print(name)
    print(name)
    ^
IndentationError: expected an indented block after function definition on line 1

A dedent that lands between two existing levels:

if True:
    x = 1
  y = 2
IndentationError: unindent does not match any outer indentation level

An indent with no block opener above it:

if True:
    x = 1
        y = 2
IndentationError: unexpected indent

And the one that is invisible on screen, a line indented with a tab under a line indented with spaces:

TabError: inconsistent use of tabs and spaces in indentation

TabError is a subclass of IndentationError. Python 3 refuses to guess how wide a tab is when it is mixed with spaces, which is why code that looks perfectly aligned in one editor fails to run.

The first time I lost real time to this, the code had been pasted from a chat message into a file that already used spaces; one pasted line carried a tab, the editor rendered it at four columns, and the diff looked clean. The fix that actually stuck was not “be careful” but configuration: set the editor to insert spaces, turn on visible whitespace, and let a formatter such as black or ruff format rewrite indentation on save. PEP 8 recommends four spaces per level; the interpreter only requires consistency, but four spaces is what every tool and codebase expects.

When a block has to exist but should do nothing yet, use pass. An empty body is not allowed:

def todo():
    pass  # placeholder so the block is not empty

Variables are names bound to objects

Python has no declarations. x = 10 creates the name x if it does not exist and binds it to an int object. Rebinding it to a string later is legal because the name has no type; the object does:

x = 10
x = "ten"
print(type(x).__name__)  # str

The practical consequence is aliasing. Assignment never copies; it binds another name to the same object:

a = [1, 2, 3]
b = a
b.append(4)
print(a)  # [1, 2, 3, 4]

If you come from a language where assignment copies arrays or structs, this is the first surprise. Mutable objects (lists, dicts, sets) are shared between every name bound to them; use a.copy() or list(a) when you want an independent list. The data types article goes deeper into mutable vs immutable types.

Unpacking

Multiple assignment evaluates the whole right-hand side first, then binds left to right, which is why swapping needs no temporary:

x, y = 1, 2
x, y = y, x
print(x, y)  # 2 1

first, *rest = [1, 2, 3, 4, 5]
print(first, rest)  # 1 [2, 3, 4, 5]

The counts must match unless you use a starred target:

a, b = [1, 2, 3]     # ValueError: too many values to unpack (expected 2)
a, b, c = [1, 2]     # ValueError: not enough values to unpack (expected 3, got 2)

These errors often appear when a function that used to return two values starts returning three; the call site fails at runtime, not at import time.

Names and type hints

PEP 8 conventions are snake_case for variables and functions, UPPER_CASE for module-level constants and PascalCase for classes. Names cannot start with a digit or contain - (my-var parses as my - var). Type hints such as age: int = 25 document intent and let tools like mypy or pyright check it, but the interpreter does not enforce them: greet(123) runs fine even if greet is annotated to take a str.

Arithmetic: where Python differs from C and JavaScript

print(7 / 2, 7 // 2, 7 % 2)  # 3.5 3 1

/ always returns a float in Python 3, even for 4 / 2 (which is 2.0). // is floor division and % is the matching remainder. Integers have arbitrary precision, so 2 ** 100 prints all 31 digits instead of overflowing.

Negative floor division

This is the rule that bites people porting code from other languages:

print(-7 // 2, -7 % 2)   # -4 1
print(int(-7 / 2))       # -3   (truncation, like C)

import math
print(math.fmod(-7, 2))  # -1.0 (C-style remainder)
print(divmod(-7, 2))     # (-4, 1)

Python floors toward negative infinity, C, Java and JavaScript truncate toward zero. Python’s choice guarantees that a == (a // b) * b + a % b and that a % b has the sign of b. That is exactly what you want for wrapping indexes or bucketing time:

seconds = -90
print(seconds // 60, seconds % 60)  # -2 30  (i.e. -2 minutes + 30 seconds)

I have seen this bite in both directions: an algorithm ported from C that relied on truncation silently produced off-by-one results for negative inputs, and conversely JavaScript code that needed ((i % n) + n) % n to wrap an index could be simplified to i % n in Python. If a formula involves negative numbers and division, check which semantics the original assumed.

Floats, rounding and precedence

print(0.1 + 0.2, 0.1 + 0.2 == 0.3)       # 0.30000000000000004 False
import math
print(math.isclose(0.1 + 0.2, 0.3))      # True
print(round(2.5), round(3.5), round(0.125, 2))  # 2 4 0.12
print(-2 ** 2, (-2) ** 2)                # -4 4

Floats are IEEE 754 doubles, so compare them with math.isclose and use decimal.Decimal for money. round() uses round-half-to-even (“banker’s rounding”), which is why round(2.5) is 2. And ** binds tighter than unary minus, so -2 ** 2 is -(2 ** 2).

Comparisons, identity and chaining

Python lets you chain comparisons, and it is more than syntactic sugar: a < b < c means a < b and b < c, with b evaluated only once.

x = 5
print(1 < x < 10)  # True
print(1 < x > 3)   # True  (legal, but reads badly; avoid)

A classic bug is writing English instead of Python:

color = "blue"
print(color == "red" or "green")   # green  (always truthy!)
print(color in ("red", "green"))   # False  (what was meant)

color == "red" or "green" parses as (color == "red") or "green", and a non-empty string is truthy, so an if using it always runs.

== vs is

== compares values; is compares identity (the same object in memory).

a = [1, 2, 3]
b = [1, 2, 3]
print(a == b, a is b)  # True False

Use is only for singletons: None, True, False, and sentinel objects. Using it with literals sometimes appears to work because CPython caches small integers and some strings, which is exactly why it is dangerous. The compiler warns you:

SyntaxWarning: "is" with a literal. Did you mean "=="?

(Python 3.12+ words it as "is" with 'int' literal.) Treat that warning as an error.

Truthiness and what and/or actually return

Every object has a truth value. The falsy ones are False, None, zero of any numeric type (0, 0.0, 0j), and empty containers ("", [], {}, (), set(), range(0)). Everything else is truthy, including some values that surprise people:

print(bool("0"), bool(" "), bool([0]), bool(float("nan")))  # True True True True

and and or do not return True/False; they return one of their operands, stopping as soon as the result is decided (short-circuit):

print(0 or "default")   # default
print("" or None)       # None
print([] and "x")       # []
print(3 and 4)          # 4

That makes name or "Anonymous" a handy idiom, and also the source of a real bug pattern:

def connect(timeout=None):
    timeout = timeout or 30
    return timeout

print(connect(0))  # 30  -- the caller asked for 0!

The version that respects legitimate falsy values checks for None explicitly:

def connect(timeout=None):
    if timeout is None:
        timeout = 30
    return timeout

This is one of the mistakes I now look for first in code review. A retries=0, offset=0 or discount=0.0 that gets replaced by a default does not crash; it just quietly does the wrong thing, and the test suite often only uses non-zero values. The rule I follow: if not items: is fine for “is this container empty”, but for optional parameters, compare with None.

Also note that bool is a subclass of int: True + True == 2 and isinstance(True, int) is True. That matters when you validate numeric input with isinstance.

Strings and f-strings

Concatenating a string with a number is a TypeError, not an implicit conversion as in JavaScript:

"Age: " + 30
# TypeError: can only concatenate str (not "int") to str

Use an f-string (Python 3.6+) instead. The part after : is a format specification, and learning a few of them removes most manual string formatting:

price, count, ratio, name = 1234.5678, 1234567, 0.4567, "Ada"

print(f"{price:.2f}")        # 1234.57     fixed decimals
print(f"{count:,}")          # 1,234,567   thousands separator
print(f"{count:_}")          # 1_234_567
print(f"{ratio:.1%}")        # 45.7%       multiplies by 100
print(f"[{name:>6}] [{name:<6}] [{name:^7}]")  # [   Ada] [Ada   ] [  Ada  ]
print(f"{42:08.3f}")         # 0042.000    zero-padded width 8
print(f"{255:x} {255:#x} {5:03d} {5:b}")       # ff 0xff 005 101
print(f"{name!r}")           # 'Ada'       repr() instead of str()
print(f"{price=}")           # price=1234.5678  (3.8+, great for debugging)
print(f"{price = :.1f}")     # price = 1234.6
width = 10
print(f"[{name:>{width}}]")  # [       Ada]   nested field for dynamic width
print(f"{{literal braces}}") # {literal braces}

The general shape is {value!conversion:fill align width , .precision type}. The = specifier is the one I use most while debugging, because it prints the expression and its value together.

Conditionals and the match statement

if / elif / else works as you would expect; there are no parentheses around the condition and no braces:

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

The conditional expression puts the condition in the middle: status = "adult" if age >= 18 else "minor". It is fine for simple choices; nesting several of them gets unreadable quickly.

match (Python 3.10+)

Python had no switch until 3.10 added structural pattern matching. It can compare literals, but its real strength is matching shapes and binding parts of them:

def describe(command):
    match command.split():
        case ["quit" | "exit"]:
            return "bye"
        case ["go", direction] if direction in ("north", "south"):
            return f"moving {direction}"
        case ["go", _]:
            return "unknown direction"
        case ["pick", *items]:
            return f"picking {len(items)} item(s)"
        case _:
            return "unrecognized"

for c in ["quit", "go north", "go up", "pick key lamp", "dance"]:
    print(c, "->", describe(c))
quit -> bye
go north -> moving north
go up -> unknown direction
pick key lamp -> picking 2 item(s)
dance -> unrecognized

Cases are tried top to bottom and there is no fall-through. | combines alternatives, if adds a guard, _ matches anything without binding, and dictionary patterns match a subset of keys (extra keys are ignored):

def handle(event):
    match event:
        case {"type": "click", "x": x, "y": y}:
            return f"click at {x},{y}"
        case {"type": "key", "key": k}:
            return f"key {k}"
    return "ignored"

print(handle({"type": "click", "x": 3, "y": 4, "extra": True}))  # click at 3,4

The trap: a bare name in a pattern captures, it does not compare.

RED = "red"
match color:
    case RED:          # binds color to a new name RED; matches anything
        print("red")
    case _:
        print("other")
    case RED:
         ^^^
SyntaxError: name capture 'RED' makes remaining patterns unreachable

Python catches this particular case because later branches become unreachable, but if case RED: were the last branch it would compile and match everything. To compare against a constant, use a dotted name (case Color.RED: with an Enum, or case config.RED:) or a literal. If you are on Python 3.9 or older, match is a SyntaxError, so check your minimum supported version before using it in a library.

Loops

for iterates over things, not indexes

Python’s for is a for-each loop over any iterable. Reach for enumerate when you need the index and zip to walk several sequences in parallel, instead of range(len(...)):

fruits = ["apple", "banana", "cherry"]
prices = [1.2, 0.5, 3.0]

for i, fruit in enumerate(fruits, start=1):
    print(i, fruit)

for fruit, price in zip(fruits, prices):
    print(f"{fruit:<8}{price:>5.2f}")

zip stops at the shortest input without complaint (zip([1, 2, 3], "ab") yields two pairs). On Python 3.10+, zip(a, b, strict=True) raises ValueError when lengths differ, which is what you usually want for data that should line up.

range(stop) excludes stop: range(10) is 0 to 9, range(1, 10, 3) is [1, 4, 7], and range(5, 0, -2) is [5, 3, 1].

break, continue and pass

break leaves the loop, continue skips to the next iteration, and pass does nothing at all. People confuse the last two because both appear inside if blocks:

for n in range(5):
    if n == 2:
        pass      # no effect: 2 is still printed
    print("pass:", n)

for n in range(5):
    if n == 2:
        continue  # 2 is skipped
    print("continue:", n)

The first loop prints 0 to 4; the second prints 0, 1, 3, 4.

else on loops

A loop’s else block runs only if the loop finished without break. It replaces the “found” flag you would otherwise need:

def find_first_negative(nums):
    for i, n in enumerate(nums):
        if n < 0:
            print(f"first negative at index {i}")
            break
    else:
        print("no negatives")

find_first_negative([3, 1, -4, 1])  # first negative at index 2
find_first_negative([3, 1, 4])      # no negatives

while supports else too, which reads well for retry loops that give up. Many developers find the keyword name misleading (think of it as “no break”), so a short comment helps readers.

Two loop pitfalls

Loop variables are not scoped to the loop; they remain bound afterwards with their last value:

for i in range(3):
    pass
print(i)  # 2

And removing items from a list while iterating over it skips elements, because the iterator’s index keeps advancing while the list shifts left:

nums = [1, 2, 2, 3, 4]
for n in nums:
    if n % 2 == 0:
        nums.remove(n)
print(nums)  # [1, 2, 3]  -- one 2 survived

Build a new list instead: nums = [n for n in nums if n % 2 != 0] gives [1, 3]. List comprehensions are the idiomatic tool for this.

Python has no labeled break. To exit nested loops, move them into a function and return:

def find_pair(limit):
    for i in range(limit):
        for j in range(limit):
            if i * j > 10:
                return i, j
    return None

print(find_pair(5))  # (3, 4)

Exercise: FizzBuzz with the tools above

Show answer
for i in range(1, 16):
    match (i % 3, i % 5):
        case (0, 0):
            print("FizzBuzz")
        case (0, _):
            print("Fizz")
        case (_, 0):
            print("Buzz")
        case _:
            print(i)

Matching on a tuple of remainders makes the four cases explicit and removes the ordering subtlety of the if i % 15 == 0 version.

Next in the series

Python data types comes next, where the name-binding and aliasing rules from this post explain most of the surprises with lists and dicts. After that: functions and modules.