Troubleshooting
Common errors, gotchas, and their fixes when working with the repository, query builder, transactions, and subcollections.
1. Composite Index Required
Section titled “1. Composite Index Required”Error: Query requires a Firestore index
The library surfaces this as a FirestoreIndexError (see
Error handling), whose message includes the console URL
Firestore generated for the missing index.
Solution: Click the URL in the error message to create the index, then wait 1–2 minutes for it to build before retrying the query.
2. Hooks in Transactions
Section titled “2. Hooks in Transactions”Hooks behave differently inside transactions, and this trips people up. The second argument passed
to your runInTransaction callback is a transaction-scoped repository — you must use that
repo, not the outer one, for every write helper inside the callback.
// beforeCreate DOES fire on the tx-scoped repo; after* hooks do NOT fire in transactionsawait repo.runInTransaction(async (tx, repo) => { await repo.createInTransaction(tx, data); // beforeCreate ran; afterCreate will NOT run here});The distinction:
before*hooks (beforeCreate,beforeUpdate,beforeDelete) do fire on the tx-scopedrepo’s transaction helpers (createInTransaction,updateInTransaction/patchInTransaction,deleteInTransaction).after*hooks (afterCreate,afterUpdate,afterDelete) do not fire inside transactions. The transaction hasn’t committed yet while the callback runs, so post-commit side effects belong outside it.
Solution: Return what you need from the transaction and run side effects after it resolves:
const result = await repo.runInTransaction(async (tx, repo) => { const { id } = await repo.createInTransaction(tx, data); // A transaction cannot read a document back after writing it, so `createInTransaction` // resolves to `{ id }` only. Return whatever the side effect needs alongside it. return { id, email: data.email };});
// Now run side effects (the transaction has committed)await sendEmail(result.email);Note:
query().update()andquery().delete()do run the bulk hooks (beforeBulkUpdate/afterBulkUpdate,beforeBulkDelete/afterBulkDelete) — they do not run the per-documentbefore/afterUpdate/before/afterDeletehooks. Inside transactions, onlybefore*hooks run (via the tx-scoped helpers above). See Lifecycle hooks and Transactions.
3. “in” Query Limit (30 values)
Section titled “3. “in” Query Limit (30 values)”// Firestore allows at most 30 values in an `in` / `not-in` / `array-contains-any` filterawait userRepo .query() .whereId('in', arrayOf50Ids) // ERROR: too many values .get();For id lookups, prefer getMany(ids) — no 30-value cap, results in input order, and missing ids
are marked with null in position instead of being silently dropped:
const rows = await userRepo.getMany(arrayOf50Ids);// rows[i] is FirestoreDocument | null — aligned with arrayOf50Ids[i]For non-id in filters, chunk into batches of 30 or fewer:
const chunks = chunkArray(statusList, 30);const results = [];
for (const chunk of chunks) { const users = await userRepo.query().where('status', 'in', chunk).get(); results.push(...users);}4. Query Ordering Requires Index
Section titled “4. Query Ordering Requires Index”// This requires a composite indexawait repo .query() .where('status', '==', 'active') .orderBy('createdAt', 'desc') // Different field from the where clause .get();Solution: Create the composite index via the link in the error message, or order by the same field you filter on. See Queries for the full query-builder surface.
5. Subcollection Parent ID Lost
Section titled “5. Subcollection Parent ID Lost”When querying a subcollection, the parent document ID isn’t automatically included in the returned documents.
Solution: Read it from the repository with getParentId():
import { z } from 'zod';
const orderSchema = z.object({ total: z.number() });const ordersRepo = userRepo.subcollection('user-123', 'orders', orderSchema);const parentId = ordersRepo.getParentId(); // 'user-123'getParentId() returns the parent ID for a subcollection repository, or null for a top-level
repository. See Subcollections for more.
6. Dot Notation in Transactions
Section titled “6. Dot Notation in Transactions”Issue: Your transaction logic needs the current document state before it can compute an update.
Solution: Read inside the transaction with getInTransaction() only when your business rules
actually need the prior state, then apply a dot-notation update:
await repo.runInTransaction(async (tx, repo) => { const doc = await repo.getInTransaction(tx, 'doc-123'); if (!doc) throw new Error('Document not found'); await repo.updateInTransaction(tx, 'doc-123', { 'nested.field': 'value', });});See Dot-notation nested updates and Transactions for details.
7. Composite Filter Limits (OR queries)
Section titled “7. Composite Filter Limits (OR queries)”Firestore normalizes a composite filter into a disjunctive form and enforces three limits on the
server, so they arrive as INVALID_ARGUMENT rather than a local error:
// Too many disjunctions after normalization. Result had 31 disjunctions which is// more than the maximum of 30await postRepo .query() .whereFilter(f => f.or(...thirtyOneConditions)) .get();
// 'NOT_IN' cannot be used in the same query with 'IN', 'ARRAY_CONTAINS_ANY' or 'OR'await postRepo .query() .whereFilter(f => f.or(f.where('status', 'not-in', ['draft']), f.where('pinned', '==', true))) .get();
// Only a single 'NOT_EQUAL' … filter allowed per queryawait postRepo .query() .whereFilter(f => f.or(f.where('status', '!=', 'draft'), f.where('visibility', '!=', 'public'))) .get();Watch for the multiplication: an in with N values inside an OR branch expands to N disjunctions,
so a few branches can cross the cap of 30 quickly.
Solution: Chunk and merge, the same pattern as the in limit above — run one query per group of
branches and dedupe by id:
const groups = chunkArray(conditions, 10);const byId = new Map<string, Post>();
for (const group of groups) { const rows = await postRepo .query() .whereFilter(f => f.or(...group.map(c => c(f)))) .get(); rows.forEach(row => byId.set(row.id, row));}
const posts = [...byId.values()];Because Firestore normalizes the filter and evaluates each disjunct, a composite query can also
require composite index coverage for more than one branch — one whereFilter() may surface several
successive FirestoreIndexErrors. Create the index from each error’s link until every branch is
covered.
Note: the ORM deliberately does not pre-check these limits locally. A local copy of the server’s
normalization rules would risk rejecting a query Firestore would happily accept, would drift as the
backend changes, and could not see clauses added outside the callback anyway. The one thing the ORM
does reject locally is an empty f.or() / f.and() group, because Firestore silently drops
it and matches every document instead of failing.
8. OR Query Returns Fewer Rows Than One of Its Branches
Section titled “8. OR Query Returns Fewer Rows Than One of Its Branches”Issue: a whereFilter(f => f.or(...)) query returns fewer documents than one of its own
disjuncts returns on its own.
Cause: an inequality (<, <=, >, >=, !=) anywhere in the filter tree makes Firestore
add an implicit orderBy on that field, and a document missing an ordered field cannot appear in
the results — even if it matched a completely different branch.
// Three documents have kind: 'x'; two of them have no `score` field.await postRepo.query().where('kind', '==', 'x').get(); // → 3 documentsawait postRepo .query() .whereFilter(f => f.or(f.where('score', '>', 5), f.where('kind', '==', 'x'))) .get(); // → 1 documentcount() reports the same reduced number, and the update() / delete() terminals silently skip
the excluded documents.
Solution: keep inequalities out of or() branches. Use equality, in, or array-contains /
array-contains-any inside a disjunction; f.whereId(...) with a comparison operator is also safe
(Firestore skips documentId() when adding implicit orders, and a document name always exists).
Otherwise, guarantee the field is always written, or run the branches as separate queries and merge
by id.
9. Collection-Group Query Needs Its Own Index (works locally, fails deployed)
Section titled “9. Collection-Group Query Needs Its Own Index (works locally, fails deployed)”Issue: a collectionGroup().query().where('status', '==', 'published') passes against the
emulator, then fails in production with FirestoreIndexError — even though the same where(...) on
the collection itself needs no index at all.
Cause: Firestore’s automatic single-field indexes are created with collection scope. A collection-group query reads a collection-group-scoped index, which is never created automatically. So a group query needs an explicitly created index even for one field, and the emulator does not enforce index requirements at all.
Solution: create a collection-group-scoped index for each field you filter or order on. The
error’s console URL pre-fills the right one; in firestore.indexes.json the scope is the
queryScope field, and which section it goes in depends on how many fields it covers.
A single-field group index is a fieldOverrides entry — indexes entries are composite and
require two or more fields:
{ "fieldOverrides": [ { "collectionGroup": "posts", "fieldPath": "status", "indexes": [{ "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" }] } ]}A multi-field group index is an indexes entry with queryScope set:
{ "indexes": [ { "collectionGroup": "posts", "queryScope": "COLLECTION_GROUP", "fields": [ { "fieldPath": "status", "order": "ASCENDING" }, { "fieldPath": "createdAt", "order": "DESCENDING" } ] } ]}Note that a fieldOverrides entry replaces automatic indexing for that field, so list every
index you still want — adding only the COLLECTION_GROUP entry drops the automatic
collection-scoped ones and can break existing single-collection queries on the same field.
Because the emulator never raises this, treat a green local run as no evidence that a deployed group query is indexed.
10. Firestore does not support descending key scans
Section titled “10. Firestore does not support descending key scans”Issue: orderById('desc') (or orderByPath('desc') on a collection group) fails with
FAILED_PRECONDITION: Firestore does not support descending key scans.
Cause: Firestore will not run a query whose only ordering is a descending document-name scan. It is not specific to collection groups — a plain collection behaves identically.
Solution: add any equality where(...) clause, or a preceding orderBy(...) on a real field.
Ascending document-name ordering is unrestricted.
await userRepo.query().orderById('desc').get(); // ✗ FAILED_PRECONDITIONawait userRepo.query().where('status', '==', 'active').orderById('desc').get(); // ✓await userRepo.query().orderBy('createdAt', 'desc').orderById('desc').get(); // ✓11. Two Collection-Group Rows Have the Same id
Section titled “11. Two Collection-Group Rows Have the Same id”Issue: de-duplicating or keying collection-group results by id collapses distinct documents.
Cause: document ids are unique only within one collection. users/u1/posts/p1 and
users/u2/posts/p1 are two different documents that both report id: 'p1'.
Solution: key on path, which every collection-group result carries. parentPath tells you
which collection (and therefore which parent) a row came from.
const rows = await postGroup.query().where('status', '==', 'draft').get();const byPath = new Map(rows.map(row => [row.path, row])); // ✓ uniqueconst byId = new Map(rows.map(row => [row.id, row])); // ✗ silently collapses