Chapter 05 · Queries: Filters, Ordering, Limits, Cursors, Collection Groups, and Query Constraints
Collection Group Queries Across Subcollections and Their Modeling / Security Implications
Query Firestore collection groups safely across subcollections while aligning data modeling, index scope, Security Rules, ownership, and lifecycle.
Learning outcomes
AtlasMart stores each user's orders under
users/{uid}/orders/{orderId}. A support dashboard
now needs “all paid orders” across users. That access pattern is
exactly what a collection-group query can express, but the
collection-group name, index scope, duplicated ownership fields,
and Rules pattern become part of the design.
Explain collection-group scope as all collections with the same collection ID, regardless of parent path.
Distinguish a collection-scoped index from a collection-group-scoped index.
Write Security Rules v2 patterns that can authorize a collection-group query and explain why Rules still are not filters.
Denormalize ownership/tenant fields into child documents when that makes the query and authorization predicate provable.
Account for lifecycle: deleting a parent document does not automatically delete its subcollection documents.
Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.
AtlasMart keeps the same local course environment used in
Chapters 01–04: project ID
demo-atlasmart-firestore, Standard edition /
Native mode / (default) database for
Core-operation labs, Firestore emulator
127.0.0.1:8080, Authentication emulator
127.0.0.1:9099, Emulator UI
127.0.0.1:4000, Firebase CLI
15.30.0, Firebase JavaScript SDK
12.19.0, Firebase Admin Node SDK
14.4.0 (which currently carries
@google-cloud/firestore 9.1.0),
@firebase/rules-unit-testing 5.0.2,
and Node.js 22+. No moving latest tag is required
by the lab.
The generation environment does not run the Firebase emulators, so code and commands are checked against current official Firebase/Google Cloud documentation but the output shown in this lesson is an expected invariant, not fabricated captured output. In particular, the Firestore emulator does not enforce production composite indexes in the same way as the managed service. Operator validation, deterministic result ordering, cursor contracts, and Security Rules tests belong in the mandatory local lab; a missing-composite-index error or Query Explain evidence must be treated as a separately labeled production verification.
1. Collection group means same collection ID across the hierarchy
collectionGroup(db, "orders") addresses every
orders collection in the database, including
users/u-alice/orders and
users/u-bob/orders. It does not mean “join parent
users with child orders.” The returned documents are still order
documents. If the query needs ownerUid or
tenantId for filtering/authorization, those fields
should normally exist on each order document rather than being
assumed from an inaccessible parent join.
import { collectionGroup, getDocs, orderBy, query, where } from "firebase/firestore";const paid = query( collectionGroup(db, "orders"), where("ownerUid", "==", currentUid), where("status", "==", "paid"), orderBy("createdAt", "desc"));const snap = await getDocs(paid);console.log(snap.docs.map(d => ({ path:d.ref.path, ...d.data() })));
2. Query scope and index scope must agree
Automatic collection indexes are not the same as collection-group indexes. A filtered or ordered collection-group query needs an index with collection-group scope corresponding to its fields/order. The emulator can execute many query shapes without reproducing production composite-index enforcement, so a local success does not prove the managed index exists.
| Question | Single collection | Collection group |
|---|---|---|
| Scope | One concrete collection path | All collections sharing an ID |
| Typical index scope | Collection | Collection group |
| Rules match | Concrete/recursive path as designed | Rules v2 recursive wildcard pattern is typically required |
| Parent data | Known from query path | Not automatically joined into each result |
| Lifecycle | Parent/document policy local to path | Must account for orphaned descendants across the whole group |
3. Rules must prove the whole candidate set
For collection-group queries, use
rules_version = '2' and a recursive wildcard
pattern that matches the target collection. A robust AtlasMart
rule can authorize an order when the authenticated UID equals
the duplicated ownerUid. The client query must
include constraints compatible with that proof. A broad “all
orders” query does not become safe merely because each returned
document could be checked individually.
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { match /{path=**}/orders/{orderId} { allow get: if request.auth != null && request.auth.uid == resource.data.ownerUid; allow list: if request.auth != null && request.auth.uid == resource.data.ownerUid; } }}
A query for every order cannot rely on the rule to remove Bob's documents from Alice's response. The query must constrain the possible result set so the Rules engine can prove it is safe. Chapter 12–13 will deepen the exact rule/query proof model; here the modeling consequence is the key point.
4. Deliberately wrong approach: infer owner from the path after reading
A developer issues an unrestricted collection-group query, then
parses users/u-alice from each path in JavaScript
and discards other users. This is both an authorization error
and unnecessary read amplification. The corrected model
duplicates ownerUid (and, where appropriate,
tenantId) into the order document, constrains the
query on that field, and validates those fields on writes so
clients cannot forge ownership.
Duplication creates a maintenance obligation: the copied field should be immutable for the order's lifetime or changed only through a trusted, tested migration. Chapter 03's denormalization rule still applies—duplicate deliberately, with a consistency policy.
5. Hands-on lab: group query plus rule contract
mkdir atlasmart-firestore-query-labcd atlasmart-firestore-query-labnpm init -ynpm install firebase@12.19.0 firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0 @firebase/rules-unit-testing@5.0.2node --versionnpx firebase --versionnpx firebase emulators:start --only firestore,auth --project demo-atlasmart-firestore
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";import { initializeApp, applicationDefault } from "firebase-admin/app";import { getFirestore, Timestamp } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();const catalog = { "p-1001": { category:"camera", price:99, rating:4.8, tags:["outdoor","camera"], published:true, rank:10, sellerId:"seller-a" }, "p-1002": { category:"camera", price:99, rating:4.5, tags:["studio","camera"], published:true, rank:20, sellerId:"seller-a" }, "p-1003": { category:"sensor", price:49, rating:4.8, tags:["outdoor","iot"], published:true, sellerId:"seller-b" }, "p-1004": { category:"sensor", price:149, rating:4.1, tags:["industrial","iot"], published:false, rank:null, sellerId:"seller-b" }, "p-1005": { category:"camera", price:199, rating:4.9, tags:["outdoor","camera"], published:true, rank:30, sellerId:"seller-c" }, "p-1006": { category:"gateway", price:99, rating:4.2, tags:["iot"], published:true, rank:40, sellerId:"seller-c" }};for (const [id, data] of Object.entries(catalog)) { await db.collection("catalogItems").doc(id).set({ ...data, updatedAt: Timestamp.fromMillis(1760000000000) });}const orders = [ ["u-alice","o-1001",{ ownerUid:"u-alice", tenantId:"seller-a", status:"paid", total:198, createdAt:Timestamp.fromMillis(1760000100000) }], ["u-alice","o-1002",{ ownerUid:"u-alice", tenantId:"seller-b", status:"shipped", total:49, createdAt:Timestamp.fromMillis(1760000200000) }], ["u-bob", "o-1003",{ ownerUid:"u-bob", tenantId:"seller-a", status:"paid", total:99, createdAt:Timestamp.fromMillis(1760000300000) }]];for (const [uid,id,data] of orders) await db.doc(`users/${uid}/orders/${id}`).set(data);console.log("seeded", Object.keys(catalog).length, "catalog items and", orders.length, "orders");
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";import assert from "node:assert/strict";import { initializeApp } from "firebase-admin/app";import { getFirestore } from "firebase-admin/firestore";initializeApp({projectId:"demo-atlasmart-firestore"});const db=getFirestore();const snap=await db.collectionGroup("orders") .where("tenantId","==","seller-a") .orderBy("createdAt","asc").get();assert.deepEqual(snap.docs.map(d=>d.ref.path),[ "users/u-alice/orders/o-1001", "users/u-bob/orders/o-1003"]);console.log("collection-group server contract passed");
Then use @firebase/rules-unit-testing@5.0.2 to
create an authenticated Alice test context and assert that an
owner-constrained group query succeeds while a broad group query
or Bob-targeting query is denied. Keep the Admin seed outside
Rules evaluation; Admin/server SDK access is privileged and
proves database state, not client authorization.
Delete users/u-alice in the emulator without
recursively deleting its descendants. Verify that
users/u-alice/orders/o-1001 can still exist. That
observation is why account-erasure workflows must
enumerate/verify nested data rather than assuming parent
deletion cascades.
Production judgment
Collection groups are powerful when the same child concept must
be queried globally, but that naming decision becomes an API
surface. Reusing a generic subcollection ID such as
events for unrelated semantics can accidentally
create one enormous group with awkward Rules and index
requirements. Name subcollections according to durable meaning
and plan deletion/retention across descendants.
Knowledge check
-
What does
collectionGroup(db,"orders")include? - Why copy
ownerUidinto an order document? - Why can an emulator-successful group query still fail in production?
- Why is a broad collection-group query followed by client filtering unsafe?
- What lifecycle fact must deletion code remember?
Review the answers
1. All collections named
orders anywhere in the database hierarchy, not
parent documents or an implicit join.
2. It can make the collection-group query and Rules predicate explicit/provable without relying on a server-side parent join.
3. The emulator does not reproduce all production composite-index enforcement; production may require a collection-group index.
4. Rules are not filters; authorization must be provable from the query candidate set.
5. Deleting a parent document does not automatically delete its subcollection documents.
Summary and next step
Collection-group scope ties modeling, indexes, and authorization together. Lesson 4 generalizes that idea: when a desired query is unsupported, ambiguous because of missing fields, or dependent on an absent production index, change the access pattern or data shape rather than hiding the mismatch.
Authoritative references
- Perform simple and compound queries in Cloud Firestore — Current Core filter/operator behavior and Standard-edition limitations.
- Order and limit data with Cloud Firestore — Ordering, limits, field-existence effect, and limit semantics.
- Paginate data with query cursors — Cursor boundaries and document-snapshot pagination.
- Index types in Cloud Firestore — Index scopes, implicit document-name ordering, and query/index execution model.
- Securely query data — Why Security Rules are not filters and collection-group rule requirements.
- Firebase release notes — Current SDK/tooling versions used for the pinned lab baseline.