<< Home

Chapter 2: Foundations of the Python Language

Every programming language has its own rhythm—its way of arranging thought into structure, logic into form. Python’s rhythm is unusually clean. It invites clarity not by constraint, but by design. Its rules are simple enough to learn in a day, yet subtle enough to reward a lifetime of practice.

At its heart, Python treats readability as a discipline. Code that looks simple is often simpler to reason about, and Python’s syntax enforces that discipline from the start. Instead of symbols and ceremony, it prefers whitespace and meaning. The structure you see is the structure that runs.

Understanding this foundation is about more than just learning rules; it’s about learning how Python thinks. Each line reflects an underlying philosophy that explicit is better than implicit, names carry weight, and everything lives within a clear and logical space. When you see Python code, you are reading a small story written in order and intention.

This chapter begins to shape that awareness. It explores how Python arranges ideas, how names and values interact, and how its scoping rules give coherence to what might otherwise seem fluid. These ideas form the language’s backbone, defining how Python code behaves at every level.

By mastering these foundations, you begin to see why Python feels both rigorous and humane, why its syntax disappears when your thoughts align with it, and why those who learn it rarely return to languages that speak in harsher tones.

Statements vs. expressions

Every line of Python does something, but not all lines do it in the same way. Some lines perform an action; others produce a value. The difference lies between statements and expressions.

An expression is something that can be evaluated to yield a result. It has a value that Python can use within a larger operation. A statement is a complete instruction—something Python executes rather than evaluates. You can think of an expression as a phrase, and a statement as a full sentence.

When you type 3 + 4, Python computes and returns the value 7. That is an expression. When you write print(3 + 4), Python performs an action: it displays a result. That is a statement. The first produces data; the second produces an effect.

Some expressions can appear inside statements, and many statements depend on expressions to do their work. The line between them is functional rather than absolute. For example, an if statement uses an expression to decide which block of code to run, but the if itself is not an expression as it doesn’t yield a value. Understanding this distinction helps when you read or write code that mixes both ideas. Expressions build the flow of data, and statements shape the flow of control. Together, they form the pulse of Python’s execution model.

Comments and docstrings

Python code is meant to be read by people as well as machines. Comments and docstrings are how you speak directly to those people, your future self included. They do not change what the program does, but they shape how it is understood.

A comment begins with a hash mark (#) and continues to the end of the line. Python ignores it completely. Comments are best used to explain why something is done, not what the code is doing. Clear code often needs little commentary, but when reasoning or design choices are not obvious, a short note can make all the difference.

A docstring, short for documentation string, lives inside triple quotes ("""like this""") and is attached to a module, class, or function. Unlike comments, docstrings are stored at runtime and can be accessed through the help() system or the .__doc__ attribute. They serve as part of the program’s living documentation:

def greet(name):
  """Return a friendly greeting.
  Args:
    name: The person’s name as a string.
  Returns:
    A message that greets the person.
  """
  return f"Hello, {name}!"

Here the triple-quoted string describes what the function does, what its parameter means, and what value it returns. You can view it directly in an interactive session:

>>> help(greet)
Help on function greet in module __main__:

greet(name)
  Return a friendly greeting.
  Args:
    name: The person’s name as a string.
  Returns:
    A message that greets the person.

Because docstrings are visible to tools, they are often written in a consistent format. The first line gives a short summary; following lines may describe arguments, return values, or examples. This convention lets others understand a function without reading its full implementation.

Thoughtful commenting and concise docstrings create a dialogue between code and reader. They remind us that programs are not just instructions for a computer—they are also explanations for other humans who will one day need to know what we meant.

💡 A docstring is a string literal placed immediately after a function, class, or module definition. It becomes part of that object’s documentation and can be viewed using help() or the .__doc__ attribute.

Variables and assignment

In Python, variables are not boxes that contain values. They are names that point to objects held elsewhere in memory. When you assign a value to a variable, you are binding a name to an object, not copying the object itself. This distinction shapes how data behaves throughout a program.

Consider the line x = [1, 2, 3]. The name x now refers to a list object stored in memory. If you then write y = x, both x and y point to the same list. Changing the contents through one name affects what the other sees. Assignment links names; it does not duplicate data.

x = [1, 2, 3]
y = x
y.append(4)
print(x) # [1, 2, 3, 4]

Immutable objects such as numbers, strings, and tuples behave differently because they cannot be changed once created. When you “modify” them, Python actually creates a new object and binds the name to that new object. This is why a = 5 followed by a = a + 1 does not alter the original number 5. It simply makes a point to 6 instead.

Understanding this binding model helps avoid subtle bugs, especially when working with mutable objects like lists or dictionaries. Variables are not containers; they are labels that direct Python to the current object associated with a given name.

Dynamic typing and name binding

Python is a dynamically typed language, which means that names are not bound to a fixed type. A variable’s type is determined by the object it refers to at a given moment, and that binding can change freely as the program runs. This flexibility makes Python expressive and concise, though it also demands care in reasoning about what each name refers to.

When you write x = 10, the name x is bound to an integer object. If you later write x = "ten", the same name now points to a string. The original integer still exists somewhere in memory until Python’s garbage collector reclaims it, but x no longer refers to it. The binding between names and objects is fluid.

x = 10
print(type(x)) # <class 'int'>

x = "ten"
print(type(x)) # <class 'str'>

This design makes Python adaptable. You can reuse variable names in different contexts, and functions can operate on any object that behaves as expected, regardless of its type. This approach, known as “duck typing,” emphasizes behavior over declaration. If an object acts like a list or a file, Python treats it as one.

Dynamic typing encourages flexibility but also calls for discipline. Because names can change meaning, clear naming and consistent conventions become vital. The freedom to bind any object to any name is powerful, but with that freedom comes the responsibility to write code that remains predictable and clear to others.

⚠️ Dynamic typing can hide subtle bugs when a name changes type unexpectedly. For example, a variable used for arithmetic might later be rebound to a string, breaking later code that assumes it still holds a number:
count = 5
count = count + 1 # count is now 6
count = "six"     # name now points to a string
total = count + 4 # TypeError: cannot add a string and a number

Namespaces and scope

Every name in Python lives inside a namespace, which is a mapping between names and the objects they reference. You can think of a namespace as a labeled environment, keeping track of what each name currently means. Python manages several such environments at once, which together determine how and where a name can be found.

The concept of scope describes the visibility of a name within a program. When Python encounters a name, it looks for it in a specific order known as the LEGB rule: Local, Enclosing, Global, and Built-in. This sequence defines how Python resolves names from the most specific context to the most general.

💡 Grasping how scopes and namespaces work is essential for understanding variable behavior, especially when defining functions inside functions or modifying global state. The LEGB rule ensures that Python always resolves names in a predictable order, keeping the language’s dynamic nature under clear control.

When you use a name inside a function, Python first checks the Local scope (the function’s own variables). If it’s not found there, Python looks one level out, in any Enclosing scopes (functions that contain this one). If still not found, it checks the Global scope (module-level names). Finally, it searches the Built-in scope, which holds standard names like len and print.

x = "global"

def outer():
  x = "enclosing"
  def inner():
    x = "local"
    print(x)
  inner()

outer() # prints "local"

In this example, each assignment of x creates a new binding within a different scope. When print(x) runs, Python finds x in the local scope of inner() and stops searching. If that line were removed, Python would look outward through the enclosing, global, and built-in namespaces until it found a match.