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:
# 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:
# 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:
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:
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.
# math_utils.py
def add(a, b):
return a + b
def multiply(a, b):
return a * b
PI = 3.14159
# 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:
myapp/
__init__.py
models.py
utils.py
services/
__init__.py
payment.py
notification.py
# myapp/models.py
class User:
def __init__(self, name):
self.name = name
# myapp/services/payment.py
def charge(amount):
return f"Charged {amount}"
# 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:
# myapp/__init__.py
from myapp.models import User
from myapp.services.payment import charge
# 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:
# 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:
# 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 explicittry/finallyaround.close()) — a crash betweenopen()and.close()leaks the file handle for the rest of the process's lifetime. - Forgetting
__init__.pyin 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 whyimportfails 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.