DEV Community

Davis Mark
Davis Mark

Posted on

A Pragmatic Guide to Processing Bulk Files in Python Without Losing Your Sanity

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Discovery - finding the files you actually care about.
  2. Processing - doing one well-defined job per file.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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 UnicodeDecodeError at 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}%")
Enter fullscreen mode Exit fullscreen mode

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)