skip to content

What can go wrong when shutil.rmtree deletes a directory tree?

level: seniorimportance: should knowfreq 34%

answer

  1. A walk, not a single operation
  2. Failing halfway leaves a mess
  3. Two knobs: swallow, or handle and retry
  4. Rename first, delete afterwards
  5. The link at the root is refused

basics

~20 s

It can stop partway and leave a half-deleted tree, refuse a path that is itself a symlink, and fail on read-only or still-open files. Handle failures with the onexc callback or ignore_errors, and never point it at unvalidated input.

solid answer

~50 s

`shutil.rmtree` walks a tree and unlinks everything, and it is not atomic — a permission error partway through leaves a partially deleted directory, so a cleanup step that fails is not simply a no-op. Failures are routed to the `onexc` callback added in Python 3.12, which receives the function that failed, the path, and the exception instance; the classic use is clearing a read-only bit and retrying, which is what makes cleanup work on Windows. `ignore_errors=True` swallows everything instead. A symlink handed in as the root raises `OSError` rather than being followed, and symlinks found inside the tree are unlinked without the target being touched, so it will not walk out of the tree. On platforms that support the file-descriptor-relative implementation it also resists symlink swaps mid-walk; the module exposes a flag saying whether that holds.

code

python · 19 lines
python
import os
import shutil
import stat
import tempfile

d = tempfile.mkdtemp()
locked = os.path.join(d, "locked.csv")
with open(locked, "w", encoding="utf-8") as f:
    f.write("id,amount\n")
os.chmod(locked, stat.S_IRUSR)


def clear_readonly(func, path, exc):
    os.chmod(path, stat.S_IWUSR)
    func(path)


shutil.rmtree(d, onexc=clear_readonly)
print("removed:", not os.path.exists(d))

go deeper

for a junior

Recall that shutil.rmtree removes a whole tree while os.rmdir only removes an empty directory, and that it raises rather than doing nothing when the path is missing. Treat it as a destructive call and double-check the path before running it.

for a middle

Explain the mechanics: it walks and deletes as it goes, so it is not atomic; ignore_errors=True swallows failures while the onexc callback added in 3.12 receives the failed function, the path and the exception so you can repair and retry.

for a senior

Show the production judgement — rename the tree before removing it so observers never see a half-deleted directory, write the read-only retry handler that makes cleanup portable, and treat any externally-influenced path as unsafe to hand to it.

for a principal

Own the guardrails around destructive operations: who is allowed to call recursive deletion, how paths are constrained to a root the service created, and whether cleanup is a soft rename-and-sweep with retention rather than an immediate irreversible delete.

