One file works until it does not. The moment a script holds two unrelated ideas, the right move is to split it - but where the lines fall matters more than the number of files. This guide takes a working single-file program, pulls it apart, and shows the rules that keep the result easy to navigate.
When does a single file stop being enough?
File size is a poor signal. A two-thousand-line file of generated data and a two-thousand-line file of unrelated business rules are not the same problem. The signal is change: if one part of the file changes every week and another part has not changed since you wrote it, they are two modules that happen to share a filename.
Four concrete symptoms mean it is time to split.
- Two sections of the file never need each other, but you scroll past each other constantly.
- You keep copy-pasting a helper into another script, and the copies drift apart.
- The same name means two different things in different parts of the file.
- You cannot write a test for one part without importing the side effects of the other.
The last symptom is the strongest one. A file that must be executed to be useful cannot be tested piece by piece, and untestable code stays unfixed.
Where should the split lines go?
The rule that works most often: a module is one reason to change. Data shapes live with each other, business rules live together, and input and output handling sits at the edges. Here is the single-file program we are about to break up.
# shifts.py - everything in one file
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class Shift:
"""A single shift worked by a team member."""
person: str
day: date
hours: float
SHIFTS = [
Shift("ada", date(2026, 10, 5), 8),
Shift("ada", date(2026, 10, 6), 9.5),
Shift("grace", date(2026, 10, 6), 7),
Shift("grace", date(2026, 10, 7), 8),
Shift("linus", date(2026, 10, 6), 10),
]
def total_hours(shifts: list[Shift]) -> float:
"""Return the total number of hours worked."""
return sum(shift.hours for shift in shifts)
def hours_by_person(shifts: list[Shift]) -> dict[str, float]:
"""Return the hours worked per person."""
totals: dict[str, float] = {}
for shift in shifts:
totals[shift.person] = totals.get(shift.person, 0) + shift.hours
return totals
def busiest_day(shifts: list[Shift]) -> date | None:
"""Return the day with the most hours, or None when there are no shifts."""
totals: dict[date, float] = {}
for shift in shifts:
totals[shift.day] = totals.get(shift.day, 0) + shift.hours
if not totals:
return None
return max(totals, key=totals.get)
for person, hours in sorted(hours_by_person(SHIFTS).items()):
print(f" {person:<8} {hours:>6.1f}")
print(f"{len(SHIFTS)} shifts, {total_hours(SHIFTS):.1f} hours total")
Three different things are tangled together: the Shift record, the calculations, and the printing. The printing at the bottom is the most important smell - it is why the file can only be tested by running it.
Splitting is not the same as making files smaller. If you break the program into three files of roughly equal length, you have made it harder to read, because now the reader has to open three files to follow one idea. Split along the seams that already exist in the problem: the data shape, the rules, the presentation and the entry point.
Sample data deserves its own mention. In the original file the list of shifts sits in the middle of the program, so importing the file for its functions also imports the data. Moving that list into the entry point keeps the data next to the decision to use it, which is what makes the rest of the package reusable from a different script tomorrow.
What does the split program look like?
Four files, each with one job, and a package folder to hold them.
shiftplan/
├── __init__.py # empty - it marks the folder as a package
├── models.py # the Shift record
├── scheduler.py # the calculations
├── report.py # turning results into text
└── __main__.py # the entry point
# shiftplan/models.py
"""The data types the planner works with."""
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class Shift:
"""A single shift worked by a team member."""
person: str
day: date
hours: float
@property
def is_long(self) -> bool:
"""Return True when the shift runs longer than eight hours."""
return self.hours > 8
# shiftplan/scheduler.py
"""Pure calculations over a list of shifts."""
from collections import defaultdict
from datetime import date
from .models import Shift
def total_hours(shifts: list[Shift]) -> float:
"""Return the total number of hours worked."""
return sum(shift.hours for shift in shifts)
def hours_by_person(shifts: list[Shift]) -> dict[str, float]:
"""Return the hours worked per person."""
totals: dict[str, float] = defaultdict(float)
for shift in shifts:
totals[shift.person] += shift.hours
return dict(totals)
def busiest_day(shifts: list[Shift]) -> date | None:
"""Return the day with the most hours, or None when there are no shifts."""
totals: dict[date, float] = defaultdict(float)
for shift in shifts:
totals[shift.day] += shift.hours
if not totals:
return None
return max(totals, key=totals.get)
# shiftplan/report.py
"""Turning a schedule into text for humans."""
from .models import Shift
from .scheduler import hours_by_person, total_hours
def render(shifts: list[Shift]) -> str:
"""Return a readable report of the schedule."""
lines = [f"{len(shifts)} shifts, {total_hours(shifts):.1f} hours total"]
for person, hours in sorted(hours_by_person(shifts).items()):
lines.append(f" {person:<8} {hours:>6.1f}")
return "\n".join(lines)
# shiftplan/__main__.py
"""Run the planner demo with: python -m shiftplan"""
from datetime import date
from .models import Shift
from .report import render
from .scheduler import busiest_day
SAMPLE = [
Shift("ada", date(2026, 10, 5), 8),
Shift("ada", date(2026, 10, 6), 9.5),
Shift("grace", date(2026, 10, 6), 7),
Shift("grace", date(2026, 10, 7), 8),
Shift("linus", date(2026, 10, 6), 10),
]
def main() -> None:
"""Print the sample schedule."""
print(render(SAMPLE))
day = busiest_day(SAMPLE)
print("busiest day:", day.isoformat() if day else "none")
if __name__ == "__main__":
main()
Run it with python -m shiftplan from the folder that contains the package. The report and the busiest day come from the same data as before, and now the calculations can be tested without printing anything.
python -m shiftplan
Five shifts and 42.5 hours, with ada on 17.5 and grace on 15.0. The busiest day is the 6th, with three people working.
How do you keep the import graph healthy?
The rule is one-directional: the entry point knows about everything, the report knows about the calculations, the calculations know about the data types, and the data types know about nobody. Nothing points back up the chain, so the graph cannot contain a cycle.
import ast
from pathlib import Path
for path in sorted(Path("shiftplan").glob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
imports = [
node.module
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.level == 1
]
print(f"{path.name}: {imports}")
Run from the folder holding the package, this prints the relative imports of every module - report.py imports from models and scheduler, scheduler.py imports from models, and models.py imports from nothing. That is the shape you want, and a script like this one tells you in seconds when a new import breaks the pattern.
Two habits keep the graph clean. Modules that contain calculations should take plain data and return plain data, so they can be tested without touching the filesystem or the network. And the module that knows about the outside world - the entry point - is the only one allowed to read input and print output.
Which file is the entry point?
One file per program owns the start. For a package, that file is __main__.py, and python -m shiftplan runs it. For a script, the file you invoke with python file.py is the entry point, and the if __name__ == "__main__": guard keeps the rest of the file importable.
Two habits make the difference between a program and a library. Put the sample data in the entry point rather than in a data module, so importing the package has no side effects. And keep the entry point thin: it wires things together and calls functions, but it contains no calculations of its own.
Names are part of the structure too. scheduler.py tells a reader what lives inside it before they open it, while utils.py or helpers.py tells them nothing. When two modules need similar names, that is usually a sign one of them is missing a concept of its own - a pricing.py and a billing.py are two ideas, whereas a utils.py is a drawer for things nobody has named yet.
Quick reference
- Split when parts of a file change for different reasons, not when the file gets long.
- One module, one reason to change: data types, rules, presentation, entry point.
- Keep the import graph acyclic and pointing downwards, from the entry point to the data types.
- Calculations take data and return data, which is what makes them testable.
- Give the package a
__main__.pyand run it withpython -m shiftplan.
Try it yourself: Take the single-file program on this page and split it exactly as shown. Then split it differently: move the printing into a render.py and the sample data into a sample_data.py, and decide which of the two layouts you would rather work in. Finally, write a test that calls busiest_day with an empty list and asserts it returns None - if that test is easy to write, the split worked.