Implementing the One-In-One-Out Rule in Python: A Step-by-Step Guide for Cleaner Code
Ever opened a function that looked like a tangled knot of arguments, globals, and side‑effects? You’re not alone. The One‑In‑One‑Out rule is a simple habit that can untangle that mess, and it works especially well in Python where readability is king.
Why the One‑In‑One‑Out Rule Matters
The rule says: every function should accept at most one piece of data and return at most one piece of data. In practice that means a single, well‑named argument and a single, well‑named return value. When you follow it, three things happen:
- The function becomes self‑contained. You can read it from top to bottom and understand what it does without hunting for hidden state.
- Testing gets easier. One input, one output = one test case per scenario.
- Refactoring stays safe. Changing the internals won’t ripple through the rest of the code because the contract stays the same.
I first tried this rule on a data‑cleaning script at my old job. The script had ten parameters, used a global config, and wrote directly to a file. After I forced it into a one‑in‑one‑out shape, the whole pipeline became a series of tiny, composable steps. It felt like swapping a rusty wrench for a set of precision screwdrivers.
Getting Started: Set Up Your Environment
Before you dive in, make sure you have a recent version of Python (3.9 or later) and a test runner like pytest. Having a test suite ready will let you verify that the refactor didn’t break anything.
Pick a Small Function to Refactor
Choose a function that is not too big but already shows signs of trouble – maybe it has more than three parameters or touches a global variable. A good candidate is something like:
def process_record(record, schema, logger, dry_run=False):
# lots of logic here
return success, errors
This function returns a tuple, which already violates the “one output” part. Let’s turn it into a clean, single‑input, single‑output function.
Step 1: Identify the Single Input
Ask yourself: what is the core piece of data the function really needs to do its job? In the example above, record is the data being transformed. The schema tells us how to interpret the record, and logger is a side‑effect. dry_run is a flag that changes behavior but does not belong to the data itself.
Create a small data class (or a simple dictionary) that bundles everything the function truly needs:
from dataclasses import dataclass
@dataclass
class ProcessContext:
record: dict
schema: dict
dry_run: bool = False
Now the function signature becomes:
def process(context: ProcessContext) -> dict:
...
You have reduced three parameters to one object that clearly represents the “input”.
Step 2: Identify the Single Output
What does the caller actually need after processing? Usually it’s either a transformed record or an error report, not both. Decide which is the primary result and wrap the secondary information in a field of the same output object.
@dataclass
class ProcessResult:
transformed: dict
errors: list
Now the function returns a single ProcessResult instance. The caller can inspect errors if needed, but the contract stays simple: one object out, one object in.
Step 3: Refactor the Body
With the new input and output types in place, rewrite the inner logic to use the fields of context. Remove any direct references to globals or external loggers. If you still need logging, inject a logger into the context or use Python’s built‑in logging module inside the function – but keep it optional.
def process(context: ProcessContext) -> ProcessResult:
if context.dry_run:
# skip actual write, just simulate
transformed = _transform(context.record, context.schema)
return ProcessResult(transformed=transformed, errors=[])
try:
transformed = _transform(context.record, context.schema)
_store(transformed) # side‑effect, but isolated
return ProcessResult(transformed=transformed, errors=[])
except Exception as exc:
return ProcessResult(transformed={}, errors=[str(exc)])
Notice how the function now has a clear flow: read input, do work, return result. No hidden state, no surprise side‑effects.
Common Pitfalls and How to Avoid Them
Too Many Parameters
It’s tempting to keep adding fields to the context object until it looks like a “bag of everything”. If you find yourself adding more than five fields, consider whether some of them belong to a separate helper object or whether the function is trying to do too much. Split the work into smaller functions that each get its own context.
Hidden State
Globals, module‑level caches, or mutable default arguments can silently break the rule. Replace them with explicit arguments or use dependency injection. For example, instead of reading a config file inside the function, pass a Config object through the context.
Putting It All Together: A Real Example
Let’s take a tiny CSV‑parsing task and apply the rule step by step.
import csv
from dataclasses import dataclass
from typing import List
@dataclass
class ParseContext:
path: str
delimiter: str = ','
encoding: str = 'utf-8'
@dataclass
class ParseResult:
rows: List[dict]
errors: List[str]
def parse_csv(context: ParseContext) -> ParseResult:
rows = []
errors = []
try:
with open(context.path, newline='', encoding=context.encoding) as f:
reader = csv.DictReader(f, delimiter=context.delimiter)
for i, row in enumerate(reader, start=1):
rows.append(row)
except Exception as e:
errors.append(f'Failed to read {context.path}: {e}')
return ParseResult(rows=rows, errors=errors)
The function now has a single input (ParseContext) and a single output (ParseResult). The caller can decide what to do with rows or errors without worrying about hidden side‑effects.
When to Relax the Rule
The rule is a guide, not a law. There are cases where a function naturally needs more than one input – for example, a factory that builds an object from a config and a runtime flag. In those cases, bundle related arguments into a data class, as we did with ProcessContext. The goal is always to keep the public signature simple and expressive.
By turning messy multi‑parameter functions into tidy one‑in‑one‑out units, you gain readability, testability, and confidence when you change code. It may feel like extra work at first, but the payoff shows up quickly in fewer bugs and smoother refactors. Give it a try on a small piece of your codebase today – you’ll be surprised how much cleaner it feels.
- → How to Build Your First End-to-End Machine Learning Project in Python @datascitrial
- → Build Your First Python Automation: A Step‑by‑Step Guide to Saving Hours with Simple Scripts @pythonstarter
- → A Step-by-Step Guide to Adding a Local LLM to Your Python App @techfrontier
- → Forecast Stock Prices with LSTM in Python: Complete Project Guide from Data Prep to Deployment @mltutorialhub
- → Create a Beginner‑Friendly Data Visualization: Plotting Your First Chart with Matplotlib in 10 Minutes @pythonstarter