shutil.move() moves a file or folder from one place to another, and unlike os.rename() it works across drives and filesystems. That is the main reason to prefer it. This guide covers how it behaves in each situation, the overwrite trap, and how to write a move that cannot destroy data.
The basics#
import shutil
# Move into an existing folder, keeping the name
shutil.move("report.pdf", "archive/")
# Move and rename in one step
shutil.move("report.pdf", "archive/report-2026.pdf")
# Move a whole folder
shutil.move("temp/", "backup/temp/")
The function returns the new path, which is worth keeping:
new_path = shutil.move("report.pdf", "archive/")
print(new_path) # archive/report.pdf
How the destination is interpreted#
This is where the surprises come from:
| Destination | What happens |
|---|---|
| Existing folder | Moved into it, name unchanged |
| Path that does not exist | Treated as the new file name |
| Existing file | Overwritten without warning |
| Folder that does not exist | Treated as a file name, no folder created |
That last row catches people out. shutil.move("a.txt", "newfolder/") when newfolder does not exist fails with an error rather than creating it. Create the folder first:
from pathlib import Path
Path("archive").mkdir(parents=True, exist_ok=True)
shutil.move("report.pdf", "archive/")
The overwrite trap#
shutil.move("draft.txt", "final.txt") # destroys final.txt silently
No exception, no warning, no way back. If there is any chance the destination exists, check first:
from pathlib import Path
import shutil
def safe_move(source, destination):
"""Move without ever overwriting: adds -1, -2 and so on."""
source = Path(source)
destination = Path(destination)
if destination.is_dir():
destination = destination / source.name
if destination.exists():
stem, suffix = destination.stem, destination.suffix
counter = 1
while destination.exists():
destination = destination.with_name(f"{stem}-{counter}{suffix}")
counter += 1
destination.parent.mkdir(parents=True, exist_ok=True)
return shutil.move(str(source), str(destination))
print(safe_move("report.pdf", "archive/"))
Using .stem and .suffix rather than splitting on a dot means archive.tar.gz becomes archive.tar-1.gz instead of something broken.
shutil.move versus os.rename#
import os
os.rename("a.txt", "b.txt") # same filesystem only
os.rename("C:/a.txt", "D:/a.txt") # OSError: Invalid cross-device link
os.rename asks the operating system to change one directory entry. That is instant, but it only works within a single filesystem. Crossing a drive boundary, or moving out of a Docker container’s mounted volume, fails.
shutil.move tries os.rename first, and falls back to copy-then-delete when that fails. So it always works, but a cross-drive move of a large file takes real time and needs enough free space for both copies at once.
Moving many files#
from pathlib import Path
import shutil
source = Path("downloads")
target = Path("downloads/pdfs")
target.mkdir(exist_ok=True)
for pdf in source.glob("*.pdf"):
shutil.move(str(pdf), str(target / pdf.name))
print("moved", pdf.name)
Two habits worth adopting for anything that moves files in bulk. First, do a dry run:
DRY_RUN = True
for pdf in source.glob("*.pdf"):
destination = target / pdf.name
print(("would move" if DRY_RUN else "moving"), pdf.name, "->", destination)
if not DRY_RUN:
shutil.move(str(pdf), str(destination))
Second, collect the list before you start moving. Modifying a folder while iterating over it can cause files to be skipped:
files = list(source.glob("*.pdf")) # materialise first
for pdf in files:
shutil.move(str(pdf), str(target / pdf.name))
Handling errors#
import shutil
try:
shutil.move("report.pdf", "archive/report.pdf")
except FileNotFoundError:
print("The source file does not exist.")
except PermissionError:
print("No permission, or the file is open in another program.")
except shutil.Error as error:
print("Move failed:", error)
except OSError as error:
print("Filesystem error:", error)
On Windows, PermissionError most often means the file is open in another application. Windows locks open files in a way that Linux and macOS do not.
Copying instead of moving#
shutil.copy("a.txt", "b.txt") # contents and permission bits
shutil.copy2("a.txt", "b.txt") # also timestamps and metadata
shutil.copyfile("a.txt", "b.txt") # contents only, destination must be a file
shutil.copytree("src/", "dest/", dirs_exist_ok=True)
shutil.rmtree("old/") # delete a folder and everything in it
Use copy2 by default — preserving the modification time is almost always what you want, and it is what shutil.move uses internally for the cross-drive case.
Questions people ask#
Does shutil.move work on folders?
Yes. Moving a folder moves everything inside it. Across filesystems it becomes a recursive copy followed by a recursive delete, which is much slower than it looks.
Can I move a file to a folder that does not exist?
Not directly — the trailing slash is not enough. Call Path(target).mkdir(parents=True, exist_ok=True) first.
Why is my cross-drive move so slow?
Because it is really a copy plus a delete. There is no way around that: the data physically has to be written to the other device.
Is there a way to move to the recycle bin instead of deleting?
Not in the standard library. The third-party send2trash package does it on Windows, macOS and Linux, and is worth using in anything that deletes files a person cares about.
Where to go next#
- Build a Python file organiser — this guide turned into a finished project.
- Getting the file name from a path — the pathlib attributes used above.
- Python file handling — reading and writing the files you move.