Watch at EaseStories. Answers. Perspective.

Technology · PRACTICAL GUIDE

Syncthing conflict files everywhere? Audit every copy before you delete or merge

A read-only Python audit pairs each documented .sync-conflict copy with its likely base file, hashes both and labels it identical, different or base-missing. It does not delete, rename or merge anything.

Original editorial illustration of two computers with a paused folder between a base file and a conflict copy.
Original illustration: pause syncing and make an independent backup before deciding what to do with a conflict copy. Watch at Ease

Syncthing creates a conflict copy when it sees concurrent changes that it cannot safely reconcile. The filename records a date, time and device ID, but those details do not prove which content you should keep. Before deleting or renaming anything, make an independent backup, pause syncing on the affected folder and build a read-only inventory.

First, understand what the conflict name can and cannot tell you

Current Syncthing documentation describes names such as `report.sync-conflict-20261011-102030-DEVICE.docx`. The timestamp and device suffix help identify the copy, but Syncthing does not understand whether a spreadsheet, database, photo or note contains the best information. A newer timestamp is not a content-quality verdict. Conflict files can also propagate to other devices, so a quick delete on one computer is not a substitute for reviewing the set.

The safest opening move is separate from Syncthing: stop editing the affected data, pause the folder on every reachable device and make a backup outside the synchronized tree. The audit below reads file bytes only. It does not query or modify Syncthing's database.

Use three states instead of a delete button

The audit classifies each conflict copy as `identical`, `different` or `base-missing`. Identical means the SHA-256 digest of the conflict copy exactly matches the likely base file. Different means both files exist but their bytes differ. Base-missing means the expected unsuffixed file is not present as a regular file. These are evidence labels, not automatic actions.

Even an identical pair deserves a backup and a quick path check before cleanup, because a similarly named base in the wrong folder is not the intended document. A different pair needs an application-aware comparison. A base-missing conflict may be the only remaining copy, so it should never be treated as clutter.

Run the tested read-only audit

Save the following as `syncthing_conflict_audit.py`. It uses only the Python standard library, skips symbolic links, walks subfolders without following linked directories and writes the manifest outside the scanned tree when you choose an outside output path. ```python #!/usr/bin/env python3 """Inventory Syncthing conflict copies without changing any files. The tool pairs filenames of the form ``name.sync-conflict-YYYYMMDD-HHMMSS-DEVICE.ext`` with their likely base file, hashes both when possible, and emits a CSV or JSON manifest. """ from __future__ import annotations import argparse import csv import hashlib import json import os import re import sys from dataclasses import asdict, dataclass from pathlib import Path from typing import Iterable, TextIO CONFLICT_RE = re.compile( r"^(?P<stem>.+)\.sync-conflict-" r"(?P<date>\d{8})-(?P<time>\d{6})-" r"(?P<device>[^./\\]+)(?P<suffix>\.[^/\\]+)?$" ) @dataclass(frozen=True) class AuditRow: conflict_path: str base_path: str status: str conflict_sha256: str base_sha256: str conflict_bytes: int base_bytes: int | None conflict_timestamp: str modified_by: str def parse_conflict_name(name: str) -> tuple[str, str, str] | None: """Return (base filename, compact timestamp, device ID), if recognized.""" match = CONFLICT_RE.match(name) if not match: return None suffix = match.group("suffix") or "" base_name = f"{match.group('stem')}{suffix}" timestamp = f"{match.group('date')}T{match.group('time')}" return base_name, timestamp, match.group("device") def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str: digest = hashlib.sha256() with path.open("rb") as handle: while chunk := handle.read(chunk_size): digest.update(chunk) return digest.hexdigest() def find_conflicts(root: Path) -> Iterable[Path]: """Yield regular conflict files in deterministic order; do not follow links.""" found: list[Path] = [] for directory, dirnames, filenames in os.walk(root, followlinks=False): dirnames.sort() for filename in sorted(filenames): candidate = Path(directory, filename) if parse_conflict_name(filename) and candidate.is_file() and not candidate.is_symlink(): found.append(candidate) yield from sorted(found) def audit(root: Path) -> list[AuditRow]: root = root.resolve() rows: list[AuditRow] = [] for conflict in find_conflicts(root): parsed = parse_conflict_name(conflict.name) assert parsed is not None base_name, timestamp, device = parsed base = conflict.with_name(base_name) conflict_hash = sha256_file(conflict) conflict_size = conflict.stat().st_size if base.is_file() and not base.is_symlink(): base_hash = sha256_file(base) base_size: int | None = base.stat().st_size status = "identical" if conflict_hash == base_hash else "different" else: base_hash = "" base_size = None status = "base-missing" rows.append( AuditRow( conflict_path=str(conflict.relative_to(root)), base_path=str(base.relative_to(root)), status=status, conflict_sha256=conflict_hash, base_sha256=base_hash, conflict_bytes=conflict_size, base_bytes=base_size, conflict_timestamp=timestamp, modified_by=device, ) ) return rows def write_json(rows: list[AuditRow], output: TextIO) -> None: json.dump([asdict(row) for row in rows], output, ensure_ascii=False, indent=2) output.write("\n") def write_csv(rows: list[AuditRow], output: TextIO) -> None: fieldnames = list(AuditRow.__dataclass_fields__) writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() writer.writerows(asdict(row) for row in rows) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Create a read-only manifest of Syncthing .sync-conflict files." ) parser.add_argument("root", type=Path, help="Folder to scan recursively") parser.add_argument("--format", choices=("csv", "json"), default="csv") parser.add_argument( "--output", type=Path, help="Manifest path; omit to write to standard output", ) return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) if not args.root.is_dir(): print(f"error: not a directory: {args.root}", file=sys.stderr) return 2 rows = audit(args.root) output: TextIO if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) output = args.output.open("w", encoding="utf-8", newline="") else: output = sys.stdout try: if args.format == "json": write_json(rows, output) else: write_csv(rows, output) finally: if args.output: output.close() print( f"Scanned {args.root}: {len(rows)} conflict file(s); no files changed.", file=sys.stderr, ) return 0 if __name__ == "__main__": raise SystemExit(main()) ```

