JavaRush/Courses/Kotlin SELF/Atomic write: temp file + r...

Atomic write: temp file + renameTo as protection against partial writes

Open

1. Why writeText() is sometimes dangerous

When you first start saving data to a file, it feels like the task is solved forever: “well, I called file.writeText(text)—so the file is written.” But a file system is not a function; it is a separate little world with its own rules. And that world sometimes pulls surprises: sudden process termination, running out of disk space, power loss, an antivirus that “paused to think,” or someone simply unplugged a flash drive (yes, it happens).

The problem is not that writeText() is “bad.” The problem is that overwriting the target file directly can leave the file in an intermediate state. For example, you stored your app’s data in data/expenses.txt, started writing a new version—and the write got interrupted mid-way. As a result, the file can become empty or partially written. And the worst part: on the next start you will try to read it, and there you have “half a line” or a truncated chunk.

Imagine your file is a notebook with homework. Overwriting directly is when you erase the old answer with an eraser and then suddenly fall asleep face-first on the notebook mid-process. In the morning you have neither the old solution nor the new one. The atomic write approach is when you write the new homework on a draft sheet, and then neatly paste the finished sheet into the notebook.

A small demo of “how we usually write” (and why it is risky when failures happen):

import java.io.File

fun main() {
    val target = File("data/expenses.txt")
    target.writeText("...new data...\n")
    println("Saved!") // Saved!
}

From the code point of view, everything looks nice. From the real-life point of view, a lot can happen between the start and the end of the write.

2. The atomic write idea: temp → rename

Now we will go through a simple protocol that applications very often use to reduce the risk of a “partial write.” I am intentionally explaining it in a down-to-earth way, because truly “strictly atomic on all OSes and file systems under any conditions” is a deeper and more complex topic. Within this course, it is important to master a practical and noticeably more reliable approach without magic and without diving into low-level details.

The idea is: we do not touch the target file until the new version is fully ready. First, we write the entire text into a temporary file (temp) next to the target. If writing the temp succeeds, then we “publish” the result: we rename temp to the target. If something crashes in the middle of writing temp, the target file remains the old one—intact and readable.

Process diagram (as a mini-algorithm):

flowchart TD
    A[Assembled the new text] --> B[Wrote it to a temp file next to the target]
    B -->|success| C[Published: rename temp -> target]
    B -->|error| D[Left the target as it was]
    C --> E[Deleted temp leftovers, if any remained]
    D --> E

The key point here is: “build the new result separately, then swap it in quickly.” Quickly matters, because the less “time in the danger zone,” the fewer chances you have to end up with an intermediate state.

3. Why the temp file should be created next to the target file

When you hear “temporary file,” your hand naturally reaches to write it somewhere “in /tmp,” “in the current directory,” or “in a separate temp folder.” And sometimes that is a perfectly fine idea. But in our protocol there is an important nuance: renaming via renameTo() behaves much more predictably when temp is in the same directory as the target.

To a beginner this may look like nitpicking (“what difference does it make where the file is if I am renaming it?”), but in practice it does matter. If temp is on another disk/partition/file system, a rename can turn into a “move,” and a move is not required to be fast and reliable. Sometimes it will simply fail, and renameTo() will honestly return false.

We do not need to dive into OS internals right now. Remember the practical rule: create temp in the same folder where the target file will live. Then you are less likely to get false from renameTo() and more likely that “publishing” works as intended.

Let’s make a small utility that builds the temp path next to the target:

import java.io.File

fun tempFor(target: File): File {
    val parent = target.parentFile ?: File(".")
    return File(parent, target.name + ".tmp")
}

Yes, the .tmp name is quite simple. In real applications, people often add a random suffix to avoid conflicts, but for a learning project we will start with a clear and understandable variant.

4. renameTo() and checking the result

Renaming sounds like a “rock-solid operation,” but the JVM method File.renameTo(...) has an important detail: it returns a Boolean. That means the operation can fail—and you will not automatically get an exception (this depends on the situation and platform). Therefore, our code must treat renameTo() as a step that must be checked.

It is very easy to make a thinking mistake here: “well, if there is no exception, then everything is fine.” No, it does not mean that. You must check true/false and make a decision.

A mini-example of the correct style:

import java.io.File

fun main() {
    val from = File("data/a.tmp")
    val to = File("data/a.txt")

    val renamed = from.renameTo(to)
    println("renamed=$renamed") // renamed=true (or false)
}

