safewrite
Atomic file writes with no dependencies: the content is replaced in full, or not replaced
at all. Neither a crashed process, nor Ctrl-C, nor a full disk will leave you with a
truncated config file.
Python 3.8+ · no dependencies · Linux, macOS, Windows
$ pip install safewrite
Why
open(path, "w") truncates the file the moment it is opened — the old data is already
gone while the new data has not been written yet:
with open("config.json", "w") as f:
json.dump(config, f) # crash here -> an empty file on disk
safewrite writes to a temporary file in the same directory and swaps it into place with
a single os.replace(). Readers see either the old content or the new one, never anything
in between.
from safewrite import atomic_write
with atomic_write("config.json", encoding="utf-8") as f:
json.dump(config, f) # crash here -> the old file is intact, the temp file is gone
Shorthands for data you already have:
from safewrite import write_bytes, write_text
write_text("state.json", json.dumps(state), encoding="utf-8")
write_bytes("model.bin", payload)
write_text("service.token", token, perms=0o600) # never world-readable, not even briefly
Isn't this 20 lines of my own code?
The first 20 lines are easy: mkstemp, write, os.replace. The next ones are the reason
this package exists — and each of them is a real incident someone has already had:
- an existing file silently loses its mode, because
mkstempcreates files as0600; - a new file gets
0600instead of what yourumasksays; - a secret exists on disk world-readable for a moment, because
chmodruns after the write; - the data survives the crash but not the power loss, because the directory was never
fsynced; - a symlinked config (
/etc/app.conf -> /mnt/conf/app.conf) turns into a regular file; Ctrl-Cin the middle leaves a.app.conf.x7f2.tmpnext to the real file, forever;overwrite=Falseimplemented as "check, then write" races with another process.
All seven are handled here and covered by tests.
What is preserved, and what is not
| Preserved | |
|---|---|
| File content | replaced atomically, all or nothing |
| Permissions of an existing file | yes — 0640 stays 0640 |
| Permissions of a new file | 0o666 & ~umask, like a plain open(); umask is re-read on every write |
Explicit perms=0o600 |
applied before the first byte is written (POSIX; on Windows chmod only toggles the read-only flag) |
| setuid / setgid / sticky | yes — restored after the swap, since the kernel clears them on write |
| Symlinks | followed by default — the link stays a link, its target is rewritten |
| Owner (uid/gid) | no — a new file belongs to whoever wrote it; running as root makes the file root's |
| The inode | no — readers holding the file open keep seeing the old content forever |
| POSIX ACLs | no — reset to the basic mode bits |
| Extended attributes (xattr) | no — dropped |
| SELinux context | no — inherited from the directory; run restorecon if the file had a custom label |
| Hard links | no — the link is broken, the other name keeps the old content |
Every "no" row follows from the same fact: the result is a new inode, not the old file modified in place. If you need one of them, write in place and accept the risk, or restore the attribute yourself after the swap.
Durability
fsync on the file before the swap and on the directory after it, so a completed write
survives a power loss. This is not free. Writing 300 small JSON files on a consumer NVMe (ext4); spinning disks and NFS are slower still:
| per file | 300 files | |
|---|---|---|
durable=True (default) |
12.2 ms | 3.66 s |
durable=False |
0.05 ms | 0.01 s |
plain open() |
0.02 ms | 0.01 s |
Two orders of magnitude — the cost is the flush, not the library.
When writing hundreds of files in a batch, durable=False plus a single os.sync() at
the end trades a narrow window of risk for two orders of magnitude of speed. (os.sync()
is Unix-only, and until it runs the batch is exposed to the usual delayed-allocation
surprise — files that exist but are empty after a power cut.)
CLI
Installing the package adds a safewrite command — a sponge workalike from moreutils:
stdin is read to the end, and only then the file is swapped.
$ grep -v DEBUG app.log | safewrite app.log # a plain `> app.log` would truncate it
$ curl -s https://example.com/data | safewrite data.json --no-clobber
$ vault read -field=token secret/app | safewrite app.token --perms 600
Do not filter a log a running service still holds open. The swap gives the path a new
inode, and the writer keeps its file descriptor on the old one — everything it logs after
that goes nowhere until it reopens the file. This is not specific to safewrite, it is
what any replace-based rewrite does, sponge included. Rotate the log properly
(logrotate with copytruncate) or reload the service afterwards.
--append reads the whole file back and rewrites it, which is neither cheap on large
files nor atomic against other appenders — 40 concurrent --append runs lose lines,
40 concurrent >> do not. Use the shell's >> for that; --append is for the case where
nothing else is writing.
If the command on the left of the pipe fails, stdin is empty — and a naive sponge would
wipe your log. safewrite refuses to truncate a non-empty file with empty input:
$ grep -v DEBUG missing.log | safewrite app.log
safewrite: refusing to truncate 'app.log' with empty input (the command upstream may
have failed; pass --allow-empty to force)
$ echo -n "" | safewrite app.log --allow-empty # deliberate truncation
Exit codes: 0 written, 2 write failed, 3 refused to truncate. --no-fsync is the
CLI spelling of durable=False.
Offline and air-gapped installs
Pure Python, zero dependencies, one universal py3-none-any wheel — nothing is compiled
at install time and no build backend is needed:
$ pip download safewrite -d ./wheels # on a connected host
$ pip install --no-index --find-links ./wheels safewrite # inside the closed network
Concurrency
Two overlapping writers leave you with the full content of one of them, never a mix — no
locking required for that guarantee. Locking is required for read-modify-write cycles,
where a lost update is still possible: use flock(1) around the CLI, or fcntl.flock on
a separate lock file in Python.
Other limitations
- Atomicity comes from
os.replace(), which works within a single filesystem. The temporary file is always created next to the resolved target, so this holds even when the path is a symlink pointing to another mount. - On Windows the directory
fsyncis unavailable and is skipped;os.replace()fails if the file is open in another process. - The directory must be writable — the temporary file is created there, not in
/tmp.
API
atomic_write(path, mode="w", *, encoding=None, errors=None, newline=None,
perms=None, overwrite=True, durable=True, follow_symlinks=True)
write_text(path, data, *, encoding="utf-8", errors=None, newline=None,
perms=None, overwrite=True, durable=True, follow_symlinks=True)
write_bytes(path, data, *, perms=None, overwrite=True, durable=True, follow_symlinks=True)
Fully type annotated, ships py.typed, checked with mypy --strict in CI.
License
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file safewrite-0.1.3.tar.gz.
File metadata
- Download URL: safewrite-0.1.3.tar.gz
- Upload date:
- Size: 12.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a82dd3c2846214fbee585c0521040ddb6302c083dd29e4e10574d9efed85d86f
|
|
| MD5 |
fc11b8b6b57b1d13cb2241a24e375fc6
|
|
| BLAKE2b-256 |
c18764c90d288788ba93f08ee00d403af8983a09c667c22a05cacaa1b9d19d80
|
Provenance
The following attestation bundles were made for safewrite-0.1.3.tar.gz:
Publisher:
release.yml on max-ibragimow/safewrite
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
safewrite-0.1.3.tar.gz -
Subject digest:
a82dd3c2846214fbee585c0521040ddb6302c083dd29e4e10574d9efed85d86f - Sigstore transparency entry: 2496733930
- Sigstore integration time:
-
Permalink:
max-ibragimow/safewrite@f3a7bd81c9d391d75dc2b078e549ca69b46348b2 -
Branch / Tag:
refs/tags/0.1.3 - Owner: https://github.com/max-ibragimow
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f3a7bd81c9d391d75dc2b078e549ca69b46348b2 -
Trigger Event:
push
-
Statement type:
File details
Details for the file safewrite-0.1.3-py3-none-any.whl.
File metadata
- Download URL: safewrite-0.1.3-py3-none-any.whl
- Upload date:
- Size: 10.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
058411a4ce59fbc76c408a19d215d73053f4acf1590bdaa9dd051d3b7e2ee414
|
|
| MD5 |
163c8a94f078397012f252fa9938d456
|
|
| BLAKE2b-256 |
bee357325bdb53c004d8686efca67cc4ee03b0ed7dd9fc234ff2bd355bd68c7f
|
Provenance
The following attestation bundles were made for safewrite-0.1.3-py3-none-any.whl:
Publisher:
release.yml on max-ibragimow/safewrite
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
safewrite-0.1.3-py3-none-any.whl -
Subject digest:
058411a4ce59fbc76c408a19d215d73053f4acf1590bdaa9dd051d3b7e2ee414 - Sigstore transparency entry: 2496734522
- Sigstore integration time:
-
Permalink:
max-ibragimow/safewrite@f3a7bd81c9d391d75dc2b078e549ca69b46348b2 -
Branch / Tag:
refs/tags/0.1.3 - Owner: https://github.com/max-ibragimow
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f3a7bd81c9d391d75dc2b078e549ca69b46348b2 -
Trigger Event:
push
-
Statement type: