Processing hundreds or thousands of files is one of those tasks that starts simple and slowly turns into a maintenance nightmare. What begins as a small loop over a folder quickly becomes a tangled mess of duplicated logic, fragile error handling, and scripts that silently skip half the input. Over the years I have built, torn down, and rebuilt enough bulk file pipelines to know that a little structure up front saves a lot of pain later.
In this tutorial we will walk through a clean, reusable approach to batch-processing files in Python. We will cover the core building blocks, a real example that ties them together, a comparison table, and a few tips I wish someone had told me earlier.
Why a plain loop is not enough
Most people start with something like this:
import os
from pathlib import Path
for name in os.listdir("input"):
if name.endswith(".csv"):
# ... process the file inline
pass
It works, but it has problems. Error handling is usually bolted on after the fact. There is no way to know which files failed and why. Re-running the script starts from scratch every time. And the moment someone adds a new file type, the loop grows another if branch until it becomes unreadable.
A better approach separates three concerns:
- Discovery - finding the files you actually care about.
- Processing - doing one well-defined job per file.
- Reporting - telling you what succeeded and what failed.
Each concern lives in its own function. That makes the pipeline testable, reusable, and easy to extend.
Step 1: Discover files safely
Use pathlib and a recursive gloss with a suffix filter. Avoid collecting everything into memory when you can iterate lazily.
from pathlib import Path
from typing import Iterator, Iterable
def discover_files(root: str | Path, extensions: Iterable[str]) -> Iterator[Path]:
root = Path(root)
wanted = {ext.lower() for ext in extensions}
for path in root.rglob("*"):
if path.is_file() and path.suffix.lower() in wanted:
yield path
rglob walks subdirectories for you and yields paths as it goes. The set of wanted extensions is lowercased once so the suffix comparison stays cheap even with thousands of files.
Step 2: Write one focused processor
Keep the per-file logic in a single callable. It should do one job and raise an exception on failure rather than swallowing errors.
import csv
from pathlib import Path
from dataclasses import dataclass
@dataclass
class FileResult:
path: Path
rows: int
skipped: int
def process_csv(path: Path, threshold: float = 10.0) -> FileResult:
rows = 0
skipped = 0
with path.open("r", newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle)
for row in reader:
rows += 1
try:
value = float(row.get("value", "0"))
except ValueError:
skipped += 1
continue
if value > threshold:
# pretend we are writing this to an output sink
pass
return FileResult(path=path, rows=rows, skipped=skipped)
Keep the processor pure: no printing, no logging, no side effects beyond the work itself. Let a later layer decide how to report the outcome.
Step 3: Orchestrate with clear reporting
Now tie discovery and processing together and collect results.
import traceback
from dataclasses import dataclass, field
@dataclass
class BatchReport:
ok: list[FileResult] = field(default_factory=list)
failed: list[tuple[Path, str]] = field(default_factory=list)
@property
def total_files(self) -> int:
return len(self.ok) + len(self.failed)
@property
def success_rate(self) -> float:
if not self.total_files:
return 0.0
return len(self.ok) / self.total_files * 100
def run_batch(root: str, extensions):
report = BatchReport()
for path in discover_files(root, extensions):
try:
report.ok.append(process_csv(path))
except Exception as exc: # keep the batch alive
report.failed.append((path, str(exc)))
traceback.print_exc()
return report
The catch-all at the orchestrator level is intentional. A single malformed file should not nuke an overnight run of ten thousand files. You log the traceback, record the failure, and move on.
A side-by-side comparison
| Approach | Error isolation | Re-runnable | Extensible | Lines of glue |
|---|---|---|---|---|
| Inline loop | Poor | Manual reset | Poor | Few, but tangled |
| Single processor script | Medium | Partial | Medium | Medium |
| Discovery + processor + report | Strong | Add checkpointing | High | Slightly more |
The extra structure buys you far more than the small amount of boilerplate it costs.
Step 4: Add checkpointing for long runs
When a job takes hours, you want to resume after a crash instead of restarting. A simple approach is to write a manifest of completed files and skip anything already in it.
def load_done(manifest: Path) -> set[str]:
if not manifest.exists():
return set()
return {line.strip() for line in manifest.read_text().splitlines()}
def run_with_checkpoints(root, extensions, manifest_path):
done = load_done(manifest_path)
with manifest_path.open("a", encoding="utf-8") as out:
for path in discover_files(root, extensions):
if str(path) in done:
continue
try:
process_csv(path)
except Exception as exc:
traceback.print_exc()
else:
out.write(str(path) + "\n")
out.flush()
Flushing after every successful file means an interrupted run loses at most the one file that was in flight.
Step 5: Make it a repeatable script
Wrap everything behind a small command line entry point using argparse so you do not have to edit code to point at a new directory.
import argparse
def main() -> None:
parser = argparse.ArgumentParser(description="Batch process CSV files")
parser.add_argument("root", help="directory to scan")
parser.add_argument("--ext", nargs="+", default=[".csv"], help="file extensions")
parser.add_argument("--threshold", type=float, default=10.0)
args = parser.parse_args()
report = run_batch(args.root, args.ext)
print(f"Processed {len(report.ok)} files, failed {len(report.failed)}")
print(f"Success rate: {report.success_rate:.1f}%")
for path, err in report.failed:
print(f"FAILED {path}: {err}")
if __name__ == "__main__":
main()
Practical tips I learned the hard way
-
Always specify the encoding. Opening files and hoping the platform default is right is how you get cryptic
UnicodeDecodeErrorat 2 a.m. on a box with a different locale. -
Open files with
newline=""when reading CSV in Python. The csv module behaves much more predictably across operating systems. - Prefer generators over lists. For a million files, materializing a list of paths beats holding them all in memory, and it also means the first file is processed before discovery even finishes.
- Log the path, not just the error. "Failed to parse value" is useless if you cannot tell which of 5,000 files it came from.
- Keep the processor dependency-free. If the core per-file logic only uses the standard library, you can unit test it in isolation and reuse it in other projects.
- Test on a shuffled sample first. Run a batch against a small, randomized subset before committing to the full run. It catches 90 percent of the surprises.
Putting it together
Here is a minimal end-to-end usage of the pieces above:
from pathlib import Path
# 1. discover
files = list(discover_files("data/inbox", ('.csv', '.tsv')))
print(f"Found {len(files)} files")
# 2. process with reporting
report = run_batch("data/inbox", ('.csv',))
# 3. evaluate
print(f"OK={len(report.ok)} FAILED={len(report.failed)} "
f"rate={report.success_rate:.1f}%")
When to grow into a pipeline framework
The pattern above comfortably handles thousands of files and a handful of file types. When your needs grow past that, consider introducing proper batch processing tooling built around the same three abstractions. The discipline does not change: discover cleanly, process one thing well, and report honestly.
For most real-world workloads, though, the standard library is enough. Resist the urge to pull in a heavy dependency for a job a few well-structured functions can do.
Summary
Bulk file processing in Python does not have to be a swamp of nested loops and swallowed exceptions. Separate discovery, processing, and reporting; make each piece testable; add checkpointing for long runs; and keep failure information explicit. The result is a pipeline you can extend, rerun, and debug with confidence.
Next time you open a folder with ten thousand files and a tight deadline, start with structure, not speed. The structure is what will save you, every single time.
Top comments (0)