Stop overwriting your files.
Three functions. Zero dependencies. Race-free when you need it.
uniqpath gives you a unique filename or directory path in Python, so writing
output.txt twice never overwrites the first one. It can also reserve the path
atomically, which is what you need when parallel jobs write into the same folder.
We have all shipped this function:
# every codebase, somewhere
i = 1
while os.path.exists(f"{name}_{i}.txt"):
i += 1And we have all watched a parallel job quietly overwrite three hours of results, because two workers ran that loop at the same time.
pip install uniqpathfrom uniqpath import unique_path
unique_path("output.txt") # → output.txt nothing there yet
unique_path("output.txt") # → output_1.txt now there is
unique_path("output.txt") # → output_2.txt you get the ideaThat is the whole library. Then there is the part that actually matters.
unique_path() tells you a name that is free right now. Between that
answer and your open(), another process can grab it. In a single script,
who cares. In 16 parallel workers, that is your evening.
reserve_path() closes the gap: it creates the path in the same syscall
that tests it — O_CREAT | O_EXCL for a file, mkdir for a directory. The
loser of a race gets FileExistsError and quietly moves to the next candidate.
from uniqpath import reserve_path
run_dir = reserve_path("experiments/run", is_dir=True) # yours, guaranteed# 16 workers, 16 distinct directories, zero coordination
with ThreadPoolExecutor(max_workers=16) as pool:
dirs = pool.map(lambda _: reserve_path("out/run", is_dir=True), range(16))
assert len({str(d) for d in dirs}) == 16 # run, run_1, run_2, ... run_15And when you were going to write to the file anyway, skip the middleman:
from uniqpath import uniq_open
with uniq_open("results.csv") as f:
f.write("...")
print("wrote", f.name) # results_3.csvuniq_open() reserves the path, opens it, and closes it on the way out.
f.name is the path it actually used.
| tells you a free name | creates it | opens it | safe under concurrency | |
|---|---|---|---|---|
unique_path() |
✅ | ❌ | ❌ | ❌ |
reserve_path() |
✅ | ✅ | ❌ | ✅ |
uniq_open() |
✅ | ✅ | ✅ | ✅ |
pip install uniqpath # library + CLI
pipx install uniqpath # just the CLI, isolated
uv tool install uniqpath # same, with uv
uvx uniqpath output.txt # no install at allHomebrew, conda, Arch
brew install julienrabault/tap/uniqpath
conda install -c conda-forge uniqpath
yay -S python-uniqpathRecipes and their status live in packaging/.
Python 3.9+. Linux, macOS, Windows. No runtime dependencies, ever.
The suffix is a format string. Mix and match:
unique_path("run.log", suffix_format="_{date:%Y-%m-%d}_{num:03d}")
# → run_2026-09-22_001.log
unique_path("shard.parquet", suffix_format="_{pid}_{uuid:8}")
# → shard_48213_5ba950c1.parquet
unique_path("backup.tar.gz", suffix_format=".{timestamp}")
# → backup.tar.1790080200.gz| Placeholder | What you get | Example |
|---|---|---|
{num} |
attempt counter, from 1 | _7 |
{num:03d} |
…zero-padded, any format spec | _007 |
{date} |
current datetime, full strftime | {date:%Y-%m-%d} → _2026-09-22 |
{timestamp} |
UNIX seconds | _1790080200 |
{rand} |
random alphanumerics, 6 by default | {rand:4} → _a7Zq |
{uuid} |
UUID4 hex, 32 by default | {uuid:8} → _5ba950c1 |
{pid} |
current process id | _48213 |
Typo a placeholder and you get told immediately, with the list of valid ones —
not a KeyError five minutes into a job.
$ uniqpath output.txt
output.txt
$ touch output.txt && uniqpath output.txt
output_1.txt
$ uniqpath results --dir --reserve # creates it, then prints it
results_1Or skip the variable entirely — --exec reserves the path and drops it into
your command wherever you put {}:
uniqpath results/run --dir --exec -- python train.py --out {}uniqpath exits with your command's exit code, and leaves stdout alone so pipes keep working.
Which makes job scripts boring, in the good way:
#!/bin/bash
#SBATCH --array=0-63
uniqpath "results/$SLURM_JOB_NAME" --dir --exec -- python train.py --out {}64 array tasks, 64 directories, no $SLURM_ARRAY_TASK_ID arithmetic, no
collisions.
eval "$(uniqpath --completion bash)" # bash
uniqpath --completion zsh > "${fpath[1]}/_uniqpath" # zsh
uniqpath --completion fish > ~/.config/fish/completions/uniqpath.fishIt completes paths, options, and the suffix formats above — so you stop looking them up.
By default the kind is guessed: a path with an extension that is not already a directory is a file, and the suffix goes before the extension.
unique_path("archive.tar.gz") # → archive.tar_1.gz
unique_path("results") # → results_1Guessing has limits. Say so explicitly when it matters:
unique_path("release.v1.0", is_dir=True) # → release.v1.0_1
unique_path("release.v1.0", is_dir=False) # → release.v1_1.0Full API
unique_path(
path, # str | os.PathLike
suffix_format="_{num}",
if_exists_only=True, # return path untouched when it is already free
return_str=False, # return str instead of Path
max_num=50_000, # attempts before giving up
verbose=False, # log each attempt on the "uniqpath" logger
is_dir=None, # None = guess, True = directory, False = file
) -> Path | str
reserve_path(
..., # everything above, plus:
parents=True, # create missing parent directories
) -> Path | str
uniq_open( # context manager
path,
mode="w", # any writing mode: w, a, x, +b variants
*, # plus suffix_format, if_exists_only, max_num,
..., # parents, buffering, encoding, errors, newline
) -> IO # f.name is the path that was usedfrom uniqpath import UniqPathError, MaxAttemptsError, InvalidFormatErrorMaxAttemptsError— no free path withinmax_numattempts. Also aRuntimeError, so pre-0.2 handlers keep working.InvalidFormatError— unknown or malformed placeholder. Also aKeyError, for the same reason.
A suffix with no varying part ("_backup", "_{timestamp}") has exactly one
possible candidate. If it is taken, you get MaxAttemptsError straight away
instead of 50 000 pointless stat calls.
Gotchas worth knowing
unique_path()does onestatper attempt. A directory holding thousands ofname_Nsiblings costs thousands of syscalls — use{rand}or{uuid}there, they land on the first try.reserve_path()creates an empty file. Opening it afterwards in"w"is the normal flow, and it never truncates something that already existed.- Reservations are not garbage-collected. A path you reserve and never use stays on disk.
- Atomicity rests on
O_EXCLandmkdir. On NFS without working locking,O_EXCLguarantees are weaker — a filesystem limitation, not a uniqpath one.
How do I avoid overwriting a file in Python?
Ask for a free name before you write: unique_path("output.txt") returns
output_1.txt once output.txt exists. If another process might be writing to
the same folder, use reserve_path() or uniq_open() instead — they hold the
path for you.
How do I increment a filename if it already exists?
That is the default: _{num} counts from 1. Use suffix_format="_{num:03d}" if
you want _001, _002, and so on.
How do I get a unique filename for every run of a script?
unique_path("run.log", suffix_format="_{date:%Y-%m-%d}_{num:03d}") for something
readable, or _{uuid:8} when you only care that it never collides.
How do I create a new output directory per job, without collisions?
reserve_path("results/run", is_dir=True), or from the shell:
uniqpath results/run --dir --exec -- python train.py --out {}.
Issues and PRs welcome.
git clone https://github.com/JulienRabault/uniqpath
cd uniqpath
pip install -e ".[dev]"
pytest -q --cov=uniqpath # 190 tests
ruff check . && ruff format --check .
mypy # strictCI runs the suite on Linux, macOS and Windows across Python 3.9 → 3.13.
MIT — Julien Rabault.
If this saved you a results directory, a ⭐ is appreciated.