Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Python with_suffix Removes Part of a Filename: Choose Replace or Append

Choose replacement or append for dotted filenames, then inspect a collision-aware destination plan before enabling file operations.

By ON Lab5 min readSources checked November 10, 2026 (KST)

A document generator receives a descriptive filename containing a period. It calls with_suffix('.pdf') and discovers that part of the person's name or a version label has disappeared. The method interprets the last suffix of the final path component; it does not recognize which periods your application intended as punctuation. Decide whether the output should replace the last suffix or preserve the complete input name and append another ending. Make that decision before running a batch.

Reproduce the difference without changing any files

This example uses pure paths, so it performs string-like path calculations without opening, creating, or renaming files. It also checks both POSIX and Windows path syntax. That is useful for a naming contract, but does not test the behavior of an actual Windows filesystem.

from pathlib import PurePosixPath, PureWindowsPath

for cls in (PurePosixPath, PureWindowsPath):
    p = cls('incoming') / 'Mr. Smith resume for review'
    replaced = p.with_suffix('.pdf')
    appended = p.with_name(p.name + '.pdf')
    assert replaced.name == 'Mr.pdf'
    assert appended.name == 'Mr. Smith resume for review.pdf'
    assert replaced.parent == appended.parent == p.parent
    print(cls.__name__, replaced.name, appended.name)

The surprising result is consistent with the method's replacement contract. A suffix is not a content inspection result or a registry of recognized document extensions. A space after the period does not make the remainder immune to suffix replacement. The Python issue linked below documents this reader problem; it is evidence of a real failure mode, not evidence of current search volume or a promise that the behavior will change.

Choose a naming contract at the input boundary

Use replacement when the input is a filename with a known format ending and your application deliberately wants to replace that ending. For example, a conversion pipeline may map an input text filename to an output PDF filename after a real converter has produced PDF bytes. Use append when the entire supplied name is an opaque label, or when the new ending describes an additional operation such as a signature beside the original file.

from pathlib import PurePosixPath

def append_pdf(path):
    return path.with_name(path.name + '.pdf')

cases = {
    'report.txt': ('report.pdf', 'report.txt.pdf'),
    'report': ('report.pdf', 'report.pdf'),
    '.env': ('.env.pdf', '.env.pdf'),
    'archive.tar.gz': ('archive.tar.pdf', 'archive.tar.gz.pdf'),
}
for name, (replace_expected, append_expected) in cases.items():
    p = PurePosixPath('incoming') / name
    assert p.with_suffix('.pdf').name == replace_expected
    assert append_pdf(p).name == append_expected

signed = PurePosixPath('archive.tar.gz')
assert signed.with_name(signed.name + '.sig').name == 'archive.tar.gz.sig'
print('replacement, append, hidden name, and signature cases passed')

For archive.tar.gz, replacement affects the last suffix only: .gz becomes .pdf, leaving .tar. Removing every dotted segment is a different policy and can erase meaningful version labels. Do not generalize a special archive rule to arbitrary user filenames. Similarly, appending .pdf twice is possible: if repeated runs should be idempotent, define what counts as already processed and test that rule separately. A case-insensitive comparison may suit one product but should not be assumed to describe every filesystem.

The append helper intentionally fixes the ending to .pdf. It is not a sanitizer for untrusted user input. If an application accepts a user-selected ending or path, validate it against that application's supported formats and allowed destination directory. A filename operation alone cannot establish that a path is authorized or safe to write.

Inspect the entire destination plan before writing

Single-file examples hide a batch problem. Two distinct dotted names may collapse to the same replacement output. Compute all proposed destinations first and group them, while preserving each source alongside its destination. This example rejects exact duplicate destinations and prints a plan; it deliberately performs no filesystem changes.

from collections import defaultdict
from pathlib import PurePosixPath

def destination_plan(sources, transform):
    plan = [(source, transform(source)) for source in sources]
    grouped = defaultdict(list)
    for source, destination in plan:
        grouped[destination].append(source)
    collisions = {dst: src for dst, src in grouped.items() if len(src) > 1}
    if collisions:
        raise ValueError(f'duplicate destinations: {collisions}')
    return plan

sources = [PurePosixPath('draft.v1'), PurePosixPath('draft.v2')]
try:
    destination_plan(sources, lambda p: p.with_suffix('.pdf'))
except ValueError as error:
    assert 'draft.pdf' in str(error)
else:
    raise AssertionError('replacement collision was not detected')

plan = destination_plan(sources, lambda p: p.with_name(p.name + '.pdf'))
assert [str(dst) for _, dst in plan] == ['draft.v1.pdf', 'draft.v2.pdf']
for source, destination in plan:
    print(source, '->', destination)

Exact path equality is only a first check. Case folding, Unicode normalization, aliases, symlinks, and filesystem-specific naming rules can make two different strings refer to the same destination. A production plan must also account for the actual output filesystem and directory. An already existing destination can conflict even when every newly planned destination is unique. When sources and destinations share a directory, a planned output can also overlap another input; decide whether to reject that situation rather than silently changing processing order.

Before enabling writes, inspect the complete source-to-destination manifest, confirm the output directory, reject unsafe overlaps, and define an explicit existing-file policy. Keep a copy of the manifest for diagnosing failures. Checking exists() beforehand can reveal a conflict but cannot guarantee that a subsequent write or rename will avoid overwriting: another process can change the filesystem between the check and the operation. Broken symlinks also require deliberate handling. Select an operation with the required no-clobber behavior for your environment; do not treat this read-only naming example as a portable atomic file mover.

Keep conversion, naming, and version compatibility separate

with_suffix and with_name return new path objects. They do not change the original file, rename it on disk, or convert its bytes. Renaming a text file to .pdf does not create a valid PDF. Generate the real output using the appropriate converter and only then apply the destination and conflict policy designed for that pipeline.

A name ending in a single period introduces a separate version question. Python 3.14 changed how a single dot is treated as a suffix. If such names are allowed, compare supported runtimes and choose whether your input contract preserves, rejects, or normalizes them. The trailing-dot migration guide covers that compatibility boundary. It does not change the replace-versus-append choice for ordinary dotted labels demonstrated here.

For a focused regression check, keep fixtures for a dotted personal name, a version label, a conventional extension, no extension, a hidden name, a compound archive name, and two inputs that map to one destination. Run the examples with each supported Python version. Add actual filesystem tests only when you implement file operations; a successful pure-path calculation does not verify permissions, overwrite behavior, conversion validity, or recovery after an interrupted batch.

Sources & further reading