Flutter Firestore CRUD: The Complete Practical Guide
Published: 2026-10-10 · 8 min read
Master Flutter Firestore CRUD: create, read, update, delete documents, plus transactions, pagination, security rules, and read-count traps that inflate bills.
If you're building a Flutter app with Firebase, Cloud Firestore is probably your database. The official docs will get you a basic read and write in five minutes, but they skip the parts that matter in production: data modeling, security rules, transactions, pagination, and the read-count landmines that quietly run up your bill.
This guide covers Flutter Firestore CRUD end to end, the way I'd implement it in a client app today: create, read, update, and delete with real code, plus the patterns that separate a working prototype from a production-ready data layer.
The Firestore Mental Model: Collections and Documents
Firestore is not SQL. It's a NoSQL document database organized as collections (folders) of documents (JSON-like maps), and collections can contain subcollections. The most common beginner mistake is modeling data like relational tables and then paying for it in nested reads and client-side joins.
A practical rule of thumb:
- Collections hold many documents:
users,orders,posts. - Documents hold data plus a unique ID; keep them under 1 MB.
- Subcollections handle one-to-many relationships you always query together:
users/{uid}/orders. - Denormalize data you read together often. Firestore charges per document read, so one denormalized document is cheaper than three joined ones.
Setting Up Cloud Firestore in Flutter
Add the dependency and run the FlutterFire CLI:
dependencies:
cloud_firestore: ^5.0.0
flutterfire configure
Initialize Firebase in main.dart, then Firestore is one line away:
import 'package:cloud_firestore/cloud_firestore.dart';
final db = FirebaseFirestore.instance;
// Offline persistence is ON by default on mobile.
// Reads hit cache first, then sync — great for spotty networks.
One setting worth changing immediately: on the web, FirebaseFirestore.instance.settings lets you configure persistenceEnabled and cache size. On Android/iOS, the offline cache just works.
Create: Adding Documents the Right Way
The naive way to write a document looks fine until two users create records at the same time and your auto-generated keys collide — or don't collide and you have no idea how to reference them later. Here are the two create patterns and when to use each:
// 1. Auto-generated ID — best for most create operations
final docRef = await db.collection('tasks').add({
'title': 'Ship the Flutter Firestore CRUD tutorial',
'done': false,
'priority': 2,
'createdAt': FieldValue.serverTimestamp(), // never trust client clocks
});
print('Created task: ${docRef.id}');
// 2. Custom ID — when the key is meaningful (e.g. a user UID)
await db.collection('users').doc(user.uid).set({
'name': user.displayName,
'email': user.email,
'createdAt': FieldValue.serverTimestamp(),
});
Two rules I enforce in every codebase:
- Always use
FieldValue.serverTimestamp()for creation times. Client clocks lie. - Use
.set(data, SetOptions(merge: true))when a document might already exist and you don't want to wipe fields you didn't include.
// Merge: adds/updates only the fields you pass
await db.collection('users').doc(uid).set(
{'lastSeen': FieldValue.serverTimestamp()},
SetOptions(merge: true),
);
Read: Queries, Real-Time Streams, and Pagination
Reading is where Firestore bills you, so how you read matters more than anything else in this guide. Flutter Firestore CRUD apps live and die by read efficiency.
One-time reads
final doc = await db.collection('tasks').doc(taskId).get();
if (doc.exists) {
final data = doc.data()!;
print(data['title']);
}
Real-time streams
The killer feature. snapshots() gives you a live stream that updates your UI whenever data changes:
StreamBuilder<QuerySnapshot>(
stream: db
.collection('tasks')
.where('done', isEqualTo: false)
.orderBy('priority')
.snapshots(),
builder: (context, snapshot) {
if (snapshot.hasError) return Text('Error: ${snapshot.error}');
if (!snapshot.hasData) return CircularProgressIndicator();
final tasks = snapshot.data!.docs;
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, i) {
final task = tasks[i].data() as Map<String, dynamic>;
return ListTile(title: Text(task['title']));
},
);
},
);
Critical: every where() + orderBy() combination needs a composite index. Firestore will throw an error with a console link the first time — click it, the index builds itself. But note the trap: composite indexes take time to build and you can't deploy confidently if you discover them at runtime. Build them during development, not launch day.
Pagination
Never load an entire collection. Paginate with cursors:
Future<List<QueryDocumentSnapshot>> fetchTasks({
DocumentSnapshot? lastDoc,
int limit = 20,
}) async {
var query = db.collection('tasks').orderBy('createdAt').limit(limit);
if (lastDoc != null) query = query.startAfterDocument(lastDoc);
final snap = await query.get();
return snap.docs;
}
Update: Transactions and Batched Writes
Simple field updates are one line:
await db.collection('tasks').doc(taskId).update({'done': true});
But anything that reads-then-writes — counters, balances, inventory — needs a transaction, or concurrent users will silently overwrite each other:
await db.runTransaction((transaction) async {
final docRef = db.collection('stats').doc('counters');
final snapshot = await transaction.get(docRef);
final current = (snapshot.data()?['taskCount'] ?? 0) as int;
transaction.update(docRef, {'taskCount': current + 1});
});
For multiple independent writes, batched writes are cheaper and atomic:
final batch = db.batch();
final ref1 = db.collection('tasks').doc();
final ref2 = db.collection('tasks').doc();
batch.set(ref1, {'title': 'Batch task 1', 'done': false});
batch.set(ref2, {'title': 'Batch task 2', 'done': false});
await batch.commit(); // all-or-nothing, single round trip
Delete: Hard Deletes vs Soft Deletes
Deleting is the operation most tutorials rush past and production apps regret:
// Hard delete — gone forever
await db.collection('tasks').doc(taskId).delete();
// Soft delete — safer, keeps audit history
await db.collection('tasks').doc(taskId).update({
'deleted': true,
'deletedAt': FieldValue.serverTimestamp(),
});
I default to soft deletes in client work. Users "accidentally" delete things constantly, and restoring a flagged document beats explaining data loss. Filter deleted docs out of every query (where('deleted', isEqualTo: false)) and hard-delete them later with a scheduled Cloud Function.
Flutter Firestore CRUD Mistakes That Cost Real Money
| Mistake | What happens | Fix |
|---|---|---|
Listening to a whole collection in StreamBuilder |
Every document change re-bills a read per doc | Filter with where() and limit() |
where('done', isEqualTo: false) without index |
Runtime crash on first user session | Create composite indexes during dev |
| Client-side timestamps | Sorting breaks across timezones/devices | FieldValue.serverTimestamp() |
| Storing images as base64 in documents | Blows the 1 MB document limit fast | Use Firebase Storage for files |
get() in a build method |
Re-fetches on every rebuild | Cache in state management (BLoC/Riverpod) |
Security Rules: Don't Ship Without Them
A Flutter Firestore CRUD implementation with open rules (allow read, write: if true) is a data breach waiting to happen. At minimum, lock reads and writes to authenticated users and scope data to its owner:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
match /tasks/{taskId} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.ownerId == request.auth.uid;
allow update, delete: if request.auth != null
&& resource.data.ownerId == request.auth.uid;
}
}
}
Test your rules with the Firestore emulator before deploying: firebase emulators:start --only firestore.
Wrapping Up
Firestore CRUD in Flutter is straightforward once you internalize four ideas: model documents around how you read them, use server timestamps, transact anything that reads-then-writes, and treat every query as a billing event. Get those right and Firestore scales from side project to millions of users without a rewrite.
If you're planning a Flutter app with a Firebase backend and want the data layer done right from day one, reach out — this is exactly the kind of foundation work I do for clients.
Frequently Asked Questions
When should I use add() versus set() when creating Firestore documents?
Use collection.add() for an auto-generated ID, which fits most create operations, and collection.doc(id).set() when the key is meaningful, such as a user UID. Always write creation times with FieldValue.serverTimestamp() because client clocks cannot be trusted.
How do I keep Firestore read costs down?
Denormalize data you read together often, because Firestore charges per document read — one denormalized document is cheaper than three joined ones. Model as collections of documents with subcollections for one-to-many relationships, and keep documents under 1 MB.
How do I get realtime updates from Firestore in Flutter?
Call snapshots() on a query to get a live stream that updates the UI whenever data changes, and read once with get() for one-time reads. Reading efficiently matters most because reads are where Firestore bills you.
How do I update a Firestore document without overwriting existing fields?
When a document might already exist and you only want to add or update the fields you pass, use .set(data, SetOptions(merge: true)) instead of a plain set, which would wipe the fields you did not include.