Atomic File Write in .NET: Prevent Partial and Corrupted Files
Write files safely in .NET by saving to a temporary file, flushing it, and replacing or moving it into place to prevent partial updates.
Writing directly to an existing file is simple, but it can leave the file empty, incomplete, or corrupted if the application crashes, the process is terminated, or an I/O error occurs during the write.
A safer approach is an atomic file write: write the complete content to a temporary file first, flush it, and only then replace the destination. Readers should see either the previous complete file or the new complete file—not a half-written version.
Goal
- Never write new content directly over the destination file.
- Create the temporary file in the same directory as the destination.
- Flush the completed temporary file before publishing it.
- Use
File.Replacefor existing files andFile.Movefor first-time creation. - Delete abandoned temporary files when the operation fails or is canceled.
Why Direct Overwrite Can Be Risky
A direct write is convenient:
await File.WriteAllTextAsync(
"settings.json",
json,
cancellationToken);
However, overwriting an existing file normally truncates it before the new content has been written completely. If the process stops halfway through, the destination may contain only part of the new content—or no useful content at all.
This can be especially problematic for configuration files, exported reports, local databases, application state, cached API responses, and JSON documents that must always remain syntactically valid.
The Atomic Write Flow
- Create a uniquely named temporary file beside the destination.
- Write the complete content to the temporary file.
- Flush the writer and file-system buffers.
- If the destination exists, replace it with
File.Replace. - If the destination does not exist, move the temporary file into place.
- Remove the temporary file if anything fails before publication.
Important: The temporary file is deliberately created in the destination directory. Keeping both files on the same file-system volume allows the final replace or move operation to use the file system’s rename/replace behavior instead of falling back to a copy-and-delete operation.
AtomicFileWriter Helper
The helper below writes text using UTF-8 without a byte-order mark by default. It supports cancellation, optional backup creation, disk flushing, and temporary-file cleanup.
using System.Text;
public static class AtomicFileWriter
{
public static async Task WriteAllTextAsync(
string destinationPath,
string content,
Encoding? encoding = null,
string? backupPath = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
ArgumentNullException.ThrowIfNull(content);
var fullDestinationPath = Path.GetFullPath(destinationPath);
var directory = Path.GetDirectoryName(fullDestinationPath)
?? throw new InvalidOperationException(
"The destination directory could not be resolved.");
Directory.CreateDirectory(directory);
var destinationFileName = Path.GetFileName(fullDestinationPath);
var temporaryPath = Path.Combine(
directory,
$".{destinationFileName}.{Guid.NewGuid():N}.tmp");
var fullBackupPath = string.IsNullOrWhiteSpace(backupPath)
? null
: Path.GetFullPath(backupPath);
encoding ??= new UTF8Encoding(
encoderShouldEmitUTF8Identifier: false);
try
{
await using (var stream = new FileStream(
temporaryPath,
new FileStreamOptions
{
Mode = FileMode.CreateNew,
Access = FileAccess.Write,
Share = FileShare.None,
BufferSize = 64 * 1024,
Options = FileOptions.Asynchronous
}))
{
await using (var writer = new StreamWriter(
stream,
encoding,
bufferSize: 64 * 1024,
leaveOpen: true))
{
await writer.WriteAsync(
content.AsMemory(),
cancellationToken);
await writer.FlushAsync();
}
cancellationToken.ThrowIfCancellationRequested();
// Flush intermediate file-system buffers to disk.
stream.Flush(flushToDisk: true);
}
cancellationToken.ThrowIfCancellationRequested();
if (File.Exists(fullDestinationPath))
{
File.Replace(
sourceFileName: temporaryPath,
destinationFileName: fullDestinationPath,
destinationBackupFileName: fullBackupPath,
ignoreMetadataErrors: false);
}
else
{
File.Move(
sourceFileName: temporaryPath,
destFileName: fullDestinationPath);
}
}
finally
{
// The temp file no longer exists after a successful replace/move.
// If writing failed or was canceled, remove the leftover file.
TryDelete(temporaryPath);
}
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch
{
// Cleanup should not hide the original exception.
}
}
}
Why File.Replace and File.Move Are Both Needed
File.Replace expects the destination file to exist. It replaces the destination with the temporary file
and can optionally preserve the old destination as a backup.
When the destination is being created for the first time, there is nothing to replace.
In that case, File.Move publishes the completed temporary file under the final name.
if (File.Exists(fullDestinationPath))
{
File.Replace(
temporaryPath,
fullDestinationPath,
fullBackupPath);
}
else
{
File.Move(
temporaryPath,
fullDestinationPath);
}
Example: Safely Save JSON Settings
The helper can be used to save a JSON configuration file without exposing readers to a partially written document.
using System.Text.Json;
public sealed record ApplicationSettings(
string Theme,
string Language,
int PageSize);
var settings = new ApplicationSettings(
Theme: "Dark",
Language: "en",
PageSize: 25);
var json = JsonSerializer.Serialize(
settings,
new JsonSerializerOptions
{
WriteIndented = true
});
await AtomicFileWriter.WriteAllTextAsync(
destinationPath: "data/settings.json",
content: json,
backupPath: "data/settings.json.bak",
cancellationToken: cancellationToken);
If settings.json already exists, the previous version is written to
settings.json.bak before the new file replaces it.
Example: Save Without a Backup
Pass no backup path when retaining the previous version is unnecessary:
await AtomicFileWriter.WriteAllTextAsync(
destinationPath: "exports/report.json",
content: reportJson,
cancellationToken: cancellationToken);
Atomicity and Durability Are Different
An atomic replacement focuses on what readers observe: the old complete file or the new complete file. It prevents the destination from being updated gradually.
Durability focuses on whether data survives an operating-system crash or sudden power loss.
Calling Flush(flushToDisk: true) asks the operating system to flush intermediate buffers,
but final guarantees still depend on the operating system, file system, storage device, and hardware configuration.
Concurrency Is a Separate Concern
Atomic replacement does not automatically coordinate multiple writers. If two processes write the same destination at the same time, both can create temporary files and race to publish their result.
Applications with multiple writers should add a coordination mechanism such as:
- An application-level
SemaphoreSlimfor writes within one process. - A named mutex for multiple processes on the same machine.
- A database or distributed lock when multiple machines can update the same resource.
- A version check or optimistic-concurrency token to detect stale updates.
Common Improvements
- Return a result containing the final path, backup path, and written byte count.
- Limit or periodically remove abandoned
.tmpfiles left by a hard process crash. - Write binary content with the same pattern by accepting a stream or
ReadOnlyMemory<byte>. - Validate that an optional backup path is on a compatible file-system volume.
- Add retry logic only for specific transient I/O errors—never retry every exception blindly.
TL;DR
- Do not overwrite important files directly.
- Write the complete content to a temporary file in the destination directory.
- Flush the temporary file before publishing it.
- Use
File.Replacefor an existing destination andFile.Movefor a new file. - Remember that atomic replacement does not solve concurrent-writer coordination by itself.