LESSON 95 · PRACTICAL PYTHON30 min

Copy and Move Files Safely with shutil

Copy a file without deleting its source, then move a file with explicit collision handling.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Copy and Move Files Safely with shutil

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    It is easy to write a file-copy function with `read_text` and `write_text`. That works for a known small text file, but it is not a general file copy. A PNG, an archive, or any binary file may contain bytes that do not decode as UTF-8. Python's `shutil` module has explicit copy and move operations for filesystem paths. Today we will reproduce the text-copy failure, then use `copy2` for a small binary file and inspect both content and metadata. After that we will build a guarded move that refuses to overwrite an existing destination. All examples run in a disposable temporary directory.

  2. 00:42

    We will not touch the project files on your computer. The word safely has a limited meaning here: we check existence and verify the result for this local exercise. A production tool may need stronger guarantees for concurrent writers, permissions, symbolic links, and crashes. We will state those limits while learning the core operations. The byte `ff` is not a valid standalone UTF-8 sequence, so `read_text` raises before the destination can be written. The failed method assumed every source was a UTF-8 document. A general copy operation should transfer bytes without interpreting them as characters.

  3. 01:22

    Even when the source is text, decoding and re-encoding may change newline forms or error out on an unexpected encoding. That may be fine for a deliberate text transformation, but it is not a neutral copy. The source in this demo is only three bytes, allowing us to inspect it directly. Large files should also not be loaded wholesale with `read_bytes` just to copy them; the library can stream data appropriately. The lesson is to match the operation to the task: use text functions when changing text, and a filesystem copy function when preserving a file. Before choosing a function, also decide whether preserving metadata matters,

  4. 02:02

    because `copyfile`, `copy`, and `copy2` provide different levels of metadata handling. `copy2` copies the file data without decoding it and attempts to preserve metadata such as modification time. We compare the bytes and the integer modification times in this local example. The returned path points to the destination. Do not confuse metadata preservation with a universal exact clone of a file. The available attributes and permissions vary by operating system and filesystem, and `copy2` may not preserve every property. If your requirement is only to copy file data, `copyfile` may be enough.

  5. 02:45

    If you want the ordinary mode bits as well, `copy` provides that level. `copy2` attempts the most metadata preservation among these common choices. In a real backup system, verify the properties your users care about rather than assuming one call satisfies every platform. Also check where the target path points: passing a directory to `copy2` means the file is copied inside that directory with its original name. Here we pass a specific destination filename so the result is unambiguous. The helper checks that the source is a regular file and that the target path does not already exist. `shutil.move` performs the move and returns the resulting path.

  6. 03:29

    The output shows the new name and content, then confirms the old path is gone and the new one exists. Why add guards if `shutil.move` can already move files? Because the application has a policy: do not accidentally replace or merge into an existing target. The explicit checks communicate that policy and give clear errors. There is still a race between checking and moving if another process creates the target at the same moment. For high-stakes concurrent writes, use stronger platform-specific primitives and design for collision handling. This tutorial's purpose is to avoid common single-process mistakes and verify the intended result.

  7. 04:11

    Also remember that moving across filesystems may become a copy followed by deletion; do not assume every move is an atomic rename. Keep the source and destination semantics clear before invoking the operation. The first attempt encounters an existing destination and raises `FileExistsError`. We then read both files to prove the rejected move did not alter either one in this single-process test. The second attempt has no source, so it raises `FileNotFoundError` before trying to create a new target. These are more useful checks than merely confirming that the happy path once printed a filename.

  8. 04:51

    On the lesson page, change the destination from a file path to an existing directory and predict how `shutil.move` would interpret it without our guard. Then test a copy using a binary file and compare its bytes. Decide which operation your program actually needs: copying leaves the source in place; moving changes its location. A file-management tool should state that decision clearly, check the relevant edge cases, and verify the postcondition. For concurrent or cross-filesystem workflows, plan for the remaining race and failure modes rather than treating this helper as a complete transactional file manager.

Understand the concept

The shutil module provides high-level file operations. Copying creates another path while leaving the original; moving changes where the source is found. Those are different outcomes, so a function name and its tests should state which one is intended. shutil.copy2 copies bytes and attempts to preserve metadata such as modification time; shutil.move renames within a filesystem when possible and may copy then remove across filesystems. Neither function decides your overwrite policy for you.

The browser's Python console has a temporary virtual filesystem. This lesson creates a TemporaryDirectory, writes a tiny local fixture, and inspects files inside that isolated location. No visitor file is read or modified. The virtual files disappear after the exercise. On your own computer, never point a teaching command at valuable data; use a dedicated test directory.

The checked function transfer_file accepts source and destination paths plus a mode of copy or move. Convert the inputs to pathlib.Path, require a regular source file, and reject an existing destination before calling shutil. Creating destination parents is deliberately outside this contract: a misspelled directory should fail rather than create an unexpected tree. Return the final destination path for the caller to inspect.

Tests check the destination bytes, whether the source remains after copy, whether it disappears after move, and error cases for an invalid mode, absent source, and existing destination. A precheck alone is not enough for concurrent security-sensitive workflows because another process can change a path before the operation. This lesson teaches a small local transfer contract; robust adversarial file handling needs stronger platform-specific primitives.

  • Copy preserves the source
  • Move removes the source path
  • Overwrite policy must be explicit
  • Temporary fixtures protect real files

See it step by step

Read the code, predict the output, then compare it with the result.

01. Copy leaves the original available

