Queries
Build type-safe reads, aggregations, streams, and real-time subscriptions with the fluent query builder.
Query builder
Section titled “Query builder”Call repo.query() to get a FirestoreQueryBuilder<T, W, S>. It exposes a fluent, type-safe
interface for composing filters, ordering, projections, aggregations, pagination, streaming, and
real-time listeners. Chain the builder methods and then call a terminal method (get(), getOne(),
exists(), count(), paginate(), and so on) to execute the query.
const results = await orderRepo .query() .where('status', '==', 'pending') .where('total', '>', 100) .where('createdAt', '>=', startOfMonth) .orderBy('total', 'desc') .limit(50) .get();The chainable builder methods are:
where(field, op, value)— add a filter clause.where('id', …)is a compile error — the syntheticidis not a queryable field path; usewhereId(...)instead.whereId(op, value)— query by document name. Scalar operators take astring;in/not-intake areadonly string[].whereFilter(build)— compose a nested AND/OR filter with a schema-aware filter factory.select(...fields)— project only the named fields.orderBy(field, dir = 'asc')— sort results (required beforepaginate()).orderById(dir = 'asc')— order by document name.limit(n)— cap the number of documents returned (last-wins withlimitToLast).limitToLast(n)— last N of an ordered result set (requiresorderBy; not combinable withstream/ opaquepaginate/offsetPaginate).startAt/startAfter/endAt/endBefore— typed inclusive/exclusive cursor bounds (DocumentSnapshotor field values).offset(n)— skip the first N matches (0allowed; not combinable with opaquepaginate/offsetPaginate).
Terminal methods that execute the query include get(), explain(), getOne(), exists(),
count(), collectionCount(), sum(), average(), aggregate(), distinctValues(),
paginate(), offsetPaginate(), paginateWithCount(), stream(), onSnapshot(), update(),
and delete().
Opaque forward paging stays on paginate(pageSize, cursor); reverse pages use bounds +
limitToLast + get() (see Query bounds & reverse pagination).
getOne() / exists() compose with limitToLast (they skip a .limit(1) narrowing that would
otherwise last-wins overwrite the last-N window).
Performance note: Firestore charges per document read. Use limit() and pagination to control
costs on large collections — see Performance for the
full cost model.
Filtering
Section titled “Filtering”Stack where() clauses to narrow results. All standard Firestore operators are supported, including
in, array-contains, and range comparisons.
const results = await userRepo .query() .where('age', '>', 18) .where('status', 'in', ['active', 'verified']) .where('tags', 'array-contains', 'premium') .get();Composite AND/OR filters
Section titled “Composite AND/OR filters”Chained where() clauses are an implicit AND — every clause must match. For a disjunction,
use whereFilter(). The callback receives a filter factory whose field paths are typed against your
stored model at every nesting depth, so a typo inside a nested group is still a compile error.
// status == 'published' OR (authorId == me AND visibility == 'private')const posts = await postRepo .query() .whereFilter(f => f.or( f.where('status', '==', 'published'), f.and(f.where('authorId', '==', currentUserId), f.where('visibility', '==', 'private')), ), ) .orderBy('createdAt', 'desc') .get();f.whereId(op, value) puts a document-name condition inside a group, with the same validated id
boundary as whereId():
const feed = await postRepo .query() .where('deleted', '==', false) // AND-ed with the composite below .whereFilter(f => f.or(f.where('pinned', '==', true), f.whereId('in', featuredIds))) .get();A whereFilter() combines with everything else on the builder — chained where() clauses,
orderBy/limit, select, the aggregations, pagination, stream(), onSnapshot(), and the
update()/delete() terminals.
Two things to know:
-
f.or()/f.and()with no arguments throws. Firestore silently drops an empty composite filter, so an empty group would widen your query to every document in the collection instead of failing. If you build groups dynamically, check for an empty list first:const posts = statuses.length? await postRepo.query().whereFilter(f => f.or(...statuses.map(s => f.where('status', '==', s)))).get(): []; -
An OR query can need more indexes than you expect. Firestore normalizes a composite filter into disjunctive form and evaluates each disjunct, so one
whereFilter()may surface several successiveFirestoreIndexErrors — follow each link until every branch is covered. The server also caps a query at 30 disjunctions after normalization; see Troubleshooting and Performance & Cost.
⚠️ An inequality inside an OR branch excludes documents missing that field
Section titled “⚠️ An inequality inside an OR branch excludes documents missing that field”Firestore adds an implicit orderBy for every inequality field (<, <=, >, >=, !=) found
anywhere in the filter tree, and a document that lacks an ordered field cannot appear in the
results. (not-in is an inequality too, but it can never appear inside an OR — Firestore rejects
that combination outright.) Inside a disjunction that means an inequality in one branch can drop
documents matched by another branch — so an OR query can return fewer rows than one of its own
disjuncts:
// 3 documents have kind: 'x'; two of them have no `score` field at all.await postRepo.query().where('kind', '==', 'x').get(); // → 3 documents
await postRepo .query() .whereFilter(f => f.or(f.where('score', '>', 5), f.where('kind', '==', 'x'))) .get(); // → 1 document — the two without `score` are gonecount() returns the same reduced number, so this is query planning, not a read-path quirk. It also
applies to the destructive update() / delete() terminals, which would silently skip those
documents.
Safe shapes inside or(): equality, in, array-contains / array-contains-any — and
f.whereId(...) with a comparison operator, which is exempt because Firestore skips documentId()
when adding implicit orders and a document name always exists.
If you need an inequality branch, either guarantee the field is always written (give it a default at
create time) or run the branches as separate queries and merge the results by id.
Returning a prebuilt Admin SDK Filter (f => myFilter) is supported as an escape hatch, applied
verbatim without the factory’s typed paths or id validation.
⚠️ An empty prebuilt sub-group is dropped, not rejected
Section titled “⚠️ An empty prebuilt sub-group is dropped, not rejected”The zero-argument guard above is an arity check on f.and() / f.or(). It cannot see inside a
Filter you built yourself, so an empty SDK group — whether returned whole or passed in as a child
of a factory group — is silently discarded by Firestore and changes what the query means:
import { Filter } from 'firebase-admin/firestore';
// `TRUE OR published` should match everything; the empty AND is dropped, so this NARROWS to publishedawait postRepo .query() .whereFilter(f => f.or(Filter.and(), f.where('status', '==', 'published'))) .get();
// `FALSE AND published` should match nothing; the empty OR is dropped, so this WIDENS to publishedawait postRepo .query() .whereFilter(f => f.and(Filter.or(), f.where('status', '==', 'published'))) .get();Only a filter that reduces to no conditions at all is caught (the query would otherwise match
the whole collection). Build groups with f.and() / f.or() and the arity guard covers you; if you
assemble raw SDK filters, check for empty groups yourself before passing them in.
To reuse a filter group across call sites, extract it as a predicate and annotate the factory with
StoredDataOf<typeof repo> — which is already the stored shape without the synthetic id as a
typed path. Indexed stored models keep both their declared fields (even when the raw model declares
id) and dynamic index access in that alias, so a reusable predicate can name declared siblings
while still rejecting id / arbitrary map keys as typed string paths. Union stored models are
supported on the directly-typed constructor path (ADR-0028); withSchema still requires a
ZodObject and cannot express a union schema.
import type { QueryFilterFactory, StoredDataOf } from 'flintfire';
const publishedOrMine = (uid: string) => (f: QueryFilterFactory<StoredDataOf<typeof postRepo>>) => f.or(f.where('status', '==', 'published'), f.where('authorId', '==', uid));
const posts = await postRepo.query().whereFilter(publishedOrMine(currentUserId)).get();The shape must match that repository exactly — annotating a predicate with a different repository’s shape is a compile error, not a silent mismatch.
Collection-group queries
Section titled “Collection-group queries”repo.collectionGroup() queries every collection that shares this repository’s collection id,
at any depth in the database — the classic “all posts, across all users” query. The group id is
the last segment of the repository’s path, and the returned handle inherits that repository’s read
model, stored (query-path) model, read converter, and allowLegacyDatastoreIds policy.
// A repository for one user's posts: 'users/u1/posts'const userPosts = userRepo.subcollection('u1', 'posts', postSchema);
// …and the same shape, across every user.const postGroup = userPosts.collectionGroup();
const published = await postGroup .query() .where('status', '==', 'published') .orderBy('createdAt', 'desc') .limit(20) .get();If you have no concrete parent id handy, make a top-level handle — constructing a repository does no I/O, so the collection it names does not have to exist:
const postGroup = FirestoreRepository.withSchema(db, 'posts', postSchema).collectionGroup();Results carry full-path identity
Section titled “Results carry full-path identity”Document ids are only unique within one collection, so users/u1/posts/p1 and
users/u2/posts/p1 are different documents that both report id: 'p1'. Group results are therefore
CollectionGroupDocuments: the read data plus id, the full
path, and the containing collection’s parentPath — all plain strings, so a result stays
JSON-serializable.
const rows = await postGroup.query().where('status', '==', 'draft').get();
rows[0].id; // 'p1' ← ambiguous across the grouprows[0].path; // 'users/u2/posts/p1' ← the identity that is actually uniquerows[0].parentPath; // 'users/u2/posts'To act on a document, rebuild a reference from the path with the Firestore instance you already
own: db.doc(row.path).
Identity is overlaid on top of the document data, exactly as id is on a normal read, so a field
named path or parentPath would be shadowed. collectionGroup() throws if a schema-validated
repository declares either at the top level of its read or stored schema — the stored model
counts because query field paths derive from it, so where('path', …) would target a field the
result can never expose.
What the group id actually matches
Section titled “What the group id actually matches”A collection group is matched purely by collection id, which catches more than people expect:
- a same-named root collection (
posts/abc) is a member; - a same-named collection nested under a group member (
users/u1/posts/p1/posts/deep) is a member; - a collection with a different id under the same parent is never a member.
Document-name queries use the full path
Section titled “Document-name queries use the full path”Because ids are ambiguous, a group’s document-name operations work on the full path.
wherePath(...) / orderByPath(...) replace whereId(...) / orderById(...), which are not
available on a group builder:
await postGroup.query().wherePath('==', 'users/u1/posts/p1').getOne();await postGroup .query() .wherePath('in', [db.doc('users/u1/posts/p1')]) .get();
// Stable pagination tiebreaker — ordering is lexicographic over the full path.await postGroup.query().orderByPath().paginate(20);Path operands are validated segment by segment against the same rules as any other id the ORM
accepts, so a bare id, a .. segment, or a reserved __…__ segment throws
InvalidDocumentIdError before any I/O. A well-formed path that
simply isn’t in the group matches nothing (Firestore reports no error for it).
Inside whereFilter(...), the group factory exposes f.wherePath(...) for the same reason:
await postGroup .query() .whereFilter(f => f.or(f.where('status', '==', 'published'), f.wherePath('==', pinnedPath))) .get();Read-only, and the rest of the surface
Section titled “Read-only, and the rest of the surface”Everything else behaves exactly as it does on a single-collection query — where, whereFilter,
orderBy, limit, select, get / getOne / exists, count, sum / average,
distinctValues, paginate / offsetPaginate / paginateWithCount, stream, and onSnapshot.
groupCount() replaces collectionCount() as the unfiltered count.
A collection group has no CollectionReference, so there is no write surface: update() and
delete() do not exist on a group builder (a compile error, not a runtime throw). Their bulk hooks
carry { ids } payloads, and ids are not unique across a group, so every registered hook would see
ambiguous identity. That absence is structural on the group builder; for the single-collection
builder, annotate a facade return as
ReadOnlyQuery to get the same guarantee at
the type level. For a group-wide write, drop to the Admin SDK — see
Scope & Capabilities.
⚠️ Collection-group queries need their own indexes
Section titled “⚠️ Collection-group queries need their own indexes”Firestore’s automatic single-field indexes are collection-scoped. A collection-group query that
filters or orders on a field needs an explicitly created collection-group-scoped index in
production — even for a single where(...). The emulator does not enforce this, so a group query
that passes locally can fail deployed with
FirestoreIndexError. See
Troubleshooting.
Sorting
Section titled “Sorting”Chain orderBy() calls to sort by one or more fields. The direction defaults to 'asc'.
const sorted = await productRepo.query().orderBy('price', 'desc').orderBy('name', 'asc').get();Pagination
Section titled “Pagination”paginate(pageSize, cursor?) performs forward, opaque cursor-based pagination and returns
{ items, nextCursor, hasMore }. It requires at least one prior orderBy() call for a stable
cursor and throws unless pageSize is a positive integer (so 0, a negative, and 2.5 are all
rejected). Pass the previous page’s
nextCursor to fetch the next page. Tokens encode a document path only — they are not interchangeable
with typed startAfter field values.
Invalid numeric pagination arguments throw TypeError. A malformed cursor, a cursor from another
collection or collection group, or a cursor whose document was deleted throws
InvalidPaginationCursorError; branch on its reason ('malformed' | 'source_mismatch' | 'stale')
and restart from the first page rather than matching message text.
// Cursor-based pagination (recommended)// orderBy() is required for stable cursor paginationconst { items, nextCursor, hasMore } = await userRepo .query() .orderBy('createdAt', 'desc') .paginate(20);
// Next pageconst nextPage = await userRepo.query().orderBy('createdAt', 'desc').paginate(20, nextCursor);Use offsetPaginate(page, pageSize) for offset-based pagination. It is simpler but less efficient
on large datasets, since Firestore must scan and discard the skipped documents. For a raw skip on
the builder itself, offset(n) is also available (n >= 0).
// Offset pagination (less efficient for large datasets)const page2 = await userRepo.query().orderBy('createdAt', 'desc').offsetPaginate(2, 20);Query bounds & reverse pagination
Section titled “Query bounds & reverse pagination”Typed bounds mirror the Admin SDK: pass a DocumentSnapshot, or field values in the same order as
your orderBy clauses.
// Inclusive bounded range on a stored fieldconst midScores = await productRepo.query().orderBy('score', 'asc').startAt(20).endAt(40).get();
// Reverse page ending at a cursor (results still in orderBy order)const previousPage = await productRepo .query() .orderBy('score', 'asc') .endAt(40) .limitToLast(20) .get();limitToLast(n) requires orderBy and cannot be combined with stream(), paginate(), or
offsetPaginate() — use get() (or onSnapshot() for listeners). getOne() / exists() do
compose with limitToLast (they avoid applying a .limit(1) that would last-wins overwrite the
window). A prior offset(n) is likewise rejected by opaque paginate / offsetPaginate. If both
limit and limitToLast are chained, the last call wins.
paginateWithCount(pageSize, cursor?) combines paginate() and count() in a single call,
returning the same { items, nextCursor, hasMore } plus a total count of all matching documents.
It performs an extra aggregation read.
const { items, nextCursor, hasMore, total } = await productRepo .query() .where('inStock', '==', true) .orderBy('createdAt', 'desc') .paginateWithCount(20, lastCursor);
console.log(`Showing ${items.length} of ${total} products`);Aggregations
Section titled “Aggregations”Aggregations run server-side and are billed at a reduced rate compared to reading every matching document.
// Sum a numeric field across matching documentsconst totalRevenue = await orderRepo.query().where('status', '==', 'completed').sum('total');
// Average a numeric field — returns null (not 0) when there are no numeric valuesconst avgRating = await reviewRepo.query().where('productId', '==', 'prod-123').average('rating');
// Count matching documentsconst activeCount = await userRepo.query().where('status', '==', 'active').count();
// Multiple aliased aggregations in ONE request (dashboards)const stats = await orderRepo .query() .where('status', '==', 'completed') .aggregate({ orders: { kind: 'count' }, revenue: { kind: 'sum', field: 'total' }, avgOrder: { kind: 'average', field: 'total' }, });// stats.orders: number; stats.revenue: number; stats.avgOrder: number | null
// Total collection count — ignores any accumulated where() clausesconst totalUsers = await userRepo.query().where('status', '==', 'active').collectionCount();
// Existence checkconst hasOrders = await orderRepo.query().where('userId', '==', 'user-123').exists();
// Distinct values for a field — semantic equality; drops undefined but preserves stored nullconst categories = await productRepo.query().distinctValues('category');count() respects the query’s filters, whereas collectionCount() counts the entire collection and
ignores any where() clauses on the builder. sum(field) and average(field) operate on numeric
fields; average(field) returns number | null, yielding null (distinct from 0) when there are
no numeric values. aggregate(spec) runs several aliased count / sum / average entries in a
single round trip (backend max 5 per request). distinctValues(field) returns the unique values
for a field by Firestore-aware semantic equality, dropping undefined but preserving stored null.
select() + a count-only aggregation is legal. select() combined with any sum / average
(including via aggregate()) is rejected locally — Firestore does not allow property masks with
aggregation fields.
Selecting fields
Section titled “Selecting fields”Use select() to project only the fields you need. This reduces payload size but does not reduce
the document-read cost.
const userEmails = await userRepo .query() .where('subscribed', '==', true) .select('email', 'name') .get();select() returns a new query builder (it does not mutate the one you called it on), and the
result type narrows to FirestoreDocument<DeepPartial<T>> — every property, including nested map
properties, is optional, so a field you projected away (at any depth) is a compile error to access
without a guard. A readConverter written for full documents may throw on a projected result. Note
that a projected query cannot be used with onSnapshot() (see below).
Bulk query operations
Section titled “Bulk query operations”query().update(data) updates every document matching the query and returns the number of documents
written; query().delete() deletes every matching document and returns the matched (deleted)
count. To hand a query builder across a trust boundary with those write terminals withheld,
annotate it as ReadOnlyQuery.
Note:
query().update()runs the bulk lifecycle hooksbeforeBulkUpdate(which may mutate the update payload before validation) andafterBulkUpdate(receiving{ ids }of the written documents);query().delete()runsbeforeBulkDeleteandafterBulkDelete(receiving{ ids, documents }). The per-documentbefore/afterUpdateandbefore/afterDeletehooks do not run here — use the single-document methods if you need those. See Lifecycle hooks.
// Update all matching documents; returns the number of documents writtenconst updatedCount = await orderRepo .query() .where('status', '==', 'pending') .update({ status: 'processing' });
// Delete all matching documents; returns the number of documents matchedconst deletedCount = await userRepo.query().where('lastLogin', '<', oneYearAgo).delete();
// Delete matching documents with multiple filtersawait orderRepo .query() .where('status', '==', 'cancelled') .where('createdAt', '<', sixMonthsAgo) .delete();Streaming for large datasets
Section titled “Streaming for large datasets”stream() returns an async generator that yields matching documents one at a time, avoiding the
memory cost of loading an entire result set at once.
// Stream all users without loading them into memoryfor await (const user of userRepo.query().stream()) { await sendEmail(user.email); console.log(`Processed user ${user.id}`);}
// Stream with filtersfor await (const order of orderRepo.query().where('status', '==', 'pending').stream()) { await processOrder(order);}Performance cost: Streaming still reads every matching document, so you are charged for every document read. Use appropriate filters and limits.
Query Explain
Section titled “Query Explain”explain(options?) returns Admin SDK Query Explain diagnostics plus optionally the matching
documents mapped through the builder’s result shape:
const plan = await userRepo.query().where('status', '==', 'active').explain();console.log(plan.metrics.planSummary.indexesUsed);// plan.documents === null (plan-only; query was not executed)
const analyzed = await userRepo .query() .where('status', '==', 'active') .explain({ analyze: true });// analyzed.documents: FirestoreDocument<User>[] (possibly empty []); metrics.executionStats non-nulldocuments is null for plan-only requests and [] when analyze ran and matched nothing —
do not collapse the two. Collection-group builders inherit explain(); vector queries expose it
after findNearest() (see the vector-search guide).
⚠️ The Firestore emulator does not return explain metrics today — the Admin SDK throws
Error: No explain results. Real plan/execution stats require production Firestore.
explainStream(options?) streams the same diagnostics as separate chunks on Core builders
(collection and collection-group). There is no vector equivalent. Document chunks are mapped
through the builder; metrics arrive separately (and the emulator typically emits documents with
no metrics chunk):
for await (const chunk of userRepo .query() .where('status', '==', 'active') .explainStream({ analyze: true })) { if (chunk.document) { await processUser(chunk.document); } if (chunk.metrics) { console.log(chunk.metrics.planSummary.indexesUsed); }}explainStream locally rejects limitToLast (use explain() instead). Do not treat an emulator
stream as proof of production diagnostics.
Real-time subscriptions
Section titled “Real-time subscriptions”onSnapshot(callback, onError?) subscribes to live query results. It resolves to an unsubscribe
function — call it to stop listening. The callback receives the current set of matching documents on
every change.
onSnapshot() cannot be combined with select(): Firestore does not allow a real-time listener on
a field-masked query, so the builder throws locally with a clear error. Listen without select()
and project inside your callback, or use get() / stream() for a one-time projected read.
// Subscribe to query resultsconst unsubscribe = await orderRepo .query() .where('status', '==', 'active') .onSnapshot( orders => { console.log(`Active orders: ${orders.length}`); updateDashboard(orders); }, error => { console.error('Snapshot error:', error); }, );
// Stop listening when doneunsubscribe();Cost warning: Real-time listeners charge you for every document that matches your query, plus additional reads when documents change. Use narrow filters and consider polling for less critical data.