Loading...

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


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

  1. Create a uniquely named temporary file beside the destination.
  2. Write the complete content to the temporary file.
  3. Flush the writer and file-system buffers.
  4. If the destination exists, replace it with File.Replace.
  5. If the destination does not exist, move the temporary file into place.
  6. 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:


Common Improvements


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.Replace for an existing destination and File.Move for a new file.
  • Remember that atomic replacement does not solve concurrent-writer coordination by itself.