Chapter 06 · Indexes: Automatic/Composite/Collection-Group/Vector, Exemptions, and Index Cost

Composite Indexes: Ordering, Equality / Range Fields, Collection vs Collection-Group Scope, and Error-Driven Creation

Derive Firestore composite indexes from exact query contracts, field ordering, collection/collection-group scope, missing-index failures, and rollback-safe configuration.

Intermediate125–150 minutesComposite indexes + query ownershipFirebase JS 12.19.0 · CLI 15.30.0Last reviewed: September 2026

Learning outcomes

AtlasMart now has three concrete query families: catalog category + price, tag membership + rating order, and seller order history across user subcollections. The mistake is to translate each UI screen into an arbitrary pile of index definitions. A manual index should be a direct consequence of filter/order semantics and query scope.

01

Derive composite field order from equality, range, and explicit ordering semantics instead of copying console suggestions blindly.

02

Distinguish collection-scope from collection-group-scope manual indexes and test the exact owning query for each.

03

Explain why equality-field direction often does not change equality behavior while sort/range direction and field order do matter.

04

Use production missing-index errors as a diagnostic input, then convert approved indexes back into versioned configuration-as-code.

05

Plan deletion/rollback so an index is never removed before dependency tests and readiness evidence exist.

Execution and safety note

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.

Chapter 06 reproducibility baseline · reviewed 15 September 2026

AtlasMart continues the same environment used in Chapters 01–05: project ID demo-atlasmart-firestore, Standard edition / Native mode / (default) database for the main 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 (with @google-cloud/firestore 9.1.0), @firebase/rules-unit-testing 5.0.2, and Node.js 22+. The Enterprise-only exercise in Lesson 4 uses an isolated emulator configuration rather than mutating the Standard lab.

Evidence boundary for this generated lesson

The authoring environment did not execute Firebase emulators or a billed Firestore project. Local commands below are deterministic exercises to run on your machine; any shown output is labeled as an expected invariant, not captured benchmark evidence. The Firestore emulator does not reproduce production composite-index enforcement, managed index build/backfill state, billing, or production Query Explain metrics. Those observations are separated into optional managed-project checks.

1. A composite index is an ordered access path

A manual/composite index is not “a set of fields.” It is an ordered list of fields, each with an index mode, bound to a collection ID and query scope. Firestore uses equality predicates to narrow a prefix and then uses the query's range/order fields to determine how it scans the ordered entries. Changing field order can change how much of the index must be scanned even when two definitions contain the same names.

AtlasMart query contract Scope Manual index shape Why
category == camera + orderBy(price ASC) catalogItems collection category ASC, price ASC Equality prefix then ordered price scan.
tags array-contains outdoor + orderBy(rating DESC) catalogItems collection tags CONTAINS, rating DESC Array membership combined with a separate sort generally needs a manual index.
collectionGroup("orders"), tenantId == seller-a, orderBy(createdAt DESC) All orders subcollections tenantId ASC, createdAt DESC with COLLECTION_GROUP scope A collection-scope index cannot satisfy a collection-group query.

For equality-only fields in a manual index, Firestore still requires an ascending or descending mode for scalar fields, but that choice does not change equality semantics. For fields used in ordering, the direction is part of the query contract. Standard also appends document-name ordering to many indexes as the final tie-break dimension; when you need a different document-name direction, define it explicitly.

2. Capture only the query-owned indexes

firestore.indexes.json · Chapter 05 query-owned indexes
{  "indexes": [    {      "collectionGroup": "catalogItems",      "queryScope": "COLLECTION",      "fields": [        { "fieldPath": "category", "order": "ASCENDING" },        { "fieldPath": "price", "order": "ASCENDING" }      ]    },    {      "collectionGroup": "orders",      "queryScope": "COLLECTION_GROUP",      "fields": [        { "fieldPath": "tenantId", "order": "ASCENDING" },        { "fieldPath": "createdAt", "order": "DESCENDING" }      ]    },    {      "collectionGroup": "catalogItems",      "queryScope": "COLLECTION",      "fields": [        { "fieldPath": "tags", "arrayConfig": "CONTAINS" },        { "fieldPath": "rating", "order": "DESCENDING" }      ]    }  ],  "fieldOverrides": [    { "collectionGroup": "catalogItems", "fieldPath": "description", "indexes": [] },    { "collectionGroup": "catalogItems", "fieldPath": "attributes", "indexes": [] },    { "collectionGroup": "catalogEvents", "fieldPath": "expiresAt", "ttl": true, "indexes": [] }  ]}

The third index is intentionally marked experimental for this chapter. The current Chapter 05 contract does not require “tag + rating descending” in its core acceptance suite, so retaining it without a screen/query owner would be unjustified write/storage overhead. Lesson 5 will remove it after proving no contract depends on it.

One array field per manual index.

Current Standard manual-index rules allow at most one array field in an index. Do not attempt to encode arbitrary multi-array relational combinations into a composite. If the access pattern fundamentally needs that shape, remodel or precompute a queryable view.

3. Error-driven creation is a hint, not a governance process

In managed Standard Firestore, a valid compound query that lacks an index typically fails with a FAILED_PRECONDITION-style response and a console link populated with the suggested index. Vector-index failures instead can provide a Google Cloud CLI command. The error proves one thing: this query cannot run with the current managed index state. It does not prove the suggested index is desirable, non-duplicate, affordable, correctly scoped for your architecture, or safe to keep forever.

A disciplined workflow is: reproduce the owning query in a test, inspect the suggested fields/scope, compare against the repository manifest, approve or remodel, commit the definition, deploy to a disposable/controlled environment, wait for build readiness, rerun the exact query, then retain evidence linking query → index → test.

Emulator boundary.

Do not write a test that expects the local Firestore emulator to fail because a production composite index is missing. The mandatory emulator suite validates query semantics and Rules. Missing-index enforcement, build/backfill status, and error-link behavior are managed-service checks.

4. Test scope explicitly: collection is not collection group

query-contracts.mjs · collection and collection-group owners
import assert from "node:assert/strict";import { initializeApp } from "firebase/app";import { collection, collectionGroup, connectFirestoreEmulator, getDocs, getFirestore, orderBy, query, where } from "firebase/firestore";const app=initializeApp({projectId:"demo-atlasmart-firestore",apiKey:"demo",appId:"demo"});const db=getFirestore(app); connectFirestoreEmulator(db,"127.0.0.1",8080);const ids=async q => (await getDocs(q)).docs.map(d=>d.id);const cameras=query(collection(db,"catalogItems"),where("category","==","camera"),orderBy("price","asc"));assert.deepEqual(await ids(cameras),["p-1001","p-1002","p-1005"]);const sellerOrders=query(collectionGroup(db,"orders"),where("tenantId","==","seller-a"),orderBy("createdAt","desc"));assert.deepEqual((await getDocs(sellerOrders)).docs.map(d=>d.ref.path),[  "users/u-bob/orders/o-1003",  "users/u-alice/orders/o-1001"]);console.log("query owners passed");

The test owns semantics, not performance. In production Standard, the first query is tied to a collection-scope manual index and the second to a collection-group manual index. If you accidentally deploy the second definition with COLLECTION scope, the collection-group query remains unsupported even though the field names look correct.

5. Field ordering follows selectivity and sort semantics

For a query with equality filters plus ranges/order, equalities normally belong first, followed by the first range/order fields in the order that best reduces the scan while still satisfying query ordering rules. When multiple range/inequality fields exist, current guidance is to order the index after equality fields by the most selective range constraints where compatible with the required sort. Query Explain can reveal indexesUsed, index entries scanned, documents scanned, and execution/billing details on the managed service.

