Learn how to read and write documents with Cloud Firestore in a SwiftUI app, model data with Codable, and attach a real-time snapshot listener that updates your UI automatically.
Before writing code, it helps to understand Firestore's data model, because it is not a relational database. Firestore stores data in documents, which are lightweight records of key-value fields, and documents live inside collections. A document can also contain subcollections, giving you a nested tree of collections and documents. There are no tables, rows, or joins; instead you design your structure around the queries your app needs, often duplicating data (denormalizing) so a single read returns everything a screen requires. For a to-do app, you might have a top-level collection named tasks, where each document holds fields like title, isDone, and an ownerId, plus a createdAt timestamp. Because Firestore bills per document read and write, thinking about access patterns up front directly affects both performance and cost. Keep documents reasonably small, prefer many small documents over a few giant ones when you query them independently, and use subcollections to model one-to-many relationships such as a user document with a subcollection of that user's tasks. A good rule of thumb is to structure data around the screens you will build, not around how you would normalize it in SQL, because in Firestore the shape of your data is really the shape of your reads.
Add the FirebaseFirestore product to your package through Xcode's Package Dependencies if it is not already present. Firestore works cleanly with Swift's Codable protocol, which lets you map documents directly to and from your model types without manually reading each field. Define a struct, for example Task, conforming to Codable and Identifiable. Add a property annotated with @DocumentID of type String? to hold the document's identifier, which Firestore populates automatically when decoding and omits when encoding. Add your data fields such as title: String and isDone: Bool, and a createdAt property you can type as Date, which Firestore maps to its Timestamp type. Making the model Identifiable lets you drive a SwiftUI List directly from an array of these tasks. Obtain a reference to the database with Firestore.firestore() and, ideally, store it once rather than calling it repeatedly. With a Codable model and a database handle, both reading and writing become concise, type-safe operations instead of dictionary juggling. If you want a server-assigned creation time, you can annotate a field with @ServerTimestamp so Firestore fills it in on write, which keeps ordering consistent even if client clocks drift.
To create a document, get a reference to the target collection with db.collection("tasks") and call addDocument(from:) passing your Codable model instance; Firestore encodes it and generates a random document ID. If you want to control the ID yourself, use db.collection("tasks").document(customID).setData(from:), which creates or overwrites the document at that path. To update specific fields without overwriting the whole document, call updateData with a dictionary of the fields you want to change, for example toggling isDone. Prefer the async/await forms, awaiting try await ref.addDocument(from: task) inside a Task, and wrap them in do/catch to surface errors. A subtle but important point: because you likely have a snapshot listener attached, you usually should not manually append the new item to your local array after writing, since the listener will deliver the change and doing both causes duplicates. Firestore also applies writes to its local cache immediately and syncs to the server in the background, so your UI can feel instant even before the network round-trip completes. To delete a document, call delete() on its reference; like writes, deletes are reflected locally first and then confirmed by the server, and your listener will report the removal so your list updates on its own.
Sometimes you want a one-time read rather than a live subscription, for example loading a settings document on launch. Call getDocuments() on a collection or query to fetch a QuerySnapshot, then map its documents. With Codable, iterate the snapshot's documents and call document.data(as: Task.self) on each, collecting the results into an array; wrap the whole thing in do/catch because decoding can throw if a document's shape does not match your model. For a single document, call getDocument() on a document reference and decode it the same way, checking that it exists first. You can shape the read with query operators before fetching: whereField to filter, order(by:) to sort, and limit(to:) to cap results. For instance, db.collection("tasks").whereField("ownerId", isEqualTo: uid).order(by: "createdAt", descending: true) returns only the current user's tasks, newest first. Be aware that combining certain filters and orderings requires a composite index, which Firestore will prompt you to create with a direct link in the error message the first time you run the query. One-time reads are the right tool for data that rarely changes or that you only need at a single moment, and they cost you exactly one read per document rather than an ongoing subscription.
The feature that makes Firestore shine in SwiftUI is the real-time listener. Instead of fetching once, call addSnapshotListener on a collection reference or query. Firestore immediately delivers the current data and then pushes an update every time a matching document is added, changed, or removed, whether the change came from this device or another. Store this inside an ObservableObject: create a class conforming to ObservableObject with a @Published var tasks: [Task] = [], and in a start() method assign the listener, decoding each document in the snapshot's documents into your model and assigning the resulting array to tasks on the main actor. Your SwiftUI view observes this object with @StateObject and renders a List over tasks; when data changes anywhere, the array updates and the list animates automatically. Keep the returned ListenerRegistration and call remove() when the view disappears or the object deinitializes, so you stop paying for reads and avoid leaks. This one pattern replaces manual refresh logic and pull-to-refresh for live data. Because listeners deliver only the documents that changed after the initial snapshot, keeping one attached is efficient, but leaving stale listeners running on screens the user has left is a common and avoidable source of extra reads.
Firestore's SDK caches data on the device and works offline by default, which is a major advantage on mobile. Reads are served from the local cache when the network is unavailable, and writes are queued locally and synced when connectivity returns, so your app stays usable on a spotty connection. Your listener even fires with cached data first, and the snapshot's metadata tells you whether data came from cache and whether writes are pending, which you can surface as a subtle syncing indicator. On the error side, always handle the error parameter in your listener closure and the thrown errors from reads and writes, because permission failures and index-missing errors show up here. Crucially, none of this is secure until you write Firebase Security Rules in the console: rules gate every read and write, and a common pattern is allowing access only when request.auth.uid matches the document's ownerId field. Test your rules in the console's Rules Playground, and never ship with the default open test-mode rules, which allow anyone to read and write your entire database and which expire after a set period anyway. Rules run on Google's servers, so they are the real enforcement boundary regardless of what your client code does.
A complete flow looks like this: an ObservableObject owns a Firestore reference and a published array, its start() method attaches a query-scoped snapshot listener filtered to the signed-in user's uid, and its methods for adding, toggling, and deleting tasks call addDocument, updateData, and delete on the appropriate references. Your SwiftUI view uses @StateObject to hold that object, renders a List of the tasks, and calls the object's methods from buttons and swipe actions, letting the listener drive all UI updates rather than mutating the array by hand. This keeps a single source of truth and eliminates most refresh bugs. From here, natural next steps include pagination with query cursors like start(afterDocument:) for long lists, composite indexes for complex queries, batched writes or transactions when you need multiple documents to change atomically, and moving expensive or trusted logic into Cloud Functions. Combined with authentication and well-crafted security rules, Firestore gives a SwiftUI app a live, offline-capable backend with remarkably little code, which is exactly why it is the default choice for so many indie iOS developers. Start with the simplest structure that serves your screens, measure your read counts as usage grows, and refine your data model and indexes as real access patterns emerge.
getDocuments() performs a single one-time fetch and then stops. addSnapshotListener subscribes to the query and delivers the current data plus every future change in real time until you remove it. Use a one-time read for static or rarely changing data, and a listener when you want the UI to stay in sync automatically.
Yes, by default. The SDK caches documents locally, serves reads from that cache when offline, and queues writes to sync when the network returns. Listeners fire with cached data first, and snapshot metadata indicates whether data is from cache and whether writes are still pending, so you can show a syncing state.
Make your model conform to Codable, add an @DocumentID String? property for the document ID, and use addDocument(from:) or setData(from:) to write and document.data(as: YourType.self) to read. Firestore's Codable support handles the field mapping and even converts its Timestamp type to Swift Date.
Queries that combine certain filters with an ordering, or use multiple range conditions, require a composite index. The first time you run such a query, Firestore returns an error containing a direct link that creates the needed index in the console. After the index finishes building, the query works.
Snapshot listeners keep costing reads as long as they are attached and data changes. Keep the ListenerRegistration returned by addSnapshotListener and call remove() when the view disappears or the owning object deinitializes. Detaching listeners you no longer need is the main way to control listener-driven read costs.
No. Authentication provides identity, but access is controlled entirely by Firebase Security Rules. You must write rules, typically checking request.auth.uid against an ownerId field, so users can only access their own documents. Never ship with open test-mode rules, which allow anyone to read and write everything.