Files, Modules, and Packaging

Reading and writing files with with, pathlib and JSON, and organizing code into modules and packages with __init__.py.

Reading and writing files

open() returns a file object you read from or write to. The second argument is the mode: "r" (read, the default), "w" (write, truncating any existing content), "a" (append), each optionally combined with "b" for binary data:

Python
# Reading an entire file at once
with open("notes.txt", "r") as f:
    contents = f.read()
    print(contents)

# Reading line by line — memory-efficient for large files
with open("notes.txt", "r") as f:
    for line in f:
        print(line.strip())   # .strip() removes the trailing newline

# Writing — "w" truncates the file first if it already exists
with open("output.txt", "w") as f:
    f.write("First line\n")
    f.write("Second line\n")

# Appending — adds to the end without touching existing content
with open("log.txt", "a") as f:
    f.write("Another entry\n")

f.readlines() reads the whole file into a list of lines at once; f.read() reads it as one big string. For anything beyond a small file, iterating the file object directly (for line in f:) is preferable to readlines() — it reads one line at a time instead of loading everything into memory up front.

The with statement and why it matters here

Every example above opens the file inside a with block instead of a bare f = open(...). This isn't a style preference — a file handle is a limited operating-system resource that must be explicitly closed, and with guarantees that happens even if an exception is raised while reading or writing:

Python
# Fragile — if something raises between open() and close(), the file is never closed
f = open("data.txt")
contents = f.read()
f.close()

# Robust — f.close() runs automatically, even on an exception
with open("data.txt") as f:
    contents = f.read()

with works because open() returns a context manager — any object implementing __enter__ (run when the block starts) and __exit__ (run when the block ends, exception or not). The same pattern applies to anything else that needs guaranteed cleanup — a database connection, a network socket, a lock — and it's covered in more depth, including writing your own context manager, on the advanced-python page in this track.

Working with paths: pathlib

Modern Python code builds file paths with the pathlib module rather than manually concatenating strings with / or os.path.join:

Python
from pathlib import Path

config_dir = Path("config")
config_file = config_dir / "settings.json"   # / is overloaded to join path segments

print(config_file)              # config/settings.json (or config\settings.json on Windows)
print(config_file.exists())     # True/False
print(config_file.suffix)       # .json
print(config_file.stem)         # settings

config_file.parent.mkdir(parents=True, exist_ok=True)   # create the directory if needed

if config_file.exists():
    text = config_file.read_text()
else:
    config_file.write_text("{}")

Path objects are cross-platform by design — the same code produces a correctly-formatted path whether it runs on Windows, macOS, or Linux, which manual string concatenation with a hardcoded / or \ does not.

JSON in the standard library

Reading and writing JSON — the most common structured data format for config files and API payloads — needs no third-party dependency at all:

Python
import json

data = {"name": "Ada", "age": 30, "active": True}

with open("user.json", "w") as f:
    json.dump(data, f, indent=2)   # write directly to a file, pretty-printed

with open("user.json", "r") as f:
    loaded = json.load(f)          # read directly from a file
    print(loaded["name"])          # Ada

# json.dumps()/json.loads() (with an "s") work on strings instead of files
json_string = json.dumps(data)
parsed = json.loads(json_string)

Organizing code into modules

Any .py file is automatically a module — a self-contained unit of code that another file can import. Splitting a growing script into modules by responsibility (one for data models, one for utility functions, one for the entry point) is how Python code stays maintainable past a few hundred lines.

Python
# math_utils.py
def add(a, b):
    return a + b

def multiply(a, b):
    return a * b

PI = 3.14159
Python
# main.py
import math_utils

print(math_utils.add(2, 3))          # 5
print(math_utils.PI)                  # 3.14159

from math_utils import multiply       # import one name directly, no prefix needed
print(multiply(4, 5))                 # 20

from math_utils import add as sum_two  # import with a local alias
print(sum_two(1, 1))                   # 2