## A destructive call deserves the same scrutiny as a database delete `shutil.rmtree(path)` removes a directory and everything under it. It is the counterpart to `os.rmdir`, which only works on an empty directory, and it is the engine behind `tempfile.TemporaryDirectory` cleanup. Because it destroys data, its failure modes are worth knowing in detail rather than discovering during an incident. ## It is a walk, not an operation The first thing to internalise is that `rmtree` is not atomic. It descends the tree unlinking entries and removing directories as it goes. If it raises halfway — one unreadable subdirectory, one file another process still holds open — everything it already removed stays removed. A nightly cleanup that deletes a payment reconciliation job's staging root and fails on the third of four run directories has not "failed safely"; it has left the tree in a state no code expected, and a retry now sees a half-empty directory. Where the intermediate state is visible to other processes, the standard trick is to make the disappearance atomic and the deletion lazy: rename the tree to a scratch name inside the same directory with `os.rename`, which is instantaneous and atomic within a filesystem, and then `rmtree` the renamed path. Observers see the directory vanish at once, and a failure during the slow removal leaves only orphaned scratch, not a partially gutted live tree. ## Error handling: `onexc`, `onerror` and `ignore_errors` By default any failure propagates. Two knobs change that. `ignore_errors=True` discards every error and keeps going, leaving whatever could not be removed. It is right for genuinely best-effort cleanup and wrong anywhere you need to know the directory is actually gone, because it turns a failure into silence. `onexc=handler` is the precise tool, added in Python 3.12. The handler is called as `handler(function, path, exception)` — the operation that failed (`os.unlink`, `os.rmdir`, `os.scandir`), the path it failed on, and the exception *instance*. It can inspect, log, repair and retry, or re-raise to abort the walk. The older `onerror` parameter is the same idea with a worse signature: it receives the three-tuple from `sys.exc_info()` instead of the exception. Python 3.12 documented `onerror` as deprecated in favour of `onexc`; it still functions on Python 3.14, so old code keeps working, but new code should use `onexc`. The canonical handler clears a read-only bit and retries. On Windows a read-only file genuinely cannot be deleted, which is why unguarded `rmtree` in cross-platform cleanup code fails there and passes on Unix — on Unix, deletion depends on the *directory's* write permission, not the file's, so the same tree removes cleanly and the bug hides until CI runs on another platform. ## Symlinks, in three distinct cases These get conflated constantly, and they are three different behaviours. 1. **The root is a symlink to a directory.** `rmtree` refuses and raises `OSError`. It will not delete the linked-to tree. Use `os.unlink` if what you wanted was to remove the link. 2. **A symlink is found inside the tree.** It is unlinked like any other entry; the target is not followed and its contents survive. So `rmtree` cannot be lured out of the subtree by a link someone left in it. 3. **A symlink is swapped in during the walk.** This is the hostile case: an attacker who can create entries in the tree replaces a directory with a link between the moment `rmtree` inspects it and the moment it recurses. On platforms that provide the descriptor-relative directory calls, `rmtree` uses an implementation that opens each directory and operates relative to that descriptor, which defeats the swap. The module publishes a boolean flag stating whether the running platform gets that guarantee, and where it is false, `rmtree` on a directory writable by anyone else is genuinely unsafe. ## Other things that actually break it - **Open handles.** On Windows a file another process (an editor, an indexer, a virus scanner) has open cannot be deleted. This is the main reason `tempfile.TemporaryDirectory` grew `ignore_cleanup_errors=True` in Python 3.10. - **Network filesystems.** Some NFS servers rename a still-open file to a hidden entry instead of deleting it, so the parent `os.rmdir` then fails with a not-empty error even though your code deleted everything it saw. - **The path itself.** `rmtree` on a plain file raises rather than unlinking it, and on a missing path raises `FileNotFoundError` — there is no `missing_ok` equivalent, so either catch it or pass `ignore_errors=True`. - **Unvalidated input.** This is the one that ends careers. A path assembled from a request parameter, a config value or an environment variable, then handed to `rmtree`, is arbitrary deletion. Resolve the path, confirm it is inside the root you own, and refuse otherwise — and prefer deleting a directory your own code created and remembered over one whose name arrived from outside. ## What a good answer sounds like Name the non-atomicity and the rename-then-remove pattern, distinguish `ignore_errors` from `onexc`, give the read-only retry as the concrete handler, and separate the three symlink cases. That combination shows someone who has actually cleaned up after a long-running job rather than someone who has only read the signature.

  • How do you make a directory disappear atomically when the removal itself takes a while?
    Rename it out of the way first. `os.rename` to a scratch name inside the same directory is atomic and instantaneous within one filesystem, so every observer sees the live path gone in one step; then call `shutil.rmtree` on the renamed path at leisure. If that slow removal fails you are left with orphaned scratch to sweep later rather than a live tree that is half-gutted.
  • When is ignore_errors=True the wrong choice?
    Whenever a caller acts on the assumption that the directory is gone — recreating it, reporting disk reclaimed, or moving to a step that must not see leftovers. `ignore_errors` converts a failure into silence, so a tree that could not be removed looks identical to one that was. Use `onexc` when you want to repair and retry, or let the exception propagate when the cleanup genuinely has to succeed.
  • Why does cleanup code that passes on Linux fail on Windows?
    Two reasons. Deleting a file on Unix depends on write permission on its parent directory, so a read-only file removes cleanly; on Windows the read-only attribute blocks deletion outright, which is why the retry-after-chmod handler exists. Second, Windows refuses to delete a file another process still has open, so an editor or an indexer touching the tree makes the removal fail intermittently.

Demolishing a building room by room rather than pressing one button: if the crew stops at the third floor, you do not have the old building back, you have a half-demolished one.

saying these in an interview costs you the question

  • Assumes a failed rmtree leaves the tree untouched
  • Thinks rmtree follows a symlink given as the root
  • Uses ignore_errors=True as the default error strategy
  • Cannot name any way to handle a failure mid-walk
  • Calls rmtree on a path built from external input
  • Believes rmtree is atomic like a single rename

context