If renamed=false, this is not a “minor annoyance,” it is a signal: publishing did not happen. And if at that moment you delete the temp file “just in case,” you might delete the only up-to-date version of the data.

5. Implementing atomicWriteText: protocol and nuances

Direct write vs temp+rename

Before writing the full code, it is useful to capture the difference between the approaches not as “feelings,” but as expected behavior under failures.

Situation Direct overwrite of target Temp → rename
Error during write (disk full, process died) target may become partially written target remains the old version
Error during “publishing” usually too late: target may already be affected the swap may not happen, but you still have temp
Diagnostics “why is the file corrupted?”—an unpleasant quest easier to understand which step failed
Implementation easier as 1 line a bit more code, but you sleep calmer

This does not make direct writing “forbidden.” It just means you now have a tool for cases when the file is important.

Basic version of the function

Now we will assemble a function atomicWriteText that implements the protocol:

  1. make sure the folder for the target exists (or create it)
  2. write the new text to temp
  3. if the target already exists, decide what to do (in this lecture: a simple policy—delete it so it does not block rename)
  4. perform renameTo(temp -> target) and check the result
  5. in any case, try to remove temp (best effort) in finally

What matters here is that finally is our “stage crew”: it will run both on success and on failure. Kotlin (and the JVM) guarantee execution of finally when leaving try (with rare exotic exceptions), so it is an excellent tool for cleanup.

The first version of the function will be as straightforward as possible, without “beauty optimizations”:

import java.io.File
import java.io.IOException

fun atomicWriteText(target: File, text: String): Boolean {
    val parent = target.parentFile ?: File(".")
    if (!parent.exists() && !parent.mkdirs()) return false

    val temp = File(parent, target.name + ".tmp")

    return try {
        temp.writeText(text)

        if (target.exists() && !target.delete()) return false

        temp.renameTo(target)
    } catch (e: IOException) {
        false
    } finally {
        if (temp.exists()) temp.delete() // best effort
    }
}

Notice an important “psychological moment”: finally is not a “100% guarantee of deleting temp,” because delete() can also fail. But we do not need an absolute guarantee: we need to avoid leaving temporary files behind in common error scenarios, as much as possible.

Improving readability: small helper functions

When a function starts doing many small checks, a beginner often falls into one of two extremes: either they write everything in one huge block and get confused, or they split it into dozens of micro-functions and lose the thread. We will choose the golden middle: move the most “speaking” parts into helpers so the main protocol reads like a story.

By the way, Kotlin encourages “expressions instead of ceremony,” and try can also be an expression, i.e., return a value—that is normal and readable.

Let’s make directory preparation a separate function:

import java.io.File

fun ensureParentDir(target: File): Boolean {
    val parent = target.parentFile ?: File(".")
    if (parent.exists()) return parent.isDirectory
    return parent.mkdirs()
}

And temp generation next to the target:

import java.io.File

fun tempFor(target: File): File {
    val parent = target.parentFile ?: File(".")
    return File(parent, target.name + ".tmp")
}

Now the main protocol can be rewritten a bit more simply for reading:

import java.io.File
import java.io.IOException

fun atomicWriteText(target: File, text: String): Boolean {
    if (!ensureParentDir(target)) return false

    val temp = tempFor(target)

    return try {
        temp.writeText(text)

        if (target.exists() && !target.delete()) return false

        temp.renameTo(target)
    } catch (e: IOException) {
        false
    } finally {
        if (temp.exists()) temp.delete()
    }
}

This is still the same algorithm; now it is just easier to explain in plain language.

Deleting the target before rename: a trade-off

You may have noticed the line:

if (target.exists() && !target.delete()) return false

This is a simple policy: if the file already exists, we delete it so that renameTo can “take its place.” On some platforms, renaming over an existing file works; on others, it does not, so for a learning project we make the behavior more predictable.

But there is a trade-off. If you deleted the target and then renameTo() did not work, you can end up in a “the old is gone, the new is not published” situation. We partially reduce the risk by deleting the target after successfully writing temp, not before it. But there is still a window where the old file is already deleted.

In this lecture we lock in the basic temp→rename protocol and careful checking discipline. If you also need to protect the old version of the data, you need an additional mechanism that preserves the previous file before replacement. That is a separate topic, and we will not dive into it here.

Why try should be narrow

Another typical trap is to wrap absolutely everything in try: building the text, formatting, parsing, printing messages. Then you catch IOException, but in reality the error might not be I/O at all, and you lose the understanding of what exactly broke.

The idea of a narrow try/catch is: put inside only what actually touches the file system. Everything else lives outside. This style also matches the general recommendation to keep expressions (try, if, when) in a readable form: let them return a value and not grow into “a pile of everything at once.”

So this is fine:

val text = lines.joinToString("\n", postfix = "\n")
val ok = atomicWriteText(out, text)

But this is already questionable:

try {
    val text = lines.joinToString("\n", postfix = "\n")
    out.writeText(text)
    println("ok")
} catch (e: Exception) {
    println("something went wrong")
}

In the second variant, you “caught everything,” but you threw away the meaning.

6. Integrating atomic write into a console application

So the examples are not “in a vacuum,” let’s continue the practical idea of the learning app: a console expense tracker. Suppose by this point in the course you already have an in-memory list of expenses and simple commands (add/list). Keeping them only in RAM is sad: you close the program—and everything is gone. So we save data to a text file.

Let the format be simple: one line is one record like 2026-01-14;Coffee;4.50. The format is not perfect, but the goal right now is not the format; it is reliable publishing of the file.

Let’s make a function that builds the line for saving (no complex formats):

fun formatExpenseLine(date: String, title: String, amount: Double): String {
    return "$date;$title;$amount"
}

Now a function that saves the expense list to a file. Note: we first build the entire text in memory, and only then write it “atomically” via temp+rename. This matters: if you write “piece by piece” into the target, you return to the partial write problem again.

import java.io.File

fun saveExpenses(target: File, lines: List<String>): Boolean {
    val text = lines.joinToString(separator = "\n", postfix = "\n")
    return atomicWriteText(target, text)
}

And an example mini-main that saves two expenses:

import java.io.File

fun main() {
    val out = File("data/expenses.txt")

    val lines = listOf(
        formatExpenseLine("2026-01-14", "Coffee", 4.50),
        formatExpenseLine("2026-01-14", "Sandwich", 8.00)
    )

    val ok = saveExpenses(out, lines)
    println("saved=$ok") // saved=true (if everything succeeded)
}

Even if the program crashes in the middle of writing the temp file, the old expenses.txt should not turn into a “truncated stub.” That is the practical win of the protocol.

7. Common mistakes

Mistake No. 1: writing the temp file not next to the target.
Beginners often write temp into the current directory, while the target is in data/out/.... Then renameTo() suddenly returns false, and “shamanism” starts: “but I am renaming it, why can’t it?” Within our protocol, it is simpler and more reliable to create temp in the same folder as the target, so publishing is more predictable.

Mistake No. 2: not checking the result of renameTo(...).
A very common scenario: someone writes temp.renameTo(target) and immediately prints “Saved!” If renameTo returned false, you effectively lied to yourself and the user, and then you might also delete temp in finally, losing fresh data. The rule is simple: renameTo is an algorithm step; its result must be checked.

Mistake No. 3: deleting the target before successfully writing temp.
Sometimes the code looks like: “first I’ll delete the old file, then I’ll write the new one.” That brings us back to the worst-case scenario: if writing the new file fails, the old one is already gone. In our protocol, the order matters: first write temp completely, and only then decide what to do with the target.

Mistake No. 4: forgetting to clean up the temporary file.
If you do not use finally, then on an exception the temp file will remain on disk. Once is not scary, but over time the folder can turn into a museum of .tmp artifacts. finally is a normal tool exactly for such “cleanup” actions, and it is guaranteed to run when leaving try, regardless of whether there was an exception.

Mistake No. 5: doing an “atomic write,” but writing in chunks directly into temp without a completion guarantee.
Sometimes people try to write temp line by line while simultaneously updating some parts of the file. This is not forbidden by itself, but then you must be sure that temp is either written to the end, or you treat it as garbage. The most understandable path for a learning project is to assemble the entire text into a string and write it in one go. It is easier to read and easier to debug.

Mistake No. 6: catching Exception “just in case” and returning true.
The most dangerous variant: “if there is an error—whatever, we’ll still say we saved.” That way you are guaranteed to lose data and find out only from the user (who, as we know, “did everything right”). It is better to return false and honestly report that saving failed than to pretend everything is fine.

1
Task
Kotlin SELF,  45 level,  3 lecture
Locked
Next to the target
Next to the target
1
Task
Kotlin SELF,  45 level,  3 lecture
Locked
Publishing the report
Publishing the report
1
Task
Kotlin SELF,  45 level,  3 lecture
Locked
Preparing the folder
Preparing the folder
1
Task
Kotlin SELF,  45 level,  3 lecture
Locked
Save without partial writes
Save without partial writes
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet