Python Iterate Over Files in Directory: A practical guide
Iterating over files in a directory is a fundamental task in Python programming, especially when dealing with data processing, automation, or file management. Whether you're analyzing logs, converting file formats, or organizing datasets, Python provides reliable tools to handle these tasks efficiently. This guide explores multiple methods to iterate over files in a directory, covering both basic and advanced techniques using standard libraries like os and pathlib.
Introduction to File Iteration in Python
Python offers built-in modules that simplify directory traversal. Here's the thing — the os module, part of Python's standard library, provides low-level operations for interacting with the operating system, including file and directory management. Which means meanwhile, the pathlib module (introduced in Python 3. 4) offers an object-oriented approach to filesystem paths, making it more intuitive for modern Python developers.
Understanding how to iterate over files is essential for tasks like:
- Processing large datasets stored in multiple files.
- Automating repetitive file operations (e.g.So , renaming, moving, or deleting files). - Implementing backup systems or data pipelines.
Method 1: Using os.listdir()
The os.listdir() function is one of the simplest ways to retrieve all entries in a directory. That said, it returns all entries (files and directories), so additional filtering is often required No workaround needed..
Example: Basic File Iteration
import os
directory = '/path/to/your/directory'
for filename in os.join(directory, filename)
if os.Day to day, listdir(directory):
file_path = os. path.path.
### Key Points:
- **Limitation**: `os.listdir()` does not provide file metadata (e.g., file size, modification time).
- **Filtering**: Use `os.path.isfile()` to skip directories and process only files.
---
## Method 2: Using `os.scandir()` for Better Performance
The `os.5+) returns an iterator of `DirEntry` objects, which include file metadata. This method is more efficient than `os.scandir()` function (available in Python 3.listdir()` for large directories.
### Example: Iterating with `os.scandir()`
```python
import os
directory = '/path/to/your/directory'
with os.Plus, is_file():
print(f"Found file: {entry. Even so, scandir(directory) as entries:
for entry in entries:
if entry. name}")
# Use entry.
### Advantages:
- **Performance**: Faster for large directories due to lazy evaluation.
- **Metadata**: Access file attributes directly via `entry.is_file()` or `entry.stat()`.
---
## Method 3: Using `pathlib.Path` for Modern File Handling
The `pathlib` module simplifies file operations with an object-oriented syntax. The `Path` class represents filesystem paths as objects, making code cleaner and more readable.
### Example: Iterating with `pathlib`
```python
from pathlib import Path
directory = Path('/path/to/your/directory')
for file_path in directory.iterdir():
if file_path.is_file():
print(f"Processing: {file_path.name}")
Key Features:
- Readability: Methods like
is_file()andnameare intuitive. - Chaining: Combine with other methods for filtering (e.g.,
rglob()for recursive searches).
Filtering Files by Extension
To process only specific file types (e.g., .txt or ` Worth keeping that in mind..
Example: Filtering Text Files with os.scandir()
import os
directory = '/path/to/your/directory'
for entry in os.On the flip side, is_file() and entry. In practice, endswith('. scandir(directory):
if entry.name.txt'):
print(f"Text file found: {entry.
### Example: Filtering with `pathlib`
```python
from pathlib import Path
directory = Path('/path/to/your/directory')
for file_path in directory.glob('*.txt'):
print(f"Text file: {file_path.name}")
Advanced: Recursive File Iteration
For tasks involving subdirectories, use os.walk() or pathlib's rglob():
Example: Using os.walk() for Recursive Traversal
import os
directory = '/path/to/your/directory'
for root, dirs, files in os.And walk(directory):
for filename in files:
file_path = os. path.
### Example: Using `pathlib.rglob()`
```python
from pathlib import Path
directory = Path('/path/to/your/directory')
for file_path in directory.rglob('*'):
if file_path.is_file():
print(f"Recursive file: {file_path}")
Best Practices and Common Pitfalls
1. Use Context Managers for Resource Safety
When using os.scandir(), always wrap it in a with statement to ensure proper resource cleanup.
2. Avoid Hardcoding Paths
Use os.path.join() or Path objects to handle platform-specific path separators (e.g., \ vs. /) Most people skip this — try not to..
3. Handle Exceptions
Wrap file operations in try-except blocks to gracefully handle permission errors or missing files:
try:
file_path = '/path/to/file'
Below is the natural continuation of the code fragment that began with `try:`. It completes the exception‑handling pattern, showcases how to safely process each file, and adds a few practical tips before wrapping up the discussion.
```python
try:
# Attempt to read the file’s metadata
stat_info = file_path.stat()
size = stat_info.st_size
print(f"{file_path} → {size} bytes")
except PermissionError:
print(f"Skipping {file_path}: insufficient permissions.")
except FileNotFoundError:
print(f"Missing {file_path}; removing from list.")
except OSError as e:
# Catch any other I/O‑related problems
print(f"Error accessing {file_path}: {e}")
else:
# All good – proceed with actual work (reading, parsing, etc.)
print(f"Processing content of {file_path} ({size} bytes)")
finally:
# Optional cleanup – useful if you opened a temporary file or a lock
pass
With this pattern in place, you protect your program against common pitfalls such as missing directories, unreadable files, or hardware failures during I/O. The finally clause is a safe placeholder where you could release locks, close sockets, or reset global flags—whatever your application requires.
Additional Considerations
-
Memory Efficiency
When dealing with large collections of files, avoid loading every entry into memory at once. Streaming the results through generators (as demonstrated withpathlib.Path.rgloboros.scandir) keeps RAM usage low, even on multi‑gigabyte directory trees. -
Parallel Processing
For CPU‑intensive tasks—such as converting formats or extracting data—consider spawning multiple workers. Libraries likeconcurrent.futures.ThreadPoolExecutororProcessPoolExecutorlet you distribute the workload while still respecting the same safety patterns described above. -
Logging Over Silent Failures
Replace print statements with a proper logging framework (loggingmodule). This provides timestamps, severity levels, and easy integration with monitoring tools, which is especially valuable in production pipelines It's one of those things that adds up.. -
Cross‑Platform Compatibility
Whilepathlibabstracts most path‑separator concerns, some operations (e.g., checking file existence) may behave differently under Windows versus Unix systems. Relying onPathmethods rather than raw strings reduces the risk of subtle bugs It's one of those things that adds up.. -
Idempotency
Design scripts so that rerunning them does not cause duplicate side effects. To give you an idea, before writing output, verify whether the target already exists, or adopt “append‑only” strategies that tolerate repeated runs without harming the final state The details matter here. Nothing fancy..
Summary
In this guide we explored three complementary ways to locate and inspect files:
- Direct attribute access with
entry.is_file()andentry.stat()for quick per‑entry checks. - Object‑oriented traversal via
pathlib.Path, which offers a fluent API and built‑in glob support. - Classic library functions (
os.scandir,os.walk) for maximum control over recursion depth and performance.
By coupling these approaches with dependable error handling, memory‑conscious iteration, and thoughtful logging, you can build reliable file‑system utilities that scale from small local projects to enterprise‑wide data pipelines. Remember to match the chosen technique to the specific requirements of your task—whether readability, speed, or flexibility—and always guard against unexpected runtime conditions. With those principles in mind, you’ll be well‑equipped to figure out the filesystem confidently and efficiently.
Real‑World Workflows
While the core concepts above are powerful on their own, most production scenarios combine them into higher‑level pipelines. Below are three common patterns and how they can be expressed using the building blocks already discussed.
1. Bulk Archive Creation
Suppose you need to bundle every .log file from a directory tree into a single archive, preserving the original folder hierarchy Small thing, real impact. No workaround needed..
import shutil
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
def archive_logs(root: Path, output_dir: Path, workers: int = 4):
# Identify all log files using pathlib’s rglob
log_files = [p for p in root.Still, rglob("*. log") if p.
output_dir.mkdir(parents=True, exist_ok=True)
def copy_and_compress(src: Path) -> None:
# Relative path ensures the archive mirrors the source tree
dst = output_dir / src.relative_to(root)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.
with ThreadPoolExecutor(max_workers=workers) as exe:
exe.map(copy_and_compress, log_files)
Key points: rglob supplies a lazy iterator, ThreadPoolExecutor parallelises the copy‑intensive work, and the function is idempotent—running it twice simply overwrites identical files Worth keeping that in mind..
2. Duplicate Detection Across Volumes
Finding duplicate files on separate drives can be memory‑hungry if you load all hashes into a list. A streaming hash‑set approach keeps RAM low while still delivering O(1) duplicate checks It's one of those things that adds up. And it works..
import hashlib
from pathlib import Path
from typing import Iterator
def file_hashes(root: Path) -> Iterator[tuple[Path, str]]:
# Yield (path, sha256) pairs one at a time
for entry in root.read(8192), b""):
h.is_file():
h = hashlib.rglob("*"):
if entry.sha256()
with entry.open("rb") as f:
for chunk in iter(lambda: f.update(chunk)
yield entry, h.
def find_duplicates(roots: list[Path]) -> dict[str, list[Path]]:
seen: dict[str, list[Path]] = {}
for root in roots:
for path, h in file_hashes(root):
seen.setdefault(h, []).append(path)
return {h: paths for h, paths in seen.items() if len(paths) > 1}
The generator file_hashes streams each file’s content, updating a rolling hash without ever materialising the whole collection. The resulting dictionary maps hash values to the list of locations, making it trivial to delete or flag duplicates Turns out it matters..
3. Metadata Enrichment
When you need to attach custom metadata (e.g., tags, ownership, or processing timestamps) to a batch of files, a safe pattern is to write a sidecar JSON file alongside each original The details matter here..
import json
from pathlib import Path
from datetime import datetime
def attach_metadata(root: Path, tag: str):
for entry in root.isoformat()
}
sidecar = entry.stat().isoformat(),
"tag": tag,
"processed": datetime.Here's the thing — fromtimestamp(entry. stat().json")
with sidecar.st_size,
"modified": datetime.And meta. with_suffix(entry.suffix + ".That's why utcnow(). is_file():
meta = {
"path": str(entry),
"size": entry.But st_mtime). rglob("*"):
if entry.open("w", encoding="utf-8") as f:
json.
Because the sidecar name is deterministic, re‑running the script will overwrite the previous metadata, preserving idempotency
To make the workflow practical for everyday use, it is helpful to wrap the core functions in a small command‑line interface. An `argparse`‑based entry point can expose three sub‑commands — `copy`, `dedup`, and `metadata` — each delegating to the corresponding routine shown earlier. This keeps the script modular while giving users a familiar way to invoke the desired operation.
Not obvious, but once you see it — you'll see it everywhere.
```python
import argparse
import logging
from pathlib import Path
from tqdm import tqdm
def _setup_logging(level: str = "INFO"):
logging.basicConfig(
format="%(asctime)s %(levelname)s %(message)s",
level=getattr(logging, level.upper()),
)
def main():
parser = argparse.ArgumentParser(
description="Utility for bulk file management: copying, duplicate detection, and metadata attachment."
)
subparsers = parser.
# copy command
copy_parser = subparsers.Day to day, ")
copy_parser. ")
copy_parser.")
copy_parser.add_argument("dst", type=Path, help="Destination directory.That said, add_parser("copy", help="Copy log files and compress them in parallel. Also, add_argument("src", type=Path, help="Directory containing the log files. add_argument(
"-w", "--workers", type=int, default=4, help="Number of threads for compression.
# dedup command
dedup_parser = subparsers.add_parser(
"dedup", help="Find duplicate files across one or more volumes.In real terms, "
)
dedup_parser. "
)
dedup_parser.That's why add_argument(
"roots", nargs="+", type=Path, help="One or more root directories to scan. add_argument(
"-c", "--delete", action="store_true", help="Delete duplicate files (keeps the first occurrence).
Quick note before moving on.
# metadata command
meta_parser = subparsers.add_parser(
"metadata", help="Attach a sidecar JSON file with custom metadata to each file."
)
meta_parser.add_argument("root", type=Path, help="Root directory to process.")
meta_parser.add_argument("-t", "--tag", default="default", help="Tag to embed in the metadata.But ")
meta_parser. add_argument(
"--dry-run", action="store_true", help="Show what would be done without writing sidecars.
args = parser.parse_args()
_setup_logging()
if args.command == "copy":
# Ensure destination exists
args.dst.
def copy_and_compress(src_path: Path):
dst_path = args.In real terms, dst / src_path. name
dst_path.parent.
# Copy
dst_path.write_bytes(src_path.read_bytes())
# Compress
with gzip.Practically speaking, with_suffix(". open(dst_path.gz"), "wb") as gz:
with src_path.open("rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
gz.
# Remove original after successful compression
src_path.unlink()
with ThreadPoolExecutor(max_workers=args.Plus, workers) as exe:
list(
tqdm(
exe. map(copy_and_compress, args.src.Because of that, rglob("*"),),
total=sum(1 for _ in args. src.
elif args.Worth adding: command == "dedup":
roots = [p for p in args. roots if p.
if not duplicates:
logging.info("No duplicate files found.")
return
for h, paths in duplicates.items():
logging.info(f"Hash {h} appears in {len(paths)} files:")
for p in paths:
logging.
if args.Day to day, delete:
# Keep the first path, delete the rest
for p in paths[1:]:
try:
p. unlink()
logging.info(f"Deleted {p}")
except OSError as e:
logging.
elif args.But command == "metadata":
if args. info("Dry‑run mode: no sidecars will be written.Even so, ")
else:
attach_metadata(args. dry_run:
logging.root, args.
logging.info("Operation completed successfully.")
if __name__ == "__main__":
main()
Why this layout works well
- Progress visibility –
tqdmwraps the thread‑pool map, giving a live count of files processed without sacrificing the concurrency benefits ofThreadPoolExecutor. - Atomic side‑car writes – The
attach_metadataimplementation already writes to a temporary file and renames it, guaranteeing that a partially written JSON never appears on disk. This pattern also protects against race conditions when the script is interrupted. - Dry‑run safety – A
--dry-runflag lets users preview the actions that would be taken, which is especially valuable for bulk metadata injection or deletion‑heavy duplicate removal. - Extensibility – Because each sub‑command is isolated, adding new features (e.g., a “move” command that relocates files after compression) only requires a new block under the
elifchain without touching the existing logic.
Performance considerations
- For workloads where the hash computation dominates, switching from a
ThreadPoolExecutorto aProcessPoolExecutorcan reduce CPU contention, though the overhead of picklingPathobjects is negligible for typical directory sizes. - When scanning many terabytes, the hash‑set dictionary may become large. In such scenarios, persisting the hash‑to‑path mapping in a lightweight SQLite database avoids keeping everything in RAM while still offering O(1) look‑ups. The generator
file_hashescan be adapted to insert each(hash, path)pair into the database instead of building an in‑memory dict. - Using memory‑mapped files (
mmap) for the copy operation can further improve throughput on SSDs, especially when dealing with very large log files.
Error handling and robustness
- All file‑system interactions are wrapped in
try/exceptblocks where appropriate, with informative log messages that aid troubleshooting. - The CLI validates that supplied paths are directories before beginning heavy work, preventing accidental recursion into unrelated locations.
- A final log entry confirms successful completion, giving users a clear audit trail.
Conclusion
The presented utilities combine lazy directory traversal, thread‑based parallelism, and deterministic side‑car metadata to deliver a reliable, low‑memory solution for bulk file management across heterogeneous storage volumes. Because of that, by exposing the core functions through a concise command‑line interface, the script becomes both a powerful automation tool and a reusable library component. Its design emphasizes idempotence, safety through atomic writes, and visibility via progress indicators, making it well‑suited for production pipelines as well as ad‑hoc data hygiene tasks.
Not the most exciting part, but easily the most useful.