Skip to main content

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 mkstemp creates files as 0600;
  • a new file gets 0600 instead of what your umask says;
  • a secret exists on disk world-readable for a moment, because chmod runs 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-C in the middle leaves a .app.conf.x7f2.tmp next to the real file, forever;
  • overwrite=False implemented 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 fsync is 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

safewrite-0.1.3.tar.gz (12.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

safewrite-0.1.3-py3-none-any.whl (10.1 kB view details)

Uploaded Python 3

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

Hashes for safewrite-0.1.3.tar.gz
Algorithm Hash digest
SHA256 a82dd3c2846214fbee585c0521040ddb6302c083dd29e4e10574d9efed85d86f
MD5 fc11b8b6b57b1d13cb2241a24e375fc6
BLAKE2b-256 c18764c90d288788ba93f08ee00d403af8983a09c667c22a05cacaa1b9d19d80

See more details on using hashes here.

Provenance

The following attestation bundles were made for safewrite-0.1.3.tar.gz:

Publisher: release.yml on max-ibragimow/safewrite

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for safewrite-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 058411a4ce59fbc76c408a19d215d73053f4acf1590bdaa9dd051d3b7e2ee414
MD5 163c8a94f078397012f252fa9938d456
BLAKE2b-256 bee357325bdb53c004d8686efca67cc4ee03b0ed7dd9fc234ff2bd355bd68c7f

See more details on using hashes here.

Provenance

The following attestation bundles were made for safewrite-0.1.3-py3-none-any.whl:

Publisher: release.yml on max-ibragimow/safewrite

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.3 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page