import math_utils runs math_utils.py from top to bottom exactly once (Python caches already-imported modules, so re-importing elsewhere in the same run is nearly free) and binds the name math_utils to the resulting module object, giving access to everything defined in it via dot notation.

Organizing modules into packages

A package is a directory of related modules, turned into something importable by containing an __init__.py file:

Plaintext
myapp/
    __init__.py
    models.py
    utils.py
    services/
        __init__.py
        payment.py
        notification.py
Python
# myapp/models.py
class User:
    def __init__(self, name):
        self.name = name
Python
# myapp/services/payment.py
def charge(amount):
    return f"Charged {amount}"
Python
# Importing from a package elsewhere in the project
from myapp.models import User
from myapp.services.payment import charge

user = User("Ada")
print(charge(100))   # Charged 100

__init__.py can be completely empty — its mere presence is historically what told Python "this directory is a package" (Python 3.3+ also supports "namespace packages" without one, but an explicit __init__.py remains the clear, conventional default). It's also a convenient place to re-export selected names, so callers get a shorter import path:

Python
# myapp/__init__.py
from myapp.models import User
from myapp.services.payment import charge
Python
# now a caller can do this instead of importing from the deeper submodule paths:
from myapp import User, charge

Absolute vs relative imports

An absolute import spells out the full path from the project's top-level package — from myapp.services.payment import charge, as above. A relative import, used only inside a package, refers to a sibling or parent module by its position relative to the current file:

Python
# myapp/services/notification.py
from .payment import charge          # . means "the same package as this file"
from ..models import User            # .. means "one package level up"

Absolute imports are generally preferred for clarity — reading from myapp.services.payment import charge immediately tells you exactly where charge lives, whereas from ..payment import charge requires knowing the current file's location in the package tree to resolve. Relative imports are still common inside a package's own internals, where the whole package might later be renamed or relocated as a unit.

The if __name__ == "__main__": guard

Every module has a built-in __name__ variable — set to "__main__" if the file was run directly (python3 script.py), or to the module's own name if it was imported by something else. This guard is the standard way to write a file that's both directly runnable and safely importable elsewhere without its top-level code executing a second time:

Python
# analyzer.py
def analyze(data):
    return sum(data) / len(data)

def main():
    sample = [1, 2, 3, 4, 5]
    print(f"Average: {analyze(sample)}")

if __name__ == "__main__":
    main()

Running python3 analyzer.py directly executes main() and prints the average. But import analyzer from another file runs the module (defining analyze and main) without calling main() — because in that case __name__ is "analyzer", not "__main__", so the if block's body never runs. This is what lets a file serve as both a standalone script and a reusable library.

Common mistakes

  • Opening a file without with (or without an explicit try/finally around .close()) — a crash between open() and .close() leaks the file handle for the rest of the process's lifetime.
  • Forgetting __init__.py in a directory meant to be a package (in older code, or on older Python where implicit namespace packages aren't in use) and being confused why import fails to find it.
  • Writing deeply nested relative imports (from ...models import User) that become fragile and hard to follow the moment a file moves — prefer absolute imports from the project root for anything beyond a single sibling reference.
  • Putting meaningful top-level code (that isn't just a function/class definition) outside an if __name__ == "__main__": guard — it silently re-runs every time the module is imported elsewhere, which is rarely the intended behavior.

Interview questions

Q: What does if __name__ == "__main__": actually check, and why is it useful? It checks whether the current file was run directly (in which case Python sets __name__ to "__main__") versus imported as a module from somewhere else (in which case __name__ is the module's own name). Guarding a script's "do the actual work" code with this check lets the same file be both a standalone, runnable script and a safely importable library — importing it elsewhere won't accidentally re-execute its top-level logic.

Q: What's the practical difference between an absolute import and a relative import inside a package? An absolute import (from myapp.services.payment import charge) spells out the full path from the project's top-level package, so it's unambiguous no matter which file it's written in. A relative import (from .payment import charge) is resolved relative to the current file's position inside the package, using . for the same package and .. for one level up — it only works inside a package (not in a standalone script) and can get harder to follow the deeper the relative path goes.