BS
BleepingSwift
Published on
8 min read

> @AppStorage vs UserDefaults vs SwiftData: Choosing the Right Persistence

Every app needs to save data somewhere. Maybe it's a user's preferred theme, a list of favorite items, or an entire database of records. Swift gives you several options for local persistence, and picking the right one depends on what you're storing and how you plan to use it.

The three most common choices are @AppStorage, UserDefaults, and SwiftData. They each serve different purposes, but there's enough overlap that it's easy to reach for the wrong one. Let's walk through each approach and see where they shine.

@AppStorage: SwiftUI's Built-in Preference Binding

@AppStorage is a SwiftUI property wrapper that reads and writes values to UserDefaults automatically. It's the simplest persistence option and it integrates directly with SwiftUI's view update system, so your UI refreshes whenever the stored value changes.

import SwiftUI

struct SettingsView: View {
    @AppStorage("isDarkMode") private var isDarkMode = false
    @AppStorage("username") private var username = "Guest"
    @AppStorage("fontSize") private var fontSize = 16.0

    var body: some View {
        Form {
            Toggle("Dark Mode", isOn: $isDarkMode)

            TextField("Username", text: $username)

            Slider(value: $fontSize, in: 12...24, step: 1) {
                Text("Font Size: \(Int(fontSize))")
            }
        }
    }
}

That's all it takes. No save buttons, no manual encoding. The value persists across app launches and updates the view automatically.

@AppStorage supports a limited set of types out of the box: Bool, Int, Double, String, URL, and Data. If you want to store something like an enum, you can conform it to RawRepresentable:

enum AppTheme: String {
    case system
    case light
    case dark
}

struct ThemePickerView: View {
    @AppStorage("selectedTheme") private var selectedTheme: AppTheme = .system

    var body: some View {
        Picker("Theme", selection: $selectedTheme) {
            Text("System").tag(AppTheme.system)
            Text("Light").tag(AppTheme.light)
            Text("Dark").tag(AppTheme.dark)
        }
    }
}

The limitation here is real though. @AppStorage is meant for simple preferences. If you try to store anything complex by encoding it to Data, you lose the simplicity that made it appealing in the first place.

UserDefaults: The Traditional Approach

UserDefaults has been around since the early days of iOS development. It's a key-value store backed by a property list file, and it works well for small pieces of data like settings, flags, and user preferences.

// Writing values
UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding")
UserDefaults.standard.set(42, forKey: "highScore")
UserDefaults.standard.set(["Swift", "Kotlin"], forKey: "favoriteLanguages")

// Reading values
let completed = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding")
let score = UserDefaults.standard.integer(forKey: "highScore")
let languages = UserDefaults.standard.stringArray(forKey: "favoriteLanguages") ?? []

Where UserDefaults becomes more useful than @AppStorage is when you need to persist data outside of SwiftUI views. You might be saving state in a network manager, caching a token in a service class, or tracking analytics preferences in your app delegate. @AppStorage is a SwiftUI property wrapper, so it only works inside views.

You can also store Codable objects by encoding them yourself:

struct UserPreferences: Codable {
    var notificationsEnabled: Bool
    var dailyReminderTime: Date
    var preferredCategories: [String]
}

class PreferencesManager {
    private let key = "userPreferences"

    func save(_ preferences: UserPreferences) {
        if let data = try? JSONEncoder().encode(preferences) {
            UserDefaults.standard.set(data, forKey: key)
        }
    }

    func load() -> UserPreferences {
        guard let data = UserDefaults.standard.data(forKey: key),
              let preferences = try? JSONDecoder().decode(UserPreferences.self, from: data) else {
            return UserPreferences(
                notificationsEnabled: true,
                dailyReminderTime: Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: Date())!,
                preferredCategories: []
            )
        }
        return preferences
    }
}

One thing to keep in mind: UserDefaults is not a database. Apple's documentation specifically warns against storing large amounts of data in it. The entire contents get loaded into memory, so if you start cramming large arrays or image data in there, you'll see performance issues. A good rule of thumb is to keep the total size under a few hundred kilobytes.

SwiftData: Structured Persistence for Real Data

When your data has relationships, needs querying, or involves more than a handful of records, SwiftData is the right tool. Introduced at WWDC 2023, it replaces Core Data with a much cleaner API built around Swift macros and modern concurrency.

You define your data model with the @Model macro:

import SwiftData

@Model
class Recipe {
    var title: String
    var ingredients: [String]
    var instructions: String
    var isFavorite: Bool
    var dateCreated: Date

