Python 3.14 adds high-level copy and move operations directly to pathlib.Path. Path.copy(), Path.copy_into(), Path.move(), and Path.move_into() make many filesystem workflows easier to express without switching between pathlib, shutil, and os for basic operations.

The convenience is useful, but filesystem mutations still need explicit policy. Overwrites, symbolic links, metadata, cross-filesystem moves, partial failure, and concurrent changes can all affect correctness.

The four new operations

Use copy() when the destination path itself is known:

from pathlib import Path

source = Path("reports/current.csv")
destination = source.copy("archive/current.csv")

print(destination)

The returned value is a new Path pointing at the destination.

Use copy_into() when you have an existing destination directory:

archive = Path("archive")
destination = Path("reports/current.csv").copy_into(archive)

This copies the source under archive using its existing name.

The move APIs have the same destination distinction:

source = Path("incoming/result.json")

moved = source.move("processed/result.json")

or:

moved = Path("incoming/result.json").move_into(Path("processed"))

All four methods were added in Python 3.14. Code that must run on Python 3.13 or earlier needs a compatibility path, usually using shutil and the older Path.rename() or Path.replace() operations where appropriate.

copy() handles files and directory trees

Path.copy() is not limited to individual files. It can copy a directory tree:

from pathlib import Path

release = Path("dist/release")
backup = release.copy("backups/release")

This is a useful semantic difference from APIs whose names imply separate file and tree operations.

If the source is a file and the target is an existing file, the target is replaced. That behavior means an application should not treat copy() as a create-only primitive.

If overwriting is forbidden by your application, enforce that policy explicitly rather than assuming the operation will reject an existing destination:

from pathlib import Path


def copy_without_overwrite(source: Path, target: Path) -> Path:
    if target.exists():
        raise FileExistsError(target)
    return source.copy(target)

This pre-check improves application semantics but does not make the operation race-free. Another process can create the destination after exists() returns. When exclusive creation is a correctness or security requirement, use an operating-system primitive that provides the required atomic guarantee rather than relying on a check-then-act sequence.

By default, copy() follows a source symbolic link and copies what the link points to.

To copy the symbolic link itself instead, set follow_symlinks=False:

source = Path("current-config")
source.copy("backup/current-config", follow_symlinks=False)

The distinction matters for deployment trees, package layouts, and backups. A link to a large directory is very different from a small link object, and following a link can move a copy operation outside the tree an application expected to process.

Treat symlink behavior as part of the input policy, especially when paths or directory contents are not fully trusted.

Metadata preservation is explicit

By default, copy() guarantees directory structure and file data, not complete metadata preservation.

Set preserve_metadata=True when metadata is part of the artifact:

snapshot = Path("release").copy(
    "snapshot/release",
    preserve_metadata=True,
)

Where supported, this preserves information such as permissions, flags, access and modification times, and extended attributes. Filesystem and operating-system capabilities still matter, so portable programs should not assume every metadata feature exists everywhere.

On Windows, the preserve_metadata argument has no effect for file copies because metadata is always preserved by this operation.

Before enabling metadata preservation mechanically, decide whether destination permissions and attributes should actually inherit from the source. A staging or publishing workflow may intentionally want destination-specific permissions instead.

A copy may be copy-on-write

On operating systems and filesystems that support it, Path.copy() may use a lightweight copy in which data blocks are copied only when modified.

That can make an apparently large copy fast and space-efficient initially. It should not change application semantics: source and destination remain separate filesystem entries from the program’s perspective.

Do not build correctness logic around an assumption that a copy necessarily performs an immediate byte-for-byte physical duplication on storage.

move() has two execution paths

Path.move() behaves differently depending on whether source and destination are on the same filesystem.

On the same filesystem, the operation uses os.replace(). This is the fast rename-like path.

Across filesystems, a rename cannot generally perform the move. Path.move() instead copies the source while preserving metadata and symlinks, then deletes the source.

That distinction has an important operational consequence: a cross-filesystem move is not one indivisible rename.

For example:

from pathlib import Path

source = Path("/mnt/upload/video.bin")
target = Path("/srv/archive/video.bin")

moved = source.move(target)

If those paths reside on different filesystems, the operation can involve substantial I/O before source removal. Failures, disk exhaustion, process termination, and external filesystem changes therefore deserve the same planning as a copy-and-delete workflow.

Do not use a cross-filesystem move() as an atomic publication primitive.

Existing destinations need a policy

If source and target are existing files, move() overwrites the target. If source and target refer to the same file or directory, or the target is a non-empty directory, OSError is raised.

These semantics are convenient for replacement workflows but can be dangerous when destination names come from external input.

Validate destination ownership before mutation:

from pathlib import Path


