Skip to main content

Separate pure logic from I/O

Code is easier to test when the core logic is separate from reading files, making network requests, printing output, or touching a database. A small separation between pure logic and I/O often pays off immediately.

Why this matters

Functions become hard to test when they do everything at once:

def count_errors(path: str) -> int:
with open(path) as file:
lines = file.readlines()
print("Counting errors...")
return sum(1 for line in lines if "ERROR" in line)

This mixes together:

  • reading a file
  • printing a message
  • counting matching lines

The counting logic is simple, but testing it now requires a file on disk or extra mocking.

Pull the logic into its own function

def count_error_lines(lines: list[str]) -> int:
return sum(1 for line in lines if "ERROR" in line)


def count_errors_in_file(path: str) -> int:
with open(path) as file:
return count_error_lines(file.readlines())

Now the logic can be tested with plain input values:

lines = ["INFO started", "ERROR failed", "ERROR retried"]
print(count_error_lines(lines))

Output:

2

Pure logic is easier to reuse

A pure function is one that depends on its inputs and returns a result without hidden side effects.

That kind of function is easier to:

  • test
  • reuse
  • debug
  • move into another module later

The I/O layer still matters, but it should usually be thin. Let it gather inputs and pass them into logic functions.

Rules of thumb

  • Keep file access, printing, and network calls near the edges of the program.
  • Move calculations and transformations into separate functions.
  • Test logic with plain values when possible.
  • If a function is hard to test, check whether I/O and logic are mixed together.