uniqpath
Stop overwriting your files.
Three functions. Zero dependencies. Race-free when you need it.
We have all shipped this function:
# every codebase, somewhere
i = 1
while os.path.exists(f"{name}_{i}.txt"):
i += 1
And 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 uniqpath
from 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 idea
That is the whole library. Then there is the part that actually matters.
The race nobody thinks about
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_15
And 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.csv
uniq_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() |
✅ | ✅ | ✅ | ✅ |
Install
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 all
Homebrew, conda, Arch
brew install julienrabault/tap/uniqpath
conda install -c conda-forge uniqpath
yay -S python-uniqpath
Recipes and their status live in packaging/.
Python 3.9+. Linux, macOS, Windows. No runtime dependencies, ever.
Name your files however you like
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.
From the shell
$ uniqpath output.txt
output.txt
$ touch output.txt && uniqpath output.txt
output_1.txt
$ uniqpath results --dir --reserve # creates it, then prints it
results_1
Or 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.
Completion
eval "$(uniqpath --completion bash)" # bash
uniqpath --completion zsh > "${fpath[1]}/_uniqpath" # zsh
uniqpath --completion fish > ~/.config/fish/completions/uniqpath.fish
It completes paths, options, and the suffix formats above — so you stop looking them up.
Files vs directories
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_1
Guessing 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.0
Full 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 used
Errors
from uniqpath import UniqPathError, MaxAttemptsError, InvalidFormatError
MaxAttemptsError— 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.
Contributing
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 # strict
CI runs the suite on Linux, macOS and Windows across Python 3.9 → 3.13.
License
MIT — Julien Rabault.
If this saved you a results directory, a ⭐ is appreciated.
Release files for uniqpath 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| uniqpath-0.2.0.tar.gz | 27.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| uniqpath-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 45.0 kB
Release files / uniqpath-0.2.0.tar.gz
| Download URL | uniqpath-0.2.0.tar.gz |
|---|---|
| Size | 27.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ade42a7391ef41b0b72e1c1dda3358cee38862d77255ad42fb1eb476c6565f57
|
|
BLAKE2b-256 checksum How to use checksums |
91b2fc826f9e9e0f00b72f830f5ebce12e69384673f7e2a4d1207b32cda88eef
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.
Transparency logRelease files / uniqpath-0.2.0-py3-none-any.whl
| Download URL | uniqpath-0.2.0-py3-none-any.whl |
|---|---|
| Size | 17.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
db24fa0d14d0c678d57c0380a93d31fa3cdcf48e16c1cdfbb7e84d67ec13944f
|
|
BLAKE2b-256 checksum How to use checksums |
94edf7fc3e010062b2aa9a68634184fa62b830ce1500cf91ecbcd9d95ba3caf4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.
Transparency log