DEV Community

孫昊
孫昊

Posted on

Adding a Field to a Codable Struct Without Breaking Older Saved JSON (Swift)

I shipped a small feature today: yearly-recurring events in DaysUntil. Tiny visible change — toggle on, the event auto-rolls to next year once the date passes. Two-line product description.

Underneath, though, was a quiet trap I see indie iOS devs hit constantly: adding a field to a Codable struct silently breaks every user who saved the previous schema.

If you've ever shipped an update and gotten "the app crashes when I open it" reports — and the field you added had a default value that you assumed would Just Work — this post is for you.

The setup

Original Event struct, saved to disk as JSON:

struct Event: Identifiable, Codable, Hashable {
    var id: UUID = UUID()
    var title: String
    var date: Date
    var emoji: String = "🎯"
    var colorID: String = "default"
    var createdAt: Date = .now
}
Enter fullscreen mode Exit fullscreen mode

I want to add var isRecurring: Bool = false. Just one line, right? Default is false, so existing saved data should keep working.

struct Event: Identifiable, Codable, Hashable {
    var id: UUID = UUID()
    var title: String
    var date: Date
    var emoji: String = "🎯"
    var colorID: String = "default"
    var createdAt: Date = .now
    var isRecurring: Bool = false   // <-- new
}
Enter fullscreen mode Exit fullscreen mode

Ship it.

Why it breaks

Swift's synthesized Codable conformance ignores property default values during decoding. When old JSON arrives without isRecurring, the decoder throws keyNotFound, the try? JSONDecoder().decode(...) returns nil, and your EventStore silently loads zero events.

The user's data isn't corrupted on disk. It's still there. But the app shows a blank list because the try? swallowed the error.

This is one of those bugs that's invisible in dev (you only have new schema data) and lights up on day-1 of the rollout when real users open the app.

The fix: explicit init(from:)

You need a custom decoder that uses decodeIfPresent for the new field:

struct Event: Identifiable, Codable, Hashable {
    var id: UUID = UUID()
    var title: String
    var date: Date
    var emoji: String = "🎯"
    var colorID: String = "default"
    var createdAt: Date = .now
    var isRecurring: Bool = false

    enum CodingKeys: String, CodingKey {
        case id, title, date, emoji, colorID, createdAt, isRecurring
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.id          = try c.decode(UUID.self, forKey: .id)
        self.title       = try c.decode(String.self, forKey: .title)
        self.date        = try c.decode(Date.self, forKey: .date)
        self.emoji       = try c.decodeIfPresent(String.self, forKey: .emoji) ?? "🎯"
        self.colorID     = try c.decodeIfPresent(String.self, forKey: .colorID) ?? "default"
        self.createdAt   = try c.decodeIfPresent(Date.self, forKey: .createdAt) ?? .now
        self.isRecurring = try c.decodeIfPresent(Bool.self, forKey: .isRecurring) ?? false
    }
}
Enter fullscreen mode Exit fullscreen mode

The synthesized encode(to:) is fine — you only need a custom decoder. Encoding always writes all current fields; decoding tolerates missing ones from older clients.

Note: I added decodeIfPresent for all fields with defaults, not just the new one. This makes future migrations cheaper — every additional optional field is now precedented in the decoder.

The test that catches the regression

func testNonRecurringDefaultsRetainOldBehavior() throws {
    // Decoding a JSON without isRecurring (older clients) must not throw.
    let json = #"""
    {
        "id":"00000000-0000-0000-0000-000000000001",
        "title":"Old",
        "date":777600000,
        "emoji":"🎯",
        "colorID":"default",
        "createdAt":777600000
    }
    """#.data(using: .utf8)!
    let decoded = try JSONDecoder().decode(Event.self, from: json)
    XCTAssertFalse(decoded.isRecurring)
}
Enter fullscreen mode Exit fullscreen mode

If you forget the custom init(from:), this test fails with keyNotFound("isRecurring", ...). Better to catch it in CI than in the App Store reviews.

Why this matters more than it looks

Indie iOS apps tend to use simple file persistence (Documents/state.json) rather than CoreData or SQLite. That's a great default — fewer moving parts, fast iteration. But it means your schema migration story lives entirely in Codable. Adding a field without thinking about decoding-old-data is a footgun every two months.

Three rules I now follow on every persisted struct:

  1. Every field has a documented default. Either a stored property default, or a sentinel.
  2. Use decodeIfPresent for any field added after v1.0. Even if the field has a default, the synthesized decoder won't honor it.
  3. One backwards-compat test per migration. Hardcoded JSON of the old schema, must decode cleanly.

Boring discipline. Saves day-1 panic.

The bigger lesson

Most iOS bugs that hit production aren't in the visible feature. They're in the data layer one step below it. Recurring events is a 30-line feature; the schema migration is the 5-line trap that decides whether your existing users even see it.

If you're shipping any v1.0.X update and adding a new field to a Codable struct, before you push: write the JSON of the v1.0.0 schema, decode it in a test, assert it doesn't throw. That's it.

If you want the full TF Debug Bible compressed to one PDF + working scripts: TF Debug Bible — $29. Three months of Apple Forum thread digging, condensed.


Code from this post is in my open-source DaysUntil app.

If you're an indie iOS dev hitting silent data corruption on update, drop a comment — happy to compare migration patterns.

Top comments (0)