# How to Query Documents in Firestore

- Tool: Firebase
- Difficulty: Intermediate
- Time required: 15-20 min
- Compatibility: Firebase JS SDK v9+, Firestore (Spark and Blaze plans)
- Last updated: March 2026

## TL;DR

Query Firestore documents using the modular SDK v9+ by building query objects with collection(), where(), orderBy(), and limit(). Chain multiple where() clauses for compound queries, use getDocs() for one-time fetches or onSnapshot() for real-time results. Firestore requires a composite index for compound queries with range filters on different fields — the error message includes a direct link to create the needed index.

## Querying Documents in Cloud Firestore

Firestore stores data in documents organized into collections. To retrieve specific documents, you build query objects that describe what data you want. This tutorial covers single-field queries, compound queries with multiple conditions, ordering and limiting results, and subscribing to real-time updates. You will also learn how Firestore indexes work and what to do when the SDK tells you a composite index is required.

## Before you start

- A Firebase project with Firestore enabled
- Firebase JS SDK v9+ installed (npm install firebase)
- A Firestore collection with documents to query
- Basic understanding of JavaScript async/await

## Step-by-step guide

### 1. Initialize Firestore and build a simple query

Import the required functions from the firebase/firestore module and initialize your Firestore instance. Then build a simple query using collection() to target a collection and where() to filter by a single field. The query() function composes these constraints into a query object. Call getDocs() to execute the query and iterate over the results.

```
import { initializeApp } from 'firebase/app'
import { getFirestore, collection, query, where, getDocs } from 'firebase/firestore'

const app = initializeApp({
  apiKey: 'YOUR_API_KEY',
  authDomain: 'YOUR_PROJECT.firebaseapp.com',
  projectId: 'YOUR_PROJECT_ID'
})

const db = getFirestore(app)

async function getElectronics() {
  const q = query(
    collection(db, 'products'),
    where('category', '==', 'electronics')
  )

  const snapshot = await getDocs(q)
  snapshot.forEach((doc) => {
    console.log(doc.id, doc.data())
  })
}
```

**Expected result:** The console logs each document ID and its data for all products in the electronics category.

### 2. Build compound queries with multiple where() clauses

Chain multiple where() clauses to filter on several fields. Firestore allows multiple equality filters freely. When you combine an equality filter with a range filter (like < or >), both fields can differ. However, range filters on two different fields require a composite index. If the index does not exist, Firestore throws an error with a clickable link to create it in the Firebase Console.

```
import { query, collection, where, orderBy, limit } from 'firebase/firestore'

// Equality + range on different fields (needs composite index)
const q = query(
  collection(db, 'products'),
  where('category', '==', 'electronics'),
  where('price', '<', 500),
  orderBy('price'),
  limit(25)
)
```

**Expected result:** The query returns up to 25 electronics products priced under 500, sorted by price ascending.

### 3. Order and limit query results

Use orderBy() to sort results by a field and limit() to cap the number of returned documents. You can chain multiple orderBy() calls for secondary sorting. When combining orderBy() with where() range filters, the first orderBy() field must match the where() range field. Use limitToLast() to get the last N results instead of the first N.

```
import { query, collection, orderBy, limit, limitToLast } from 'firebase/firestore'

// Get 10 newest products
const newestQuery = query(
  collection(db, 'products'),
  orderBy('createdAt', 'desc'),
  limit(10)
)

// Get 5 oldest products (using limitToLast with ascending order)
const oldestQuery = query(
  collection(db, 'products'),
  orderBy('createdAt', 'asc'),
  limitToLast(5)
)
```

**Expected result:** The first query returns the 10 most recently created products. The second returns the 5 oldest.

### 4. Subscribe to real-time query results with onSnapshot()

Instead of a one-time fetch with getDocs(), use onSnapshot() to listen for real-time updates. The callback fires immediately with the current results, then again whenever a matching document is added, modified, or removed. The snapshot includes a docChanges() method that tells you exactly what changed since the last callback. Always store the unsubscribe function and call it when the listener is no longer needed.

```
import { query, collection, where, orderBy, onSnapshot } from 'firebase/firestore'

const q = query(
  collection(db, 'products'),
  where('inStock', '==', true),
  orderBy('price', 'asc')
)

const unsubscribe = onSnapshot(q, (snapshot) => {
  const products = snapshot.docs.map((doc) => ({
    id: doc.id,
    ...doc.data()
  }))
  console.log('Current in-stock products:', products)

  // Check what changed
  snapshot.docChanges().forEach((change) => {
    if (change.type === 'added') console.log('New:', change.doc.data())
    if (change.type === 'modified') console.log('Updated:', change.doc.data())
    if (change.type === 'removed') console.log('Removed:', change.doc.data())
  })
})

// Call when done listening
// unsubscribe()
```

**Expected result:** The callback logs all in-stock products immediately, then logs changes in real time as documents are added, modified, or removed.

### 5. Use array-contains and in operators

The array-contains operator checks if an array field contains a specific value. The in operator checks if a field matches any value in a provided array (up to 30 values). You can combine array-contains with equality filters, but you cannot use array-contains and array-contains-any in the same query, and you cannot use more than one in or not-in filter per query.

```
import { query, collection, where } from 'firebase/firestore'

// Find products tagged with 'sale'
const tagQuery = query(
  collection(db, 'products'),
  where('tags', 'array-contains', 'sale')
)

// Find products in specific categories
const multiCategoryQuery = query(
  collection(db, 'products'),
  where('category', 'in', ['electronics', 'clothing', 'books'])
)

// Combine array-contains with equality
const combinedQuery = query(
  collection(db, 'products'),
  where('tags', 'array-contains', 'sale'),
  where('category', '==', 'electronics')
)
```

**Expected result:** Each query returns only the documents matching the specified filter conditions.

### 6. Handle query errors and empty results

Always wrap query execution in try/catch to handle errors such as missing indexes, permission denials, or network issues. Check snapshot.empty before processing results. The most common error is 'Missing or insufficient permissions' which means your Firestore security rules do not allow the query. The second most common is 'The query requires an index' which means you need to create a composite index.

```
async function safeQuery() {
  const q = query(
    collection(db, 'products'),
    where('category', '==', 'electronics'),
    where('price', '>', 100)
  )

  try {
    const snapshot = await getDocs(q)

    if (snapshot.empty) {
      console.log('No matching documents found.')
      return []
    }

    return snapshot.docs.map((doc) => ({
      id: doc.id,
      ...doc.data()
    }))
  } catch (error) {
    if (error.code === 'permission-denied') {
      console.error('Check your Firestore security rules.')
    } else if (error.message.includes('requires an index')) {
      console.error('Create the composite index using the link in the full error.')
    } else {
      console.error('Query failed:', error)
    }
    return []
  }
}
```

**Expected result:** The function returns an array of matching products or an empty array, with clear error messages for common failure modes.

## Complete code example

File: `firestore-queries.ts`

```typescript
import { initializeApp } from 'firebase/app'
import {
  getFirestore,
  collection,
  query,
  where,
  orderBy,
  limit,
  getDocs,
  onSnapshot,
  QuerySnapshot,
  DocumentData
} from 'firebase/firestore'

const app = initializeApp({
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!
})

const db = getFirestore(app)

// One-time fetch with compound query
async function fetchProducts(
  category: string,
  maxPrice: number,
  pageSize: number = 25
) {
  const q = query(
    collection(db, 'products'),
    where('category', '==', category),
    where('price', '<', maxPrice),
    orderBy('price', 'asc'),
    limit(pageSize)
  )

  try {
    const snapshot = await getDocs(q)
    if (snapshot.empty) return []

    return snapshot.docs.map((doc) => ({
      id: doc.id,
      ...doc.data()
    }))
  } catch (error: any) {
    console.error('Query failed:', error.message)
    return []
  }
}

// Real-time listener
function subscribeToInStockProducts(
  callback: (products: DocumentData[]) => void
) {
  const q = query(
    collection(db, 'products'),
    where('inStock', '==', true),
    orderBy('createdAt', 'desc'),
    limit(50)
  )

  const unsubscribe = onSnapshot(
    q,
    (snapshot: QuerySnapshot) => {
      const products = snapshot.docs.map((doc) => ({
        id: doc.id,
        ...doc.data()
      }))
      callback(products)
    },
    (error) => {
      console.error('Listener error:', error.message)
    }
  )

  return unsubscribe
}

export { fetchProducts, subscribeToInStockProducts }
```

## Common mistakes

- **Using range filters on two different fields without creating a composite index** — undefined Fix: Click the link in the Firestore error message to create the required composite index automatically in the Firebase Console. Alternatively, define indexes in firestore.indexes.json and deploy with the CLI.
- **Combining array-contains with array-contains-any or using two in filters in the same query** — undefined Fix: Firestore allows only one array-contains or array-contains-any and one in or not-in per query. Restructure your data model or run separate queries and merge results client-side.
- **Not calling the unsubscribe function returned by onSnapshot(), causing memory leaks** — undefined Fix: Store the return value of onSnapshot() and call it when the component unmounts or the listener is no longer needed. In React, place it in the useEffect cleanup function.
- **Expecting security rules to filter results — rules reject entire queries, not individual documents** — undefined Fix: Make sure your query constraints match what your security rules allow. If rules require request.auth != null, ensure the user is authenticated before running the query.

## Best practices

- Always include orderBy() when using limit() to ensure consistent and predictable result ordering
- Use getDocs() for one-time data fetches and onSnapshot() only when you need real-time updates to reduce read costs
- Wrap query execution in try/catch to handle permission errors and missing index errors gracefully
- Pre-create composite indexes in firestore.indexes.json and deploy them with the CLI to avoid runtime surprises
- Use snapshot.empty to check for no results before iterating over documents
- Combine queries with Firestore pagination (startAfter/limit) for large result sets instead of fetching everything at once
- Keep where() and orderBy() field order consistent — the first orderBy field must match the range filter field

## Frequently asked questions

### How many where() clauses can I chain in a single Firestore query?

You can chain multiple where() clauses with equality (==) filters on different fields without limits. However, you can only use one array-contains or array-contains-any filter and one in or not-in filter per query. Range filters on multiple different fields require composite indexes.

### What does 'The query requires an index' mean?

Firestore automatically indexes single fields but requires manually created composite indexes for queries combining range filters or ordering on different fields. The error message includes a direct link to the Firebase Console that pre-fills the index configuration — just click it and confirm.

### Do Firestore queries count as reads for billing?

Yes. Each document returned by a query counts as one read. A query that returns 50 documents counts as 50 reads. If a query returns no results, it still counts as one read. Use limit() and pagination to control costs.

### Can I query across multiple collections with the same name?

Yes. Use collectionGroup() instead of collection() to query all subcollections sharing the same ID. Collection group queries require a collection-group scope index, which you can create in the Firebase Console.

### How do I sort by one field and filter by another?

Combine where() and orderBy() in the same query. If you use a range filter (like < or >) on one field and orderBy() on a different field, you need a composite index. Firestore requires the first orderBy() field to match the range filter field.

### Can RapidDev help me build complex Firestore query logic for my application?

Yes. RapidDev can design efficient Firestore query patterns, set up composite indexes, implement pagination, and optimize your data model for your specific use case.

---

Source: https://www.rapidevelopers.com/firebase-tutorial/how-to-query-documents-in-firestore
© RapidDev — https://www.rapidevelopers.com/firebase-tutorial/how-to-query-documents-in-firestore
