shutil.rmtree onexc: Handle Directory Removal Errors

Published on: September 10, 2026
Reading time: 6 minutes
Python code for safe directory cleanup with shutil.rmtree

Deleting a directory tree looks simple until protected files, changing permissions, symbolic links, concurrent processes, or partial failures appear. shutil.rmtree() handles recursive deletion, while the onexc parameter gives your application a central place to process exceptions predictably. This is useful in cleanup scripts, automated tests, installers, data pipelines, deployment jobs, and applications that create temporary workspaces.

This guide explains how to use shutil.rmtree with onexc, how to understand the callback arguments, when a retry is reasonable, when an error should be logged, and when the operation must stop. The goal is not merely to remove folders, but to do it with safety, observability, and reproducible behavior.

What shutil.rmtree does

shutil.rmtree(path) deletes a directory and everything below it. Unlike Path.rmdir() or os.rmdir(), the directory does not need to be empty. That makes the function convenient and powerful, but also dangerous: an incorrect path can remove a large amount of data.

from shutil import rmtree

rmtree("build")

Before calling it, validate the target, avoid paths built directly from untrusted input, and never apply recursive deletion to a critical directory. For modern path traversal patterns, see Python pathlib.Path.walk and Python os.fwalk.

Why onexc matters

Several internal operations can fail during recursive deletion. A file may be read-only, an antivirus program may keep it open, permissions may change between listing and deletion, or another process may remove the item first. The onexc parameter receives a callback whenever an exception occurs.

from shutil import rmtree

def on_failure(function, path, error):
    print(f"Failed in {function.__name__}: {path}: {error}")

rmtree("build", onexc=on_failure)

The callback receives the function that failed, the path involved, and the exception object. This lets you make focused decisions without scattering broad try/except blocks across the application.

onexc versus ignore_errors

ignore_errors=True suppresses failures during removal. It is simple, but it also removes visibility. The program may finish while files remain, and later steps can assume a clean state that does not actually exist.

rmtree("cache", ignore_errors=True)

onexc is better when you need to record, repair, classify, or re-raise failures. Ignoring errors can be acceptable for disposable cleanup where an incomplete result has no consequence, but it should not be the default for critical workflows.

Repairing a read-only file

A common case is a file that cannot be deleted because its permissions do not allow modification. A callback can change that permission and retry only the failed operation.

import os
import stat
from shutil import rmtree

def repair_permission(function, path, error):
    if isinstance(error, PermissionError):
        os.chmod(path, stat.S_IWRITE)
        function(path)
        return
    raise error

rmtree("output", onexc=repair_permission)

This approach should be restricted to a directory tree owned by your application. Changing permissions on arbitrary targets can magnify the impact of a configuration error. The callback should repair only known situations and re-raise everything else.

Do not hide unknown exceptions

A callback that only prints and returns may leave the tree partially removed. In critical cleanup, re-raise errors that cannot be safely corrected.

def handle(function, path, error):
    if isinstance(error, FileNotFoundError):
        return
    raise error

FileNotFoundError may be harmless when another worker already removed the item. A permission failure, filesystem error, invalid path, or unexpected operating-system condition can indicate a larger problem. For explicit API contracts, read Python dataclasses.KW_ONLY.

Logging useful context

Production code should usually replace print() with structured logging. Include the failing function, path, exception type, and execution identifier.

import logging
from shutil import rmtree

log = logging.getLogger(__name__)

def record(function, path, error):
    log.error(
        "failed to remove path",
        extra={
            "operation": function.__name__,
            "path": path,
            "error_type": type(error).__name__,
        },
    )
    raise error

rmtree("artifacts", onexc=record)

Good logs help distinguish a transient lock from a permanent configuration problem. They also let a team measure how often cleanup remains incomplete.

Validate the target before deletion

A safe helper should resolve the path, compare it with an allowed root, and reject critical directories.

from pathlib import Path
from shutil import rmtree

ROOT = Path("/srv/my-app/work").resolve()

def delete_subdirectory(value):
    target = (ROOT / value).resolve()
    if target == ROOT or ROOT not in target.parents:
        raise ValueError("Path is outside the allowed root")
    rmtree(target)

This prevents traversal through values such as ../. It also stops an empty or malformed value from selecting the working root itself. For temporary workspace patterns, see Python tempfile.

Recursive filesystem operations require attention to symbolic links and race conditions. Modern Python implementations use additional protections on supported platforms, but your application must still control where targets come from and limit cleanup to directories it owns.

Do not run privileged cleanup against user-supplied paths. When a process has elevated permissions, a single validation mistake can reach files that would normally be protected.

Short and bounded retries

Some failures are temporary because another process still holds a file open. A retry policy should be small, bounded, and visible.

import time
from shutil import rmtree

class Handler:
    def __init__(self, retries=2):
        self.remaining = retries

    def __call__(self, function, path, error):
        if self.remaining and isinstance(error, PermissionError):
            self.remaining -= 1
            time.sleep(0.2)
            function(path)
            return
        raise error

rmtree("cache", onexc=Handler())

Never turn the callback into an infinite loop. If the condition persists, fail clearly and preserve enough context for diagnosis.

Using rmtree in automated tests

Tests frequently create temporary directories and need to remove them even when an assertion fails. Prefer context managers and fixtures, with rmtree as a controlled cleanup mechanism.

