Files and persistence

Save Codable data atomically

Encode one small model, write it atomically, and replace the previous file only after the new bytes are ready.

Our notes need to survive a relaunch. For a small local app you don’t need a database. A single JSON file, written carefully, carries you a long way.

Saving is two lines

Note already conforms to Codable, so encoding the whole array is short:

func save(_ notes: [Note], to fileURL: URL) throws {
  let data = try JSONEncoder().encode(notes)
  try data.write(to: fileURL, options: .atomic)
}

The .atomic option is the part people skip, and it’s the part that matters.

Without it, write streams bytes straight into the destination file. If the app crashes or the Mac loses power halfway through, the file is left half-written. The old data is gone and the new data is garbage.

An atomic write goes through a temporary file. The full new content is written next to the destination, and only then renamed into place. The rename is a single filesystem operation. At every moment the path holds either the complete old file or the complete new one, never a mix.

Loading needs more care

“No file” and “broken file” are different situations, and your code must tell them apart:

func load(from fileURL: URL) throws -> [Note] {
  let data: Data
  do {
    data = try Data(contentsOf: fileURL)
  } catch CocoaError.fileReadNoSuchFile {
    return []
  }
  return try JSONDecoder().decode([Note].self, from: data)
}

A missing file is normal. It’s the first launch, and an empty list is the right answer.

A file that exists but fails to decode is not normal. Let that error propagate and show the user something. It means their data is there but unreadable.

The mistake that destroys data

Here it is: wrapping the whole load in try? and falling back to [].

Decoding fails for some reason. The app starts “fresh” with zero notes. The next autosave writes that empty array over the user’s file. The data that was corrupt but recoverable is now actually gone.

Distinguish the errors, and never let a failed read feed the next write.

Back up before migrating

When you later change the Note structure, copy the old file to a backup name before migrating. A migration that fails halfway with no backup is the same story with extra steps.

Verify with a crash test

Save a few notes, find the JSON in Application Support, and open it. It should be readable and complete.

Then force-quit the app mid-use a few times. The file should always parse. If you ever find half a JSON document in there, a non-atomic write slipped in somewhere.

Lesson completed