From PowerShell or a terminal, create a CSV with `python syncthing_conflict_audit.py "/path/to/synced-folder" --output "/safe-place/syncthing-conflicts.csv"`. For JSON, add `--format json` and use a `.json` output path. Do not put the manifest inside the folder being diagnosed, because that would create another synchronized change.

Read the manifest without over-interpreting it

Sort by `status`, then review `base-missing` rows first. Copy those conflict files to your independent backup before opening them. For `different` rows, compare the base and conflict paths in the application that created them. Text can use a diff tool; office files, photo catalogs, note databases and archives need format-aware inspection or export. Preserve both until you have verified the merged or chosen result.

For `identical` rows, matching SHA-256 values mean the two regular files contain the same bytes at audit time. That narrows the decision but does not authorize the script to delete anything. Confirm the relative paths, close applications that may rewrite the files and resolve one small batch at a time while syncing remains paused.

The device suffix and timestamp are clues, not winners

The documented conflict name identifies when Syncthing created the copy and which device's change was moved into that name. Clock drift, application save behavior and later manual copies can make timestamps misleading. Keep the content comparison separate from the metadata story. If a file format has an internal revision history, inspect that history too.

If conflict copies keep returning after cleanup, investigate simultaneous edits, an application that rewrites files while open, device clocks and unsupported concurrent access to database-like files. Fixing the trigger matters more than repeatedly deleting the symptom.

Use versioning for future remote changes, with one important limit

Syncthing versioning is configured per folder and per device. Official documentation says it archives changes received from other devices, but it does not archive local changes made on the same device. That means enabling versioning can add a recovery layer, yet it is not a complete backup and does not replace application-specific history or an independent backup.

After you have resolved the files and corrected the trigger, resume one device first, confirm the expected set, then bring other devices back gradually. Keep the audit manifest with your backup as a record of the exact hashes and paths you reviewed.

Original editorial illustration of two files being hashed into a three-row audit manifest while a trash bin remains closed.
Original illustration: the audit records identical, different and base-missing states but never deletes a file. Watch at Ease