def archive_result(source: Path, archive_root: Path, job_id: str) -> Path:
    if not job_id.isascii() or not job_id.isalnum():
        raise ValueError("invalid job id")

    target = archive_root / f"{job_id}.json"
    return source.move(target)

This example constrains naming, but a real security boundary may also need containment checks, symlink defenses, permissions, and operating-system-level isolation. A Path object is a convenient path representation, not a sandbox.

copy_into() and move_into() require a directory

The _into variants communicate intent well when the destination is a directory:

from pathlib import Path

queue = Path("queue")
processed = Path("processed")

for item in queue.iterdir():
    if item.is_file():
        item.move_into(processed)

The target directory should already exist. Keeping directory creation separate often makes lifecycle and permission policy clearer:

processed.mkdir(parents=True, exist_ok=True)
item.move_into(processed)

Be careful when iterating a directory while moving its entries. Files can appear, disappear, or change between discovery and mutation. Filesystem iteration is a snapshot-like observation only in the loose application sense; it does not lock the directory against concurrent writers.

Do not confuse move() with rename()

Path.rename() remains useful. It maps to os.rename() and therefore exposes that operation’s platform-dependent replacement behavior.

Path.replace() provides replacement semantics based on os.replace().

The new Path.move() is broader: it can move directory trees and handles cross-filesystem moves by falling back to copy followed by deletion.

Choose based on the guarantee you need rather than on whichever name sounds most natural:

  • use rename() when os.rename() semantics are exactly what you want;
  • use replace() for rename-style replacement semantics;
  • use move() when a higher-level move that can cross filesystems is appropriate.

For transactional publication, investigate the atomicity guarantees of the exact filesystem operation and deployment environment. A general-purpose move abstraction cannot make a copy-and-delete sequence atomic across filesystems.

Keep validation separate from mutation

A robust filesystem workflow usually has three phases: derive the destination, validate policy, then mutate.

from pathlib import Path


def publish(source: Path, root: Path, name: str) -> Path:
    if Path(name).name != name:
        raise ValueError("name must be one path component")

    target = root / name

    if target.exists():
        raise FileExistsError(target)

    return source.move(target)

This structure makes overwrite policy visible and testable.

It still has a check-then-act race, so it is suitable only when that race is acceptable or when a higher-level lock or single-writer design prevents competing mutations. Explicit code does not automatically imply atomic code.

Plan for partial failure

Filesystem operations fail for ordinary reasons: permissions change, storage fills, a mount disappears, a file is removed concurrently, or a process loses access.

Avoid surrounding a multi-step workflow with a broad exception handler that assumes nothing happened:

try:
    source.move(target)
except OSError:
    # Incorrect assumption: source and target must be untouched.
    ...

After an uncertain failure, inspect or reconcile the state according to the application’s recovery model. This is especially important for cross-filesystem moves because their implementation necessarily spans copying and deletion.

For important artifacts, design idempotent recovery around explicit states such as incoming, staged, and published rather than inferring success solely from whether one call raised.

Test semantics, not only the happy path

Temporary directories make filesystem behavior straightforward to test:

from pathlib import Path


def test_copy_file(tmp_path: Path) -> None:
    source = tmp_path / "source.txt"
    source.write_text("hello", encoding="utf-8")

    target = source.copy(tmp_path / "target.txt")

    assert target.read_text(encoding="utf-8") == "hello"
    assert source.exists()


def test_move_file(tmp_path: Path) -> None:
    source = tmp_path / "source.txt"
    source.write_text("hello", encoding="utf-8")

    target = source.move(tmp_path / "target.txt")

    assert target.read_text(encoding="utf-8") == "hello"
    assert not source.exists()

Also test the policy boundaries your application depends on: existing targets, directory trees, symbolic links, missing source paths, invalid names, and permission failures where the platform permits reliable testing.

A same-filesystem temporary-directory test does not exercise the cross-filesystem fallback. If your production design depends on that path, cover it in an integration environment with separate filesystems or mounts.

A practical checklist

Before adopting the Python 3.14 Path copy and move methods, ask:

  1. Is Python 3.14 the minimum supported runtime?
  2. May an existing destination be overwritten?
  3. Should symbolic links be followed or preserved as links?
  4. Is source metadata part of the artifact?
  5. Can the move cross a filesystem boundary?
  6. Does the workflow require atomic publication?
  7. Can another process mutate source or destination concurrently?
  8. What state should recovery expect after an I/O failure?
  9. Are untrusted path components constrained before mutation?
  10. Do tests cover the filesystem semantics the application actually relies on?

The new pathlib methods reduce ceremony, not filesystem complexity. They are most useful when the application makes overwrite, link, metadata, concurrency, and failure policies explicit around the concise API.