    @Relationship(deleteRule: .cascade)
    var tags: [Tag]

    init(title: String, ingredients: [String], instructions: String) {
        self.title = title
        self.ingredients = ingredients
        self.instructions = instructions
        self.isFavorite = false
        self.dateCreated = Date()
        self.tags = []
    }
}

@Model
class Tag {
    var name: String

    init(name: String) {
        self.name = name
    }
}

SwiftData handles the storage, schema migrations, and relationship management. You query your data using the @Query macro in SwiftUI:

struct RecipeListView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \Recipe.dateCreated, order: .reverse) private var recipes: [Recipe]

    var body: some View {
        NavigationStack {
            List(recipes) { recipe in
                RecipeRow(recipe: recipe)
                    .swipeActions {
                        Button(role: .destructive) {
                            context.delete(recipe)
                        } label: {
                            Label("Delete", systemImage: "trash")
                        }
                    }
            }
            .navigationTitle("Recipes")
            .toolbar {
                Button("Add") {
                    let recipe = Recipe(
                        title: "New Recipe",
                        ingredients: [],
                        instructions: ""
                    )
                    context.insert(recipe)
                }
            }
        }
    }
}

You set up the model container at the app level:

@main
struct RecipeApp: App {
    var body: some Scene {
        WindowGroup {
            RecipeListView()
        }
        .modelContainer(for: [Recipe.self, Tag.self])
    }
}

@Query supports filtering with predicates, which makes it easy to build search and filter features:

struct FavoriteRecipesView: View {
    @Query(
        filter: #Predicate<Recipe> { $0.isFavorite },
        sort: \Recipe.title
    )
    private var favorites: [Recipe]

    var body: some View {
        List(favorites) { recipe in
            Text(recipe.title)
        }
        .navigationTitle("Favorites")
    }
}

SwiftData requires iOS 17+, so if you're still supporting older versions, you'll need to stick with Core Data or one of the other options for now.

When to Use Each

The decision usually comes down to what kind of data you're storing and where you need to access it.

@AppStorage is perfect for individual settings that drive your UI. Think toggles, theme preferences, or small configuration values that a SwiftUI view needs to both read and write. It's the least amount of code for the most common case.

UserDefaults covers the same territory but works everywhere, not just in SwiftUI views. Use it when you need to read or write preferences from a background service, an app extension, or anywhere outside the view layer. It also lets you share data between your main app and widgets or extensions through app groups.

SwiftData is for structured, queryable data. If you're building a notes app, a recipe manager, a workout tracker, or anything where users create and manage collections of records, that's SwiftData territory. It handles relationships between objects, supports sorting and filtering, and persists everything to an SQLite database under the hood.

Here's a practical example showing all three in one app:

import SwiftUI
import SwiftData

// SwiftData model for structured data
@Model
class JournalEntry {
    var text: String
    var date: Date
    var mood: String

    init(text: String, mood: String) {
        self.text = text
        self.date = Date()
        self.mood = mood
    }
}

struct JournalView: View {
    // @AppStorage for a simple UI preference
    @AppStorage("showDates") private var showDates = true

    // SwiftData query for structured records
    @Query(sort: \JournalEntry.date, order: .reverse) private var entries: [JournalEntry]
    @Environment(\.modelContext) private var context

    var body: some View {
        NavigationStack {
            List(entries) { entry in
                VStack(alignment: .leading, spacing: 4) {
                    Text(entry.text)
                    if showDates {
                        Text(entry.date, style: .date)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                }
            }
            .navigationTitle("Journal")
            .toolbar {
                Toggle("Show Dates", isOn: $showDates)
            }
        }
    }
}

In this journal app, @AppStorage handles the display preference (whether to show dates), while SwiftData manages the actual journal entries. If this app also had a share extension that needed to check a preference, you'd use UserDefaults with an app group for that piece.

A Quick Note on Alternatives

These three aren't the only options. Keychain is the right choice for sensitive data like passwords and tokens. File-based storage (writing JSON or plist files to the documents directory) works well for large blobs of data or when you need precise control over the file format. And if you need a full relational database with complex queries, you can always use SQLite directly through packages like GRDB.

But for the vast majority of apps, some combination of @AppStorage, UserDefaults, and SwiftData covers everything you need. Start with the simplest option that fits your data, and upgrade to a more capable solution when you outgrow it.

Sample Project

Want to see this code in action? Check out the complete sample project on GitHub:

View on GitHub

The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.