Skip to content

Queries

Build type-safe reads, aggregations, streams, and real-time subscriptions with the fluent 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 synthetic id is not a queryable field path; use whereId(...) instead.
  • whereId(op, value) — query by document name. Scalar operators take a string; in/not-in take a readonly 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 before paginate()).
  • orderById(dir = 'asc') — order by document name.
  • limit(n) — cap the number of documents returned (last-wins with limitToLast).
  • limitToLast(n) — last N of an ordered result set (requires orderBy; not combinable with stream / opaque paginate / offsetPaginate).
  • startAt / startAfter / endAt / endBefore — typed inclusive/exclusive cursor bounds (DocumentSnapshot or field values).
  • offset(n) — skip the first N matches (0 allowed; not combinable with opaque paginate / 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.

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();

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 successive FirestoreIndexErrors — 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 gone

count() 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 published
await 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 published
await 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.

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();

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 group
rows[0].path; // 'users/u2/posts/p1' ← the identity that is actually unique
rows[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.

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.

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();

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.

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();

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 pagination
const { items, nextCursor, hasMore } = await userRepo
.query()
.orderBy('createdAt', 'desc')
.paginate(20);
// Next page
const 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);

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 field
const 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 run server-side and are billed at a reduced rate compared to reading every matching document.

// Sum a numeric field across matching documents
const totalRevenue = await orderRepo.query().where('status', '==', 'completed').sum('total');
// Average a numeric field — returns null (not 0) when there are no numeric values
const avgRating = await reviewRepo.query().where('productId', '==', 'prod-123').average('rating');
// Count matching documents
const 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() clauses
const totalUsers = await userRepo.query().where('status', '==', 'active').collectionCount();
// Existence check
const hasOrders = await orderRepo.query().where('userId', '==', 'user-123').exists();
// Distinct values for a field — semantic equality; drops undefined but preserves stored null
const 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.

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).

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 hooks beforeBulkUpdate (which may mutate the update payload before validation) and afterBulkUpdate (receiving { ids } of the written documents); query().delete() runs beforeBulkDelete and afterBulkDelete (receiving { ids, documents }). The per-document before/afterUpdate and before/afterDelete hooks 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 written
const updatedCount = await orderRepo
.query()
.where('status', '==', 'pending')
.update({ status: 'processing' });
// Delete all matching documents; returns the number of documents matched
const deletedCount = await userRepo.query().where('lastLogin', '<', oneYearAgo).delete();
// Delete matching documents with multiple filters
await orderRepo
.query()
.where('status', '==', 'cancelled')
.where('createdAt', '<', sixMonthsAgo)
.delete();

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 memory
for await (const user of userRepo.query().stream()) {
await sendEmail(user.email);
console.log(`Processed user ${user.id}`);
}
// Stream with filters
for 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.

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-null

documents 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.

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 results
const 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 done
unsubscribe();

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.