PYTHON
from pathlib import Path
from tempfile import TemporaryDirectory
import shutil
with TemporaryDirectory() as folder:
    source = Path(folder) / 'a.txt'
    target = Path(folder) / 'b.txt'
    source.write_text('hello', encoding='utf-8')
    shutil.copy2(source, target)
    print(source.exists(), target.read_text(encoding='utf-8'))
EXPECTED OUTPUT
True hello

copy2 creates a second file and keeps the source.

02. Move removes the source path

PYTHON
from pathlib import Path
from tempfile import TemporaryDirectory
import shutil
with TemporaryDirectory() as folder:
    source = Path(folder) / 'a.txt'
    target = Path(folder) / 'b.txt'
    source.write_text('hello', encoding='utf-8')
    shutil.move(source, target)
    print(source.exists(), target.read_text(encoding='utf-8'))
EXPECTED OUTPUT
False hello

The old path is gone and the destination holds the bytes.

03. Check a destination before transfer

PYTHON
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as folder:
    target = Path(folder) / 'b.txt'
    target.write_text('existing', encoding='utf-8')
    print(target.exists())
    print(target.read_text(encoding='utf-8'))
EXPECTED OUTPUT
True
existing

An explicit collision check avoids accidentally replacing this fixture.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Check what a copy leaves behind

The first fixture lives inside TemporaryDirectory so the exercise never touches a visitor's personal files. After writing a small source file, shutil.copy2 creates a destination. The output prints True for source.exists() and the copied text at the destination: two paths now carry the content.

That result is the contract for a copy operation. copy2 also tries to preserve file metadata, but filesystem support varies, so the practical task checks bytes and path presence rather than promising identical metadata everywhere. In a browser console the filesystem is temporary; on a local machine use a dedicated practice folder and inspect both paths before cleaning up.

PYTHON
from pathlib import Path
from tempfile import TemporaryDirectory
import shutil
with TemporaryDirectory() as folder:
    source = Path(folder) / 'a.txt'
    target = Path(folder) / 'b.txt'
    source.write_text('hello', encoding='utf-8')
    shutil.copy2(source, target)
    print(source.exists(), target.read_text(encoding='utf-8'))
EXPECTED OUTPUT
True hello
Follow the reasoning
  1. Create an isolated source fixture.
  2. Copy to a second path and read it back.
  3. Assert that the original still exists.
02 / 03

See how moving changes the source path

The next example uses the same tiny fixture but calls shutil.move. The destination contains the original text, while source.exists() prints False. That distinction matters to callers: replacing copy with move can silently remove an input that the workflow still needs. Name the operation explicitly in an API and write a test for source persistence.

A move may be a rename on one filesystem or a copy followed by deletion across filesystems. This code only establishes the observable outcome for a successful operation in the fixture. Real applications need to consider failures and destination collisions. Do not assume a move is universally atomic merely because one local example finishes quickly.

PYTHON
from pathlib import Path
from tempfile import TemporaryDirectory
import shutil
with TemporaryDirectory() as folder:
    source = Path(folder) / 'a.txt'
    target = Path(folder) / 'b.txt'
    source.write_text('hello', encoding='utf-8')
    shutil.move(source, target)
    print(source.exists(), target.read_text(encoding='utf-8'))
EXPECTED OUTPUT
False hello
Follow the reasoning
  1. Move a file inside the temporary directory.
  2. Read destination bytes after the call.
  3. Check that the old source path is absent.
03 / 03

Make the collision rule visible

The last run creates a destination that already contains existing text. It prints the path's existence and original contents before any transfer. This is the state a safe transfer wrapper must inspect and reject when its contract promises not to overwrite. Returning early with a documented error is clearer than silently replacing data.

The checked exercise also validates an existing regular source file and an allowed mode. A destination precheck is a teaching guard, not a complete defense against another process changing a path between the check and transfer. For concurrent file handling, use stronger platform-specific operations. Here the key is to state the intended result and verify it with isolated fixtures.

PYTHON
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as folder:
    target = Path(folder) / 'b.txt'
    target.write_text('existing', encoding='utf-8')
    print(target.exists())
    print(target.read_text(encoding='utf-8'))
EXPECTED OUTPUT
True
existing
Follow the reasoning
  1. Create a destination with known content.
  2. Reject the collision before calling shutil.
  3. Test copy, move, missing source, and invalid mode separately.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnComplete transfer_file(source, destination, mode='copy'). Require mode copy or move, an existing regular source file, and a destination that does not exist. Use shutil.copy2 or shutil.move accordingly and return the destination Path. The prepared call must print True draft.
Need a hint?

Convert both paths to Path. Reject bad mode and paths before branching; call shutil.copy2 for copy and shutil.move for move.

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.After shutil.copy2, what happens to the source?

    02.What is the exercise policy for an existing destination?

    03.Where do the browser examples write fixtures?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Using move for a requested copy

    COMMON MISTAKE
    shutil.move(source, destination)
    FIX
    shutil.copy2(source, destination)

    What happens: The source path disappears after the operation.
    Match the operation to the intended source outcome.

    Ignoring a destination collision

    COMMON MISTAKE
    shutil.copy2(source, destination)
    FIX
    if Path(destination).exists():
        raise FileExistsError(destination)
    shutil.copy2(source, destination)

    What happens: An existing target can be overwritten.
    State and enforce a collision policy before the transfer.

    Browse this lesson’s problem fixes

    Sources and further reading