Try it free
Write faster with AI writing tools
WriterPilots gives you 10+ free AI writing tools — blog writer, email writer, paraphraser, grammar fixer and more. Free to try, no credit card needed.
Try Blog Writer → Paraphraser Summarizer Grammar Fixer
AI Writing Tools

Local Notes Storage: Pick SwiftData and Implement Notes with Images

March 23, 2026 · 6 min read · 13 views

Why local storage matters for a notes app

You build a notes feature that must store titles, body text, tags, creation date, and optional images. The data size is small to medium. You expect queries by tag and sorting by date. You want a path to sync later. Choose a persistence approach that fits this need.

Choose between UserDefaults, Core Data, and SwiftData

UserDefaults

UserDefaults stores small bits of user state. It is not for collections of domain objects. You will lose query power and performance with many notes. Use UserDefaults only for preferences and flags.

Core Data

Core Data offers full object graph management and mature tools. It is robust for large datasets and complex relationships. It requires more boilerplate and careful setup. If you need fine tuned performance or heavy customization, choose Core Data.

SwiftData

SwiftData is a modern layer built for Swift and SwiftUI. It keeps much of Core Data power while making models and queries simple. For a notes app with moderate complexity and future sync plans, SwiftData offers the best tradeoff of ergonomics and power.

Decision

Use SwiftData. It provides typed models, SwiftUI friendly tools, and enough power for queries and relationships. It reduces boilerplate compared to Core Data. It scales well for the use case described.

High level design

  • Store note metadata and text in SwiftData.
  • Store image files on disk in Application Support. Save the image filename in the model. This avoids bloating the database and eases backup choices.
  • Expose a lightweight repository layer. Keep file IO isolated and synchronous where appropriate, or run it on a background queue for large writes.
  • Use @Query for lists in SwiftUI views. Insert and save via the ModelContext.

Step-by-step implementation

1. Define the model

Create a SwiftData model for notes. Include an id, title, body, tags, createdAt, and an optional image filename.

import SwiftData

@Model
final class Note {
  @Attribute(.unique) var id: UUID
  var title: String
  var body: String
  var tags: [String]
  var createdAt: Date
  var imageFilename: String?

  init(
    id: UUID = UUID(),
    title: String,
    body: String = "",
    tags: [String] = [],
    createdAt: Date = Date(),
    imageFilename: String? = nil
  ) {
    self.id = id
    self.title = title
    self.body = body
    self.tags = tags
    self.createdAt = createdAt
    self.imageFilename = imageFilename
  }
}

2. Configure the model container

Wire the container into your app so views receive a ModelContext.

import SwiftUI
import SwiftData

@main
struct NotesApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
        .modelContainer(for: [Note.self])
    }
  }
}

3. File helpers for images

Store images in Application Support. Exclude files from backups when they are recreatable. Use a helper that saves and loads by filename. Use UUID-based filenames to avoid collisions.

import UIKit

struct ImageStore {
  static let directoryName = "NoteImages"

  static func imagesDirectory() throws -> URL {
    let fm = FileManager.default
    let urls = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask)
    let base = urls[0]
    let dir = base.appendingPathComponent(directoryName)
    if !fm.fileExists(atPath: dir.path) {
      try fm.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
    }
    return dir
  }

  static func save(image: UIImage, filename: String) throws -> URL {
    let dir = try imagesDirectory()
    let url = dir.appendingPathComponent(filename)
    guard let data = image.jpegData(compressionQuality: 0.85) else {
      throw NSError(domain: "ImageStore", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid image data"])
    }
    try data.write(to: url, options: .atomic)
    return url
  }

  static func load(filename: String) -> UIImage? {
    do {
      let dir = try imagesDirectory()
      let url = dir.appendingPathComponent(filename)
      let data = try Data(contentsOf: url)
      return UIImage(data: data)
    } catch {
      return nil
    }
  }

  static func remove(filename: String) {
    do {
      let dir = try imagesDirectory()
      let url = dir.appendingPathComponent(filename)
      try FileManager.default.removeItem(at: url)
    } catch {
      // ignore missing file
    }
  }
}

4. Repository and CRUD

Keep data logic in a repository. Inject ModelContext. Save image files first, then save the model. Use error handling and background queues for heavy IO.

import SwiftData
import UIKit

final class NoteRepository {
  let context: ModelContext

  init(context: ModelContext) {
    self.context = context
  }

  func add(title: String, body: String, tags: [String], image: UIImage?) throws {
    var imageFilename: String? = nil
    if let image = image {
      let filename = UUID().uuidString + ".jpg"
      _ = try ImageStore.save(image: image, filename: filename)
      imageFilename = filename
    }

    let note = Note(title: title, body: body, tags: tags, imageFilename: imageFilename)
    context.insert(note)
    try context.save()
  }

  func update(note: Note, title: String, body: String, tags: [String], image: UIImage?) throws {
    if let newImage = image {
      if let oldFilename = note.imageFilename {
        ImageStore.remove(filename: oldFilename)
      }
      let filename = UUID().uuidString + ".jpg"
      _ = try ImageStore.save(image: newImage, filename: filename)
      note.imageFilename = filename
    }

    note.title = title
    note.body = body
    note.tags = tags
    try context.save()
  }

  func delete(_ note: Note) throws {
    if let filename = note.imageFilename {
      ImageStore.remove(filename: filename)
    }
    context.delete(note)
    try context.save()
  }
}

5. Querying in SwiftUI

Use @Query for simple lists and filters. Add a filtered query by tag where needed.

import SwiftUI
import SwiftData

struct NotesListView: View {
  @Environment(\.modelContext) private var modelContext
  @Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]

  var body: some View {
    List(notes) { note in
      VStack(alignment: .leading) {
        Text(note.title)
          .font(.headline)
        Text(note.body)
          .font(.subheadline)
          .lineLimit(2)
      }
    }
    .navigationTitle("Notes")
  }
}

Production tips

  • Keep images on disk. This prevents database bloat and speeds up model queries.
  • Use atomic writes for image saves and model saves. This reduces corruption risk.
  • Graceful error handling. Surface errors to the user with retry options.
  • Background IO. Run large image writes on a background queue to avoid blocking the main thread.
  • Migration path. SwiftData includes migration support. Test upgrades on real data sets.
  • Prepare for sync. Structure models with stable ids and simple fields. That eases later integration with CloudKit or a backend.

Testing and debugging

Write unit tests for the repository. Use an in-memory model container for tests. Verify file cleanup on delete. Validate queries return expected sorting and filtering.

In-memory container example

import SwiftData

let container = try ModelContainer(for: [Note.self], configurations: .init(storeDescriptions: [ModelStoreDescription(inMemory: true)]))
let context = container.mainContext
// Use this context in tests

Wrap up

For a notes app with tags and optional images, SwiftData offers the best balance of simplicity and power. Store text and metadata in SwiftData. Store images on disk and save filenames in the model. Keep IO isolated and test the repository. This yields a robust, maintainable local storage layer that scales well and keeps future sync options open.

Related Posts