from pathlib import Path
from tempfile import mkdtemp
from shutil import rmtree

folder = Path(mkdtemp())
try:
    (folder / "data.txt").write_text("test", encoding="utf-8")
finally:
    rmtree(folder)

When several resources must be closed dynamically, Python contextlib.ExitStack provides a flexible way to register cleanup callbacks.

Idempotent cleanup

An idempotent cleanup routine can run more than once without producing an invalid state. One basic strategy is to handle the case where the directory is already gone.

from pathlib import Path
from shutil import rmtree

def remove_if_present(path):
    target = Path(path)
    if target.exists():
        rmtree(target)

The pre-check does not eliminate race conditions because the state may change immediately afterward. The callback can still tolerate FileNotFoundError when that outcome is acceptable.

Designing callbacks that are testable

Keep the callback small and deterministic. Separate policy from side effects. One function can decide whether an exception is recoverable, another can perform the repair, and a third can log the final failure. This makes unit testing easier and prevents a large callback from becoming an unstructured error handler.

def recoverable(error):
    return isinstance(error, (FileNotFoundError, PermissionError))

Tests should cover a missing file, a read-only file, an unexpected exception, and a retry that still fails. Verify that unexpected errors are not swallowed.

Version compatibility

onexc is available in modern Python versions as a clearer exception callback for rmtree. Check the minimum Python version of your project before adopting it. A library that supports older environments may need a compatibility layer or a different callback strategy.

The official shutil.rmtree documentation describes the current signature, security details, and exception behavior. The pathlib documentation is a complementary reference for resolving and validating paths.

A safer cleanup helper

import logging
import os
import stat
from pathlib import Path
from shutil import rmtree

log = logging.getLogger(__name__)

def cleanup(path, root):
    root = Path(root).resolve()
    target = Path(path).resolve()
    if target == root or root not in target.parents:
        raise ValueError("Target is not allowed")

    def onexc(function, value, error):
        if isinstance(error, FileNotFoundError):
            return
        if isinstance(error, PermissionError):
            os.chmod(value, stat.S_IWRITE)
            function(value)
            return
        log.exception("incomplete cleanup", extra={"path": str(value)})
        raise error

    rmtree(target, onexc=onexc)

The helper validates the allowed root, tolerates concurrent disappearance, repairs one known permission case, and re-raises everything unexpected.

Operational safeguards

Consider adding a dry-run mode that lists intended targets without deleting them. In scheduled jobs, record the number of files before and after cleanup. For high-value data, move a directory to a quarantine area first and delete it only after validation. These patterns reduce the blast radius of a mistake and create a window for recovery.

Do not use recursive deletion as a substitute for retention policy. Define which directories may be deleted, how old they must be, and which process owns them. A robust cleanup job has both technical checks and business rules.

Best practices

Resolve the path before deletion. Restrict operations to a controlled root. Never pass external input directly. Re-raise unknown errors. Log enough context. Avoid infinite retries. Test protected files and already-removed directories. Confirm the Python version. Do not combine ignore_errors=True with an expectation of complete deletion.

Conclusion

shutil.rmtree is a direct tool for deleting directory trees, and onexc turns unpredictable failures into explicit policy decisions. With path validation, logs, narrow repairs, and re-raised exceptions, cleanup becomes far more reliable.

Start with a callback that records and re-raises. Add only recovery behaviors that you understand and can test. This prevents automation from hiding partially removed trees or expanding the impact of an incorrect path.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Data analytics chart for Python statistics.kde
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    statistics.kde: Estimate Probability Densities

    Learn Python statistics.kde to estimate densities, choose bandwidths, compare kernels, and interpret distributions responsibly.

    Ler mais

    Tempo de leitura: 6 minutos
    10/09/2026
    Code and file structure managed with Python contextlib.ExitStack
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    contextlib.ExitStack: Manage Dynamic Resources

    Learn Python contextlib.ExitStack to manage dynamic resources, cleanup callbacks, optional contexts, and exceptions safely.

    Ler mais

    Tempo de leitura: 4 minutos
    09/09/2026
    Software developer creating text templates with Python string.Template
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    string.Template: Safe and Simple Text Templates

    Learn Python string.Template for configurable messages, placeholder validation, safe mappings, previews, and maintainable text rendering.

    Ler mais

    Tempo de leitura: 6 minutos
    09/09/2026
    Synchronized team representing Python asyncio.Barrier
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    asyncio.Barrier: Synchronize Tasks in Phases

    Learn Python asyncio.Barrier to synchronize tasks in phases, coordinate pipelines, avoid races, and handle cancellation safely.

    Ler mais

    Tempo de leitura: 5 minutos
    08/09/2026
    Software developer building models with Python dataclasses.KW_ONLY
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    dataclasses.KW_ONLY: Require Keyword-Only Arguments

    Learn Python dataclasses.KW_ONLY to require named arguments, prevent ambiguous calls, and evolve public APIs more safely.

    Ler mais

    Tempo de leitura: 5 minutos
    08/09/2026
    Software developer using Python operator.methodcaller
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    operator.methodcaller: Call Methods in Pipelines

    Learn Python operator.methodcaller for map, sorted, callbacks, arguments, reusable transformations, and clearer declarative pipelines.

    Ler mais

    Tempo de leitura: 5 minutos
    07/09/2026