Do not hard-code folklore such as “always put the most selective field first” without respecting the query's order requirements. The query and index must agree; an apparently selective index that cannot serve the required order is not a substitute.

optional managed check · Node server client
// Run only against an isolated managed project/database with IAM credentials.const q = db.collection("catalogItems")  .where("category", "==", "camera")  .orderBy("price", "asc");const explain = await q.explain({ analyze: "true" });console.dir(explain.metrics.planSummary, { depth: null });console.dir(explain.metrics.executionStats, { depth: null });// Record actual values; never paste tutorial numbers as your benchmark.

6. Failure injection: wrong scope, duplicate structure, premature deletion

Three controlled mistakes teach more than a happy path:

  1. Change the orders index scope to COLLECTION and document why it cannot own the collection-group query.
  2. Add a second category + price definition with no distinct query owner; your manifest audit should flag it as duplicate/redundant even before deployment.
  3. Simulate removing category + price from the repository and run the query-owner tests. The local query still succeeds because the emulator does not enforce the managed index, which is itself the lesson: CI needs a configuration test as well as an emulator query test.
index-contract-test.mjs · configuration assertion
import assert from "node:assert/strict";import fs from "node:fs";const cfg=JSON.parse(fs.readFileSync("firestore.indexes.json","utf8"));const canon=x=>JSON.stringify(x);const required={collectionGroup:"catalogItems",queryScope:"COLLECTION",fields:[  {fieldPath:"category",order:"ASCENDING"},{fieldPath:"price",order:"ASCENDING"}]};assert.ok(cfg.indexes.some(x=>canon(x)===canon(required)),"category+price index contract missing");const cg=cfg.indexes.find(x=>x.collectionGroup==="orders"&&x.queryScope==="COLLECTION_GROUP");assert.ok(cg,"orders collection-group index missing");console.log("index configuration contracts passed");

Hands-on lab and rollback plan

  1. Seed the Chapter 05/06 fixtures and run the collection and collection-group query tests.
  2. Save the three-index experimental manifest and run the configuration contract test.
  3. Document each manual index with owner, query signature, scope, reason, expected delete impact, and rollback definition.
  4. Optional managed project: deploy with firebase deploy --only firestore:indexes, observe build state, rerun owning queries only after the relevant indexes are ready, and capture Query Explain evidence where useful.
  5. Never delete an index as the first experiment. First remove/disable the query dependency or prove no owner remains; keep the exact JSON definition so rollback is re-add → wait ready → re-enable traffic.

Production judgment

An index should be treated like an API dependency with ownership and tests. Index count alone is not the objective; the objective is the smallest set of structures that support authorized, stable, cost-justified query contracts. Build time/backfill can be operationally significant, so migrations must account for readiness instead of assuming deploy is instantaneous.

Knowledge check

  1. Why can the same two field names define two materially different indexes?
  2. Why does a collection-group query need its own scope?
  3. What should you do with a console missing-index link?
  4. Why do you need a configuration contract test if emulator query tests pass?
  5. What is the safe index-removal rollback sequence?
Review the answers

1. Field order, direction/mode, query scope, edition, and database are part of the definition.

2. It scans collections with the same ID across different parent paths, which is a different query surface from one concrete collection.

3. Treat it as a diagnostic suggestion: map it to a tested query, review scope/order/cost, commit configuration-as-code, deploy deliberately, and verify readiness.

4. The emulator does not enforce production composite indexes, so query success cannot detect that a required managed index definition was deleted from source control.

5. Preserve the old definition, prove no dependency or gate traffic, delete, monitor; if regression appears, re-add the exact definition, wait until ready, then restore traffic.

Summary and next step

Composite indexes are ordered, scoped query dependencies, not an error-message scavenger hunt. Lesson 3 turns to the opposite decision: which fields should deliberately not be indexed because their size, cardinality, sequential nature, or lifecycle makes the default expensive.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.