Learn Python’s object model, syntax, functions, loops, exceptions, and classes through tested examples and a complete batch-processing script.
Learn Python’s object model, syntax, functions, loops, exceptions, and classes through tested examples and a complete batch-processing script.
Python can look familiar when you already write Java, Scala, or another backend language. An if still chooses a branch. A function still receives arguments and returns a result. The surprises usually appear one level below the syntax: a second name can refer to the same mutable object, a type hint does not reject a value at runtime, and a loop can have an else clause.
I want to start this series with those rules in view. This first article covers Python’s basic syntax, types, functions, loops, exception handling, and classes. By the end, you should be able to follow a small batch-processing script and explain why it behaves the way it does.
This is the opening article in Python for Backend Developers. The examples use Python 3.14, were tested on CPython 3.14.4, and need no third-party packages. They are learning examples, with the input and failure behavior kept visible.
Start with a file named hello.py. Python uses indentation to group the statements inside a branch, loop, function, or class. A colon introduces the indented body in the example below. Four spaces per indentation level is the convention in PEP 8.
service_name = "catalog"
request_count = 3
if request_count > 0:
print(f"{service_name}: {request_count} requests")Run the file with python3 hello.py on a system where python3 selects your Python 3 interpreter. On Windows, an installed Python launcher may provide py hello.py. Check which interpreter you are using with python3 --version or py --version. The interpreter tutorial describes the available invocation styles.
There are no variable declarations in this example. Assignment binds a name to a value. The string prefixed with f is an f-string: expressions inside braces are inserted into the text. The indented print runs only when the condition is true.
Arithmetic has a detail worth checking early if you are coming from JVM integer arithmetic:
print(7 / 2) # 3.5
print(7 // 2) # 3
print(7 % 2) # 1
print(-7 // 2) # -4: floor division rounds downFor these integer operands, / produces a floating-point result. // performs floor division, which rounds toward negative infinity. It does not simply remove the fractional digits toward zero. See the official introduction to numbers for the operators.
Python represents data as objects. An object has a type, an identity, and a value. The type determines which operations make sense for that object. A name can later be rebound to an object of another type; that does not convert the earlier object. The data model defines these concepts.
For a first pass, learn the built-in families below. These are practical groupings, not a diagram of inheritance.
| Family | Types and examples | Useful distinction |
|---|---|---|
| Numbers | int, float, complex42, 3.5, 2j | Immutable |
| Booleans | boolTrue, False | Immutable; bool subclasses int |
| Text | str"catalog" | Immutable Unicode text |
| Sequences | list, tuple, range[1, 2], (1, 2), range(3) | list is mutable; tuple and range are immutable |
| Mappings | dict{"retries": 2} | Mutable key/value mapping |
| Sets | set, frozenset{1, 2}, frozenset({1, 2}) | set is mutable; frozenset is immutable |
| Absence | NoneTypeNone | The singleton absence value |
A list can change its elements. A str cannot change its characters in place. A tuple’s element references are fixed, but an object referenced by the tuple can itself be mutable. For example, a list inside a tuple can still receive an append.
The diagram shows selected real inheritance relationships. object is the common base. bool is a subclass of int; float is not. The numeric values True and False therefore behave like 1 and 0 in arithmetic. Keep that in mind when a validation rule must exclude booleans from integer input.
print(type(42).__name__) # int
print(type("42").__name__) # str
print(type(None).__name__) # NoneType
print(isinstance(True, int)) # True
print(issubclass(bool, int)) # True
print(isinstance(3.5, int)) # Falsetype(value) gives its concrete type. isinstance(value, SomeType) also accepts instances of subclasses. These checks answer different questions, as the built-in function reference explains. An expression such as 42 + "1" raises TypeError; Python does not automatically turn that string into a number.
A statement can span physical lines inside parentheses, square brackets, or braces. Prefer that implicit continuation when splitting a long call or expression. Adjacent string literals can also be joined inside parentheses, while triple-quoted strings preserve actual line breaks.
timeout_seconds = (
10
+ 5
)
message = (
"Processing the batch. "
"Invalid rows will be reported."
)
report = """Batch finished.
Two jobs accepted."""
print(timeout_seconds) # 15
print(message)
print(report)message is one line of text. The space at the end of its first literal separates the two sentences. report contains a newline. Indentation inside a triple-quoted string becomes part of the string, so it can affect templates or generated output. These rules are covered in lexical analysis.
Triple quotes create a string, not a general-purpose comment. A string literal placed first in a function, class, or module body can serve as its documentation string, or docstring. Use # for ordinary comments. For a long SQL query or message, choose the string form based on whether the resulting value should contain line breaks.
Names are case-sensitive. For ordinary ASCII identifiers, use letters or an underscore first, then letters, digits, or underscores. request_count is valid; 2requests is not. Reserved keywords cannot be identifiers. Python also permits many Unicode identifiers, but familiar ASCII names make shared backend code easier to read.
Use snake_case for variables and functions, CapWords for classes, and UPPER_CASE for constants by convention. A constant name does not prevent reassignment. Avoid names such as list, str, or type for application variables because they hide useful built-ins. See PEP 8 naming conventions.
The more consequential rule is that assignment binds a name to an object. It does not copy the object. Two names can therefore refer to one list.
pending = ["build"]
alias = pending
alias.append("test")
print(pending) # ['build', 'test']
print(alias is pending) # True
alias = ["deploy"]
print(pending) # ['build', 'test']
print(alias) # ['deploy']
print([1, 2] == [1, 2]) # True: equal contents
print([1, 2] is [1, 2]) # False: distinct listsappend changes the shared list. The later assignment to alias changes only that binding. Use == to compare values and is to ask whether two references identify the same object. Use is None for the absence marker. Avoid identity checks for numeric or string values, where object reuse can make experiments misleading.
Assignment inside a function normally creates or updates a local name. It does not automatically update a name with the same spelling outside that function:
timeout = 10
def choose_timeout():
timeout = 20
return timeout
print(choose_timeout()) # 20
print(timeout) # 10Python has explicit global and nonlocal declarations for other binding behavior. For small functions, passing input and returning a result usually makes the dependency clearer. The execution model describes name binding and scope.
An if/elif/else chain evaluates conditions in order and executes the first matching branch. elif means another condition in that same chain. An else handles the remaining cases.
status_code = 429
retry_after = 0
if status_code == 200:
action = "accept"
elif status_code == 429:
action = "wait"
else:
action = "inspect"
delay = 5 if retry_after is None else retry_after
print(action, delay) # wait 0
print(bool("False")) # True: this string is not emptyConditions use truth-value testing. None, False, numeric zero, and empty built-in containers are false. A non-empty string such as "False" is true. This makes if jobs: useful when you mean “there is at least one job.” It does not make truthiness a parser for textual Boolean values.
Be precise about missing values. In this example, zero is a valid delay. Writing retry_after or 5 would replace zero with the fallback. The explicit is None check preserves it. and and or short-circuit and return an operand, so their result need not be a Boolean.
Use and, or, and not for Boolean logic. A chained comparison such as 0 < attempts <= 3 expresses an interval directly. The truth-value and Boolean operation reference explains these rules.
def defines a function. A call supplies arguments, and return supplies its result. Arguments can be positional or named. A default value lets a caller omit an argument.
def describe_job(name: str, attempts: int = 1) -> str:
return f"{name}: attempts={attempts}"
print(describe_job("email"))
print(describe_job(attempts=2, name="index"))
def announce(message: str) -> None:
print(message)
result = announce("Ready")
print(result is None) # TrueThe annotations name: str and -> str describe the expected input and result. They help readers and static tools. Python does not enforce those annotations by itself:
def double(value: int) -> int:
return value * 2
print(double(3)) # 6
print(double("a")) # aa: intentionally violates the hintThe second call deliberately violates the annotation. It still runs because multiplying a string by an integer repeats the string. A type checker can flag that call before execution; the interpreter does not reject it because of the hint. Validate external input at the boundary where it enters your program. See the type-hint documentation.
A function that reaches its end without returning a value returns None. That is why announce prints a message but does not produce a message as its result. Decide whether a function should compute a value, perform an action, or deliberately do both.
One default-argument trap deserves attention now: default expressions are evaluated when the function is defined. Using labels=[] and then mutating that list would share it across calls that omit the argument. Create the list inside the function instead:
def add_label(label: str, labels: list[str] | None = None) -> list[str]:
if labels is None:
labels = []
labels.append(label)
return labels
print(add_label("batch")) # ['batch']
print(add_label("urgent")) # ['urgent']Each omitted argument now gets a fresh list. If the caller supplies a list, this function intentionally mutates that supplied list. The | None annotation means either a list of strings or None. The tutorial’s default-argument section explains the evaluation rule.
A while loop tests its condition before each iteration. If the condition is false initially, the body never runs. Use it when repetition depends on changing state.
remaining = 3
while remaining > 0:
print(remaining)
remaining -= 1
print("Done")This prints 3, 2, 1, and then Done. The assignment remaining -= 1 makes progress toward termination. Without it, the condition would stay true.
When reviewing a while loop, identify the state that changes and the path that ends the loop. For a real polling operation, that usually also requires a deadline and a wait between attempts. This countdown performs no I/O and is not a retry policy. The while statement reference defines the control flow.
break exits the nearest enclosing loop. continue skips the remainder of the current iteration and starts the next one. A try/except statement handles selected exceptions raised while executing the try body.
Here is a bounded example: inspect candidate strings until one represents a positive timeout. Blank input, a failed integer conversion, and a non-positive value each cause another iteration.
candidates = ["", "later", "0", "15"]
index = 0
while index < len(candidates):
raw = candidates[index]
index += 1
if not raw.strip():
continue
try:
timeout = int(raw)
except ValueError:
print(f"Not an integer: {raw}")
continue
if timeout <= 0:
continue
print(f"Selected timeout: {timeout}")
break
else:
print("No valid timeout found")The output is Not an integer: later, followed by Selected timeout: 15. Notice that the index advances before any continue. Moving that increment to the bottom would leave some branches examining the same rejected value forever.
The loop’s else runs when the condition becomes false without a break. It reports that every candidate was rejected. It also runs for an initially empty list. The control-flow tutorial covers break, continue, and loop else.
The try body contains only int(raw). A ValueError from that conversion is expected for malformed numeric text. Catching every exception around the whole loop could hide a different bug and turn it into “bad input.” If an exception does not match a handler, it propagates outward.
A try statement can also have else and finally clauses:
raw = "12"
try:
value = int(raw)
except ValueError:
print("Invalid integer")
else:
print(f"Parsed: {value}")
finally:
print("Attempt finished")The else body runs when the try body finishes normally. finally runs as control leaves the statement, including ordinary exception propagation and loop control such as break or continue. Avoid returning from finally, which can replace a result or suppress an exception. For files, prefer a with block to manage closing them. See handling exceptions and the try statement reference.
A Python for loop takes items from an iterable. An iterable is an object that can supply items for iteration. Lists, strings, dictionaries, and ranges are common examples. You do not need an index to walk through a list’s values.
jobs = ["email", "index"]
for position, name in enumerate(jobs, start=1):
print(position, name)
for attempt in range(1, 4):
print(f"Attempt {attempt}")
settings = {"timeout": 10, "retries": 2}
for key, value in settings.items():
print(key, value)enumerate supplies a position and a value together. range(1, 4) supplies 1, 2, and 3; the stop value is excluded. Iterating over a dictionary directly produces its keys. Use .items() when you need key/value pairs. These patterns are covered in the looping techniques tutorial.
A for loop can also have an else. Think of it as the “no break occurred” path:
def find_positive(values: list[int]) -> None:
for value in values:
if value > 0:
print(f"Found: {value}")
break
else:
print("No positive value found")
find_positive([-3, 0, 8]) # Found: 8
find_positive([]) # No positive value foundThe empty list reaches the else without executing the body. A continue does not cancel the else; a break does. In a loop containing more than one branch, that distinction helps separate “nothing matched” from “a match ended the search.”
Avoid adding or removing items from the same collection you are iterating over. Build a separate result collection, or iterate over a suitable copy when mutation is necessary. A list copy is shallow: nested mutable objects remain shared.
A class defines a type of object. An instance holds its own state, and methods provide behavior associated with that state. Start with a small example:
class Job:
kind = "batch"
def __init__(self, name: str, attempts: int = 1) -> None:
self.name = name
self.attempts = attempts
def can_retry(self, max_attempts: int) -> bool:
return self.attempts < max_attempts
first = Job("email")
second = Job("index")
first.attempts += 1
print(first.attempts, second.attempts) # 2 1
print(first.can_retry(3)) # True
print(second.kind) # batch__init__ initializes an instance after it is created. self is the conventional name for the instance parameter. Calling first.can_retry(3) supplies first as that parameter automatically.
self.attempts is instance state, so changing the first job leaves the second job unchanged. kind is a class attribute, found through each instance when it has no attribute of the same name. A mutable list placed directly on a class would be shared; create per-instance collections inside __init__.
This class keeps its fields public and assumes suitable constructor arguments. A leading underscore can signal an internal attribute by convention, but it is not an access-control boundary. The classes tutorial explains instance attributes, methods, and class attributes.
Use a function when you only need a transformation. Use a class when a named piece of state and its behavior belong together. A class hierarchy is not required to process a few input rows.
The following program accepts simplified name,attempts rows. It skips blank rows, records invalid rows, constructs valid jobs, and stops when it has accepted the requested number of jobs. The format is intentionally small: it does not support quoted commas or implement CSV parsing.
The parser converts text to an integer. The constructor rejects an empty name or non-positive attempt count. The loop decides whether to collect an error, accept a job, or stop. Those responsibilities make the error path visible without adding a framework.
Save this complete example as basic_of_python.py and run python3 basic_of_python.py:
"""A small batch parser for the Basic of Python article. Python 3.14, no packages."""
class Job:
def __init__(self, name: str, attempts: int) -> None:
if not name:
raise ValueError("name is required")
if attempts < 1:
raise ValueError("attempts must be positive")
self.name = name
self.attempts = attempts
def summary(self) -> str:
return f"{self.name}: attempts={self.attempts}"
def parse_job(raw: str) -> Job:
parts = raw.split(",")
if len(parts) != 2:
raise ValueError("expected name,attempts")
name, raw_attempts = parts
return Job(name.strip(), int(raw_attempts))
def collect_jobs(
rows: list[str], limit: int
) -> tuple[list[Job], list[str]]:
if limit < 1:
raise ValueError("limit must be positive")
jobs: list[Job] = []
errors: list[str] = []
for line_number, raw in enumerate(rows, start=1):
if not raw.strip():
continue
try:
job = parse_job(raw)
except ValueError as error:
errors.append(f"line {line_number}: {error}")
continue
jobs.append(job)
if len(jobs) >= limit:
break
return jobs, errors
def main() -> None:
rows = ["email,2", "", "broken", "report,three", "index,0", "cleanup,1"]
jobs, errors = collect_jobs(rows, limit=10)
for job in jobs:
print(job.summary())
print(f"Accepted: {len(jobs)}; rejected: {len(errors)}")
if __name__ == "__main__":
main()Expected output:
email: attempts=2
cleanup: attempts=1
Accepted: 2; rejected: 3The count excludes the blank row. limit bounds accepted jobs, so the function stops scanning as soon as that many jobs are collected. Rows after that point are not validated. For very large input, this example would also need a deliberate policy for how many rejected rows to retain in memory.
The if __name__ == "__main__" guard runs main() when the file is executed as a script. Importing the file makes its functions and class available without running the demonstration. The examples and checks exercise blank input, invalid numbers, non-positive attempts, independent instance state, loop exhaustion, and early termination.
A useful next step is to change the sample inputs and predict the output before running it. Try an empty batch, a whitespace-only name, a zero attempt count, and a limit of one. Those changes exercise the language rules more directly than memorizing a list of keywords.
This article begins the Python foundations phase. I will keep the series page focused on articles that are ready or in progress. You can browse all engineering series or follow new articles through the newsletter.
Next in Python for Backend Developers: Variables and Memory in Python: References and Mutability. Follow names through reassignment, mutation, function arguments, and copying.
For a second pass, use the Python tutorial, the language reference, and the standard library reference. The tutorial develops the language through examples; the references are useful when a precise rule matters.

Senior Technical Architect at HCLTech · working with Leonteq Security AG
I have 10+ years of experience building distributed systems and fintech platforms. I write about practical, non-obvious engineering details that official documentation often skips.
Scala, JVM, AI, and backend systems. I send practical articles when there is something worth reading.
Join the newsletter