Ensure Firestore indexes are created for each query in the codebase.
This ESLint plugin helps you catch missing Firestore indexes at development time. It analyzes your code for Firestore queries and checks them against a configuration file (typically indexes.json) to ensure all required indexes are defined.
Features:
- Automatically detects Firestore queries using
.collection(),.collectionGroup(), or custom collection reference functions - Supports custom collection reference functions (e.g.,
templateCollRef(),passportCollRef()) - Ignores pagination methods (
limit,offset,startAt, etc.) that don't affect index requirements - Validates queries with multiple
where()clauses and/ororderBy()operations - Checks for array-contains operations that require special index configuration
This plugin requires:
- ESLint: ≥8.0.0
- Node.js: ≥14.0.0
npm install --save-dev eslint-firestore-indexes- Create an
indexes.jsonfile in your project root:
{
"indexes": [
{
"collectionGroup": "users",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "age", "order": "ASCENDING" },
{ "fieldPath": "name", "order": "ASCENDING" }
]
}
],
"fieldOverrides": []
}- Configure ESLint in your
.eslintrc.js:
module.exports = {
plugins: ['eslint-firestore-indexes'],
rules: {
'eslint-firestore-indexes/firestore-indexes': [
'error',
{
indexesPath: 'indexes.json', // Path to your indexes file
},
],
},
};Or for ESLint 9+ flat config (eslint.config.js):
import firestoreIndexes from 'eslint-firestore-indexes';
export default [
{
plugins: {
'firestore-indexes': firestoreIndexes,
},
rules: {
'firestore-indexes/firestore-indexes': [
'error',
{
indexesPath: 'indexes.json', // Path to your indexes file
},
],
},
},
];- Run ESLint on your code:
npx eslint your-code.jsThis rule detects Firestore queries that require composite indexes and validates them against your indexes.json file.
// Multiple orderBy clauses require composite index
firestore.collection('users')
.orderBy('lastName', 'asc')
.orderBy('firstName', 'asc')
.get();
// Inequality + orderBy on different fields require composite index
firestore.collection('products')
.where('price', '>', 100)
.orderBy('rating', 'desc')
.get();// Single field query - no index needed
firestore.collection('users')
.where('age', '>', 18)
.get();
// Two equality filters - uses index merging (no composite index needed)
firestore.collection('users')
.where('email', '==', 'test@example.com')
.where('status', '==', 'active')
.get();
// Equality filter + orderBy - uses index merging (no composite index needed)
firestore.collection('orders')
.where('customerId', '==', '123')
.orderBy('orderDate', 'desc')
.get();
// Index exists in indexes.json (composite index)
firestore.collection('users')
.where('age', '>', 18)
.where('name', '==', 'John')
.get();The rule accepts an options object with the following properties:
indexesPath(string): Path to the indexes configuration file. Default:'indexes.json'
The indexes file should follow the Firebase indexes export format:
{
"indexes": [
{
"collectionGroup": "collectionName",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "fieldName",
"order": "ASCENDING"
}
]
}
],
"fieldOverrides": []
}Firestore requires composite indexes for queries that:
- Use multiple
where()clauses on different fields - Combine
where()withorderBy()on different fields - Use multiple
orderBy()clauses - Use array-contains queries with other filters
Single field queries and simple equality checks typically don't require custom indexes.
Version 1.1.0+: This plugin now supports Firestore's Index Merging feature! Queries that can use index merging will not trigger errors, even if no composite index is defined.
Firestore can satisfy some queries in two ways:
1. Composite Indexes (recommended for frequently-used queries):
- Explicit indexes defined in
firestore.indexes.json - Typically provide better performance
- Required for complex queries
2. Index Merging (automatic):
- Firestore automatically merges single-field indexes to satisfy certain queries
- This plugin recognizes when index merging is available and will not report errors
- Works when all of the following conditions are met:
- All filters are equality (
==) filters, OR - All filters except one are equality, with one inequality filter on a single field, OR
- All filters except one are equality, with one
orderByclause
- All filters are equality (
- Does NOT work for:
- Multiple
orderByclauses - Multiple inequality filters on different fields
- Inequality filter and
orderByon different fields - Array-contains queries combined with other filters (requires composite index)
- Multiple
Examples that use index merging (no composite index needed):
// All equality filters
firestore.collection('users')
.where('status', '==', 'active')
.where('role', '==', 'admin')
.where('verified', '==', true)
.get();
// Equality filters + one inequality
firestore.collection('products')
.where('category', '==', 'electronics')
.where('price', '>', 100)
.get();
// Equality filters + one orderBy
firestore.collection('posts')
.where('status', '==', 'published')
.where('author', '==', userId)
.orderBy('createdAt', 'desc')
.get();Examples that require composite indexes:
// Multiple orderBy clauses
firestore.collection('users')
.orderBy('lastName', 'asc')
.orderBy('firstName', 'asc')
.get();
// Inequality + orderBy on different fields
firestore.collection('products')
.where('price', '>', 100)
.orderBy('rating', 'desc')
.get();
// Multiple inequalities on different fields
firestore.collection('products')
.where('price', '>', 100)
.where('stock', '<', 10)
.get();Firestore indexes use prefix matching. This means:
- A query must match the index from the first field
- You cannot skip fields in the middle of an index
- Query
status == X AND category == Ycannot use index[organizationId, status, category] - Conditional queries that add/remove fields at the beginning need separate indexes for each pattern
Example with conditional filters:
let query = firestore
.collection('items')
.where('status', '==', 'active')
.where('category', '==', category)
.orderBy('createdAt', 'desc')
if (organizationId) {
query = query.where('organizationId', '==', organizationId)
}This query requires two separate indexes:
- Without
organizationId:[status, category, createdAt] - With
organizationId:[status, category, organizationId, createdAt]OR[organizationId, status, category, createdAt]
- Inequality queries (
<,<=,>,>=) must come after equality queries in the index - Multiple inequality queries on the same field are allowed (e.g.,
score >= X AND score <= Y) - Array-contains queries require
arrayConfig: "CONTAINS"in the index configuration
npm testnpm run lintCheck the examples/ directory for sample code and configuration:
examples/indexes.json- Sample indexes configurationexamples/valid-queries.js- Examples of queries with proper indexesexamples/invalid-queries.js- Examples of queries missing indexes
This ESLint rule has some limitations:
- Dynamic queries: Cannot detect queries built dynamically at runtime
- Conditional logic: May have false positives/negatives with complex conditional queries
- Cross-file queries: Queries built across multiple functions may not be fully detected
- Query helpers: Custom query helper functions may need special handling
- Performance implications: The rule allows queries that use index merging, but these may have different performance characteristics than composite indexes. Consider creating composite indexes for frequently-used queries even when index merging is available.
MIT
Contributions are welcome! Please feel free to submit a Pull Request.