shutil.copytree() Leaves a Partial Copy Behind When It Raises (tested)
shutil.copytree() copies a whole directory tree and raises shutil.Error if any single file fails. We measured what's left in the destination after that exception: every file copied before the failure is still there. The call is not all-or-nothing.
1. One unreadable file among several, copied with copytree()
import shutil, os
# src/keep.txt is normal; src/bad.txt has no read permission
os.chmod("src/bad.txt", 0o000)
try:
shutil.copytree("src", "dst")
except shutil.Error as e:
print("caught:", e)
caught: [('src/bad.txt', 'dst/bad.txt', "[Errno 13] Permission denied: 'src/bad.txt'")]
Measured: copytree() does raise shutil.Error, as documented, collecting every per-file failure into one list. That part isn't surprising.
2. What's actually sitting in dst after the exception
print("dst exists:", os.path.exists("dst"))
print("dst contents:", os.listdir("dst"))
dst exists: True
dst contents: ['keep.txt']
Measured: after catching the exception, dst exists and already contains keep.txt — the file that copied successfully before bad.txt failed. copytree() does not roll back or clean up anything it already wrote; the destination is left in a half-finished state with no indication of exactly which files made it and which didn't beyond re-reading the exception's error list.
3. The pitfall: a naive retry fails differently
import shutil
try:
shutil.copytree("src", "dst")
except shutil.Error:
pass
# ...fix the permission problem, then just try again:
shutil.copytree("src", "dst")
FileExistsError: [Errno 17] File exists: 'dst'
Measured: simply retrying after fixing the underlying problem doesn't work, because dst from the first, failed attempt is already there — copytree() refuses to write into an existing directory unless told to. The second failure (FileExistsError) looks unrelated to the first (shutil.Error from a permission problem), which makes the real cause — a half-finished copy from the first attempt — easy to miss while debugging.
4. Fix: clean up on failure, or copy into a temp dir then move
import shutil, tempfile, os
tmp_dst = tempfile.mkdtemp()
shutil.rmtree(tmp_dst) # copytree wants the target to not exist
try:
shutil.copytree("src", tmp_dst)
except shutil.Error:
shutil.rmtree(tmp_dst, ignore_errors=True)
raise
else:
shutil.move(tmp_dst, "dst") # only visible at the final path on full success
Measured: copying into a fresh temporary directory first and only shutil.move()-ing it to the final destination after copytree() succeeds means a failure never leaves a half-copied tree at the path other code expects to be either absent or complete. On failure, cleaning up tmp_dst with shutil.rmtree() restores the starting state, so a retry behaves exactly like the first attempt.
Frequently asked questions
Does shutil.copytree() roll back if one file fails to copy?
No -- we measured every file copied before the failure remaining in the destination directory after shutil.Error was raised and caught.
Why does retrying shutil.copytree() after a failure raise FileExistsError?
Because the destination directory from the first, partially-failed attempt is already on disk, and copytree() refuses to copy into an existing directory unless dirs_exist_ok=True -- we confirmed this is a different error than the original failure, which can mask the real cause.
Does dirs_exist_ok=True fix the partial-copy problem?
No -- it only lets copytree() write into an existing directory. We didn't measure it undoing or detecting a prior partial copy; files from a failed first attempt simply stay mixed in with the retry's output.
How do I make a directory copy effectively atomic?
We confirmed copying into a fresh temporary directory and using shutil.move() to put it at the final path only after copytree() fully succeeds avoids ever leaving a half-copied tree at the path other code relies on.
Is this specific to permission errors?
No -- any per-file failure that copytree() collects into its shutil.Error list (a permission error, a disappearing source file, a full disk) leaves the same kind of partial result; permission denial was just the easiest failure to reproduce for this test.
Written with AI assistance. Measured on 2026-10-04 with Python 3.13.5's built-in shutil module: built a source directory with one readable and one unreadable (chmod 0o000) file, ran shutil.copytree() inside a try/except shutil.Error, and checked the destination directory's contents after the exception was caught. PlainHeaders is our extension; Python's shutil module is built in.