1. Introduction
If atomic write is “don’t corrupt the file on failure”, then backup is “don’t lose data even if you did something stupid yourself”. And it sounds offensive, but it’s honest: most data loss in educational projects happens not because of evil hackers, but because of the developer who accidentally wrote the wrong thing, to the wrong place, in the wrong format, or mixed up paths. And this is where atomic write is powerless: it will correctly replace the file with a new, but incorrect version.
Imagine our console app (let it be a simple “expense tracker”) stores data in data/expenses.txt. We added/removed expenses, pressed “save”… and suddenly found out the line-formatting logic broke and we wrote garbage into the file. The file was written reliably (thanks, atomic write), but the data became useless. Backup in this situation is like a “save checkpoint” in a game: you can step back and continue without a tragedy.
An important idea: a backup is made before replacing the file, because a backup is “the previous version”, not “just some version”.
The simplest backup: .bak
When you start doing backups for the first time, you want to build Git-level versioning… but today is not about Git, and we don’t need “history for ages”. We need a simple, understandable protocol: before we replace expenses.txt, we save the old file as expenses.txt.bak. If something goes wrong, we have “yesterday’s version”.
From a file-system perspective, it looks like this:
| Before saving | Make a backup | After backup |
|---|---|---|
|
renamed to |
|
| (no new file yet) | write the new version | |
Pay attention to a tricky detail: if .bak already exists, we must decide what to do with it. For the basic variant — either delete the old .bak, or treat it as an error and stop. For learning purposes it’s usually easier to delete it (but check the deletion result).
Mini function: one-copy backup
After this subheading, I’m intentionally not rushing straight into code. First — the logic: if the main file doesn’t exist, there’s nothing to back up, that’s “success”. If the file exists, we want to move it to .bak. If .bak is already there, we carefully delete it. And only then do we call renameTo.
import java.io.File
fun backupOnce(target: File): Boolean {
if (!target.exists()) return true // nothing to back up
val dir = target.parentFile ?: File(".")
val bak = File(dir, target.name + ".bak")
if (bak.exists() && !bak.delete()) return false // failed to clear the old backup
return target.renameTo(bak) // move the current file to .bak
}
There’s no try/catch, because renameTo and delete return a Boolean, and we honestly check it. Yes, this doesn’t make the code “invulnerable”, but it makes the behavior predictable: if the backup failed, we don’t proceed, because we might lose the old version.
2. Backup rotation: multiple copies without losing history
Why one copy isn’t enough and what rotation is
One copy is already not bad, but in real scenarios it breaks on the human factor. Suppose you saved the file with an error twice in a row. The first time you corrupted the data — .bak became the “normal version” (before the error). The second time you corrupted it again — and now .bak got overwritten by the version “after the first error”, and the “good” version disappeared. So you have a backup, but it’s useless.
Rotation solves exactly this pain: we keep not one, but several previous versions, for example 3. Then we’ll have expenses.txt.bak1, expenses.txt.bak2, expenses.txt.bak3. And each time before saving we “shift” the copies: .bak2 becomes .bak3, .bak1 becomes .bak2, and the current expenses.txt becomes .bak1. And only then we write a new expenses.txt.
A small table of how it “moves”:
| Before saving | After rotation (before writing the new file) |
|---|---|
|
|
|
|
|
|
|
delete (if maxCopies = 3) |
The idea is simple: keep a finite number of versions so it doesn’t grow without bound.
Why rotation is done “from the end”
There’s a classic newbie trap here: start with .bak1 and move forward. It seems logical: “I’ll do .bak1 → .bak2, then file → .bak1”. But if you do .bak1 → .bak2 while .bak2 hasn’t been moved to .bak3 yet, you risk overwriting .bak2 and losing history. It’s like rearranging boxes in a storage room starting with the first one: at some point you hit the fact that there’s nowhere to put the next box.
That’s why rotation is always done “from the end”: first free up space for the oldest copy, then move one step “up”, and only at the end make .bak1 from the current file. Then nothing gets overwritten accidentally.
A convenient way to remember this strategy is: “move history backward in time”. Move the oldest first.
3. Rotation implementation: rotateBackups
Now we’ll write the rotation function to be as “educational” as possible: predictable, with checks, and without trying to be clever. It will return a Boolean: success — true, failure — false. If it’s false, we do not continue writing, because our goal is data safety, not “write at any cost”.
First, a bit of preparation. We need file names bak1, bak2, ... The easiest way to build them is concatenating the name and the number.
import java.io.File
fun backupFile(target: File, index: Int): File {
val dir = target.parentFile ?: File(".")
return File(dir, target.name + ".bak" + index)
}
Now the rotation itself. Let’s agree right away: if maxCopies <= 0, consider rotation “disabled” and return true.
import java.io.File
fun rotateBackups(target: File, maxCopies: Int): Boolean {
if (maxCopies <= 0) return true
// 1) Shift older copies: bak2 -> bak3, bak1 -> bak2, ...
for (i in maxCopies downTo 2) {
val from = backupFile(target, i - 1)
val to = backupFile(target, i)
if (from.exists()) {
if (to.exists() && !to.delete()) return false
if (!from.renameTo(to)) return false
}
}
// 2) Current file -> bak1
if (target.exists()) {
val bak1 = backupFile(target, 1)
if (bak1.exists() && !bak1.delete()) return false
if (!target.renameTo(bak1)) return false
}
return true
}
It’s important to notice a few “real-life” details here.
First, we don’t try to rename something that doesn’t exist: if from doesn’t exist, we skip it. That’s fine: at the beginning of a project you might only have bak1, and bak2 and bak3 haven’t appeared yet.
Second, before renameTo we delete to if it exists. Otherwise renameTo might not work (and will return false). We don’t try to guess OS behavior — we just prepare the spot.
Third, we move files only within a single directory, because in the previous lecture about atomic write we already discussed the idea “make temp next to target”, and for backups this is also logical: fewer surprises with permissions and renaming.
4. Saving: rotation + atomic write
Combine backup and atomic write into one operation
Now the nicest part: we assemble this into a clear file-saving protocol. And it’s important not to mix up the order of steps. First backup/rotation protects the old, then atomic write publishes the new.
In the previous lecture we already had a function like atomicWriteText(target, text) (temp → rename). We’re not “rewriting” it now; we use it as a ready-made building block. And that’s generally a good style: small functions are easier to test and there’s less chance to break everything at once.
Let’s assemble the save function:
import java.io.File
fun writeWithBackup(target: File, text: String, maxCopies: Int): Boolean {
if (!rotateBackups(target, maxCopies)) return false
return atomicWriteText(target, text) // function from the previous lecture
}
If you’re thinking “where is error handling?” — it’s distributed across layers. rotateBackups returns false if it couldn’t protect the old version. atomicWriteText returns false if it couldn’t correctly write and publish the new one. We don’t hide problems; we honestly report upward “it didn’t work”.
It’s useful to remember finally again: the finally block always runs and is handy for cleaning up temporary files after an atomic write attempt.
Example in a console app: expense tracker
To keep this from being in a vacuum, let’s imagine we have a simple app that stores “state” in a string. For example, we already know how to turn a list of expenses into text (even if it’s just many lines). Today we’re interested specifically in saving.
Let’s make a minimal example: write three versions of the file in a row and see what happens to the .bak copies.
import java.io.File
fun main() {
val file = File("data/expenses.txt")
writeWithBackup(file, "v1: coffee=3\n", maxCopies = 3)
writeWithBackup(file, "v2: coffee=3\npizza=12\n", maxCopies = 3)
writeWithBackup(file, "v3: coffee=4\npizza=12\n", maxCopies = 3)
println("main exists=${file.exists()}") // main exists=true
println("bak1 exists=${backupFile(file, 1).exists()}") // bak1 exists=true
println("bak2 exists=${backupFile(file, 2).exists()}") // bak2 exists=true
}
Note: we don’t print the entire contents (we could, but then the example would bloat). We check the mere fact of existence. If everything works, then after three saves we’ll have the current expenses.txt and at least bak1, bak2. And as the number of saves grows, bak3 will start appearing, and older versions will be pushed out.
5. Save protocol: diagram
It’s useful to see this once as a sequence of actions, because in a beginner’s head it’s easy to mix everything up: “delete first or rename first or write first”. The diagram below is your insurance against logical leaps.
flowchart TD
A[Need to save the target file] --> B{Did backup rotation succeed?}
B -- no --> X[Stop: do not touch the data]
B -- yes --> C[Atomic write: write to temp]
C --> D{Was writing temp successful?}
D -- no --> Y[Stop: remove temp, target remains as was/in bak]
D -- yes --> E[Publish: rename temp -> target]
E --> F{Did rename succeed?}
F -- no --> Z[Stop: report an error]
F -- yes --> G[Done: target updated, old version in bakN]
Notice that under some failures (for example, if rotation already moved target to bak1, and atomic write failed) the current “actual” version may end up in a backup file. That’s fine, but it means your app must correctly handle “save failed” and not pretend everything is okay.
6. How many copies to keep and how to name them
When you add rotation, you may want to immediately make “100 copies to be sure”. But practice says it’s rarely needed: you waste disk space, slow down renames, and complicate life for the user (a zoo of files appears in the folder). In learning apps, 3–5 copies are usually enough.
It’s also worth agreeing on names in advance. We use the format fileName.bakN because it’s easy to read and easy for a human to sort. We could have used fileName.1.bak, but then name-based sorting would be less obvious (and we still live in a world where people open folders with their eyes, not only with code).
If you make separate Kotlin files for such utilities, try to give names that describe the contents, not UtilityMagic42.kt. It’s not about strictness for strictness’ sake; it’s about you telling yourself “thanks” a week later.
7. Typical mistakes with backup and rotation
Mistake #1: make a backup after writing the new file.
Such a backup looks “logical” at the intuition level (“save first, then make a copy”), but by meaning it’s no longer a backup — it’s a copy of the new version. If the new version is corrupted, you’ve cemented the problem: there’s nowhere to roll back. A backup always captures the old state, so it is done before replacement.
Mistake #2: rotate “from the beginning” and overwrite history.
If you start with .bak1 → .bak2, it’s easy to overwrite .bak2 before you managed to move .bak2 → .bak3. Result: part of the chain disappears, and you don’t notice it right away. The correct order is from the end: .bak2 → .bak3, then .bak1 → .bak2, and only then target → .bak1.
Mistake #3: ignore renameTo(...) == false.
renameTo is not “magic”, but an OS operation that can fail. If you don’t check the Boolean, the program will behave as if the backup was created, while in reality nothing happened. And then you can lose data: for example, you might delete target thinking it’s already in .bak1.
Mistake #4: continue writing even if the backup failed.
When backup/rotation returns false, it means: “I couldn’t protect the old version”. Continuing to write after that is risky, because you might replace the file and end up with no previous state at all. In reliable scenarios, it’s better to stop and honestly report “save failed”.
Mistake #5: keep backups in a different directory.
Sometimes you want to put .bak files into a separate folder. In theory it’s fine, but in beginner-level practice it often leads to permission issues, different disks, and unpredictable renameTo. While we’re learning, it’s better to keep backups next to the file: fewer surprises and easier diagnostics.
Mistake #6: keep an infinite number of copies.
If you create a new backup each time with a unique name (for example, with a timestamp) and never delete old ones, in a month the folder will turn into a museum of past mistakes. Rotation with maxCopies is like a limit on browser tabs: it saves you from the situation “I opened 200 tabs and now I’m afraid to close even one”.