Cloud Firestore — Queries, Filters & Pagination
Cloud Firestore — Queries, Filters & Pagination
Firestore is not just a key-value store — it is a powerful NoSQL document database with a rich querying API. This lesson covers how to build precise, compound queries using where(), orderBy(), and limit() clauses, how to implement cursor-based pagination with startAfterDocument(), and why composite indexes matter for production apps.
Basic Filtering with where()
The where() clause filters documents based on field values. Firestore supports the following comparison operators:
isEqualTo,isNotEqualToisLessThan,isLessThanOrEqualToisGreaterThan,isGreaterThanOrEqualToarrayContains,arrayContainsAnywhereIn,whereNotIn
Simple where() Query
import 'package:cloud_firestore/cloud_firestore.dart';
Future<List<Map<String, dynamic>>> getPublishedPosts() async {
final snapshot = await FirebaseFirestore.instance
.collection('posts')
.where('status', isEqualTo: 'published')
.where('viewCount', isGreaterThan: 100)
.get();
return snapshot.docs
.map((doc) => {'id': doc.id, ...doc.data()})
.toList();
}
where() calls creates a logical AND between the conditions. Firestore does not natively support OR across different fields — for that pattern you must run two separate queries and merge the results in Dart.Sorting Results with orderBy()
The orderBy() clause sorts the returned documents. You can chain multiple orderBy() calls to apply secondary sort keys. Pass descending: true to reverse the order.
where() with an inequality operator (isLessThan, isGreaterThan, etc.) on a field, the first orderBy() in the chain must sort on that same field. Failing to do so will throw a runtime exception.Compound Query with orderBy() and limit()
Future<QuerySnapshot<Map<String, dynamic>>> getTopRatedProducts() async {
return FirebaseFirestore.instance
.collection('products')
.where('inStock', isEqualTo: true)
.where('rating', isGreaterThanOrEqualTo: 4.0)
.orderBy('rating', descending: true) // must order by the inequality field first
.orderBy('createdAt', descending: true) // then secondary sort
.limit(20)
.get();
}
Cursor-Based Pagination with startAfterDocument()
Offset-based pagination (offset: n) is not supported in Firestore. Instead, Firestore uses query cursors to page through results efficiently. The key method is startAfterDocument(lastDoc), which resumes the query starting from the document after the last one you already fetched.
startAfterDocument(doc)— start after the given document (exclusive)startAtDocument(doc)— start at the given document (inclusive)endBeforeDocument(doc)— stop before the documentendAtDocument(doc)— stop at the document (inclusive)
Paginated Feed with startAfterDocument()
class PostFeed {
static const int _pageSize = 10;
DocumentSnapshot? _lastDocument;
bool _hasMore = true;
final List<Map<String, dynamic>> _posts = [];
Future<void> loadNextPage() async {
if (!_hasMore) return;
Query<Map<String, dynamic>> query = FirebaseFirestore.instance
.collection('posts')
.where('status', isEqualTo: 'published')
.orderBy('publishedAt', descending: true)
.limit(_pageSize);
// Apply cursor if this is not the first page
if (_lastDocument != null) {
query = query.startAfterDocument(_lastDocument!);
}
final snapshot = await query.get();
if (snapshot.docs.length < _pageSize) {
_hasMore = false; // reached the end of the collection
}
if (snapshot.docs.isNotEmpty) {
_lastDocument = snapshot.docs.last;
_posts.addAll(snapshot.docs.map((d) => {'id': d.id, ...d.data()}));
}
}
List<Map<String, dynamic>> get posts => List.unmodifiable(_posts);
bool get hasMore => _hasMore;
}
DocumentSnapshot object as your cursor — not just the document ID or a field value. Firestore uses the snapshot internally to resolve the precise cursor position, including tie-breaking on secondary sort fields. Losing the snapshot reference means you cannot paginate correctly.Composite Indexes
Every orderBy() call combined with a where() on a different field requires a composite index in Firestore. Without it, the SDK throws a FAILED_PRECONDITION error and includes a direct link in the error message to create the index in the Firebase Console with a single click.
- Single-field indexes are created automatically by Firestore.
- Composite indexes (multi-field) must be defined explicitly in
firestore.indexes.jsonor via the Console. - Indexes are per-collection and are eventually consistent — they may take a few minutes to build after creation.
- Deploy indexes with
firebase deploy --only firestore:indexes.
firestore.indexes.json and commit them to version control so teammates and CI share the same index configuration.Real-Time Pagination with StreamBuilder
For live-updating feeds, combine a Query with a StreamBuilder. Keep a reactive reference to the current query and update it when the user requests more data. Because each page is a separate stream snapshot, merging pages requires maintaining a local list that accumulates results across pages.
Summary
Firestore's querying surface — where(), orderBy(), limit(), and cursor-based pagination — covers the vast majority of real-world data-fetching needs. The key discipline is to plan your indexes up-front, always page with document snapshots rather than offsets, and respect the constraint that inequality filters must come first in the orderBy() chain. These patterns compose cleanly whether you are building a one-time Future fetch or a live-updating Stream.