Firestore data modelling for engineers who think in relational terms

By Priya Venkataraman · 19 July 2026101 views
Firestore data modelling for engineers who think in relational terms

A backend developer with ten years of PostgreSQL experience joins a project using Firestore. She models the data the way she knows: users, orders, order_items, products — each in its own collection, normalized. The application is built. In the first week of testing, she discovers that Firestore does not support joins. Fetching an order with its items and the customer's details requires three separate reads. The order list page requires N+1 reads to display N orders with their item counts.

She asks if they can switch to PostgreSQL. The team says no — Firebase is already integrated for authentication and the client needs real-time updates. She has to learn a different way to think about data.

Why SQL normalization fails in Firestore

Normalization — keeping each piece of data in exactly one place — solves two problems in relational databases: storage efficiency and update anomalies. If a customer's email is stored in one row, updating it takes one write and the change is immediately consistent across all references.

Firestore makes different trade-offs. Storage is cheap. Consistency across documents requires transactions (which are supported but have limitations). Queries are limited to a single collection or collection group. The problems that normalization solves in SQL are either not problems in Firestore or are solved differently.

Normalization in Firestore creates problems that are not present in SQL:

No join queries. To display an order with its customer's name, a normalized Firestore schema requires one read for the order and one read for the customer. For a list of 20 orders, that's 21 reads. PostgreSQL solves this with a JOIN.

Collection group queries are limited. Querying across all order_items subcollections for a specific product requires a collection group query, which has restrictions (no orderBy on a different field, no inequality filters on different fields than the one in the where clause).

Expensive aggregations. Counting orders for a user, summing order totals for a customer, counting items per order — all require reading documents or maintaining counters. There is no COUNT(*) or SUM().

The design principles that work in Firestore

Design for your queries, not for your data model

In relational databases, the normalized model is designed first and queries are derived from it. In Firestore, the process is often reversed: define the queries your application needs, then design the data model to support those queries efficiently.

The most common queries determine the structure:

  • "Show all orders for this user" → orders indexed by userId
  • "Show order detail with item names and prices" → items embedded in the order document or in a subcollection with enough data for the display
  • "Show all items for a product" → maintain an items_by_product collection

Denormalize for read performance

If the same data is needed for a common read operation, store it in the document that is read, not in a separate document that requires a second read.

// Normalized (SQL-style) — requires two reads for an order summary
// orders collection
{
  id: "ord_123",
  userId: "usr_456",
  status: "shipped",
  createdAt: Timestamp,
  totalAmount: 89.97
}

// users collection — separate read required
{
  id: "usr_456",
  email: "[email protected]",
  name: "Alice Chen"
}

// Denormalized (Firestore-appropriate) — one read for full order summary
// orders collection
{
  id: "ord_123",
  userId: "usr_456",
  customerName: "Alice Chen",    // Denormalized from users collection
  customerEmail: "[email protected]",  // Denormalized from users collection
  status: "shipped",
  createdAt: Timestamp,
  totalAmount: 89.97
}

The trade-off: if Alice changes her name, every order document must be updated. In SQL, updating the users table is sufficient. In Firestore, this requires a batch write across all of Alice's orders.

Evaluate this trade-off by asking: how often does this data change, and how many documents would need to be updated? Customer name changes are rare. Order counts are very large for active users. Denormalizing a rarely-changing field that is needed in every read is almost always worth it.

Subcollections are the Firestore equivalent of one-to-many relationships. An order can have many items; items are a subcollection of the order.

// Order document
// /orders/{orderId}
{
  id: "ord_123",
  userId: "usr_456",
  customerName: "Alice Chen",
  status: "shipped",
  totalAmount: 89.97,
  itemCount: 3  // Denormalized counter — avoids reading all items to get the count
}

// Order items subcollection
// /orders/{orderId}/items/{itemId}
{
  productId: "prod_789",
  productName: "Wireless Headphones",  // Denormalized from products collection
  quantity: 1,
  unitPrice: 59.99,
  totalPrice: 59.99
}

Reading the order detail page requires:

  1. One read for the order document (displays order header, status, total)
  2. One query on the items subcollection (displays all items)

Two reads, not N+1. The item count on the order document avoids reading the subcollection just to display "3 items."

Counter documents for aggregations

Firestore does not support aggregate queries like COUNT or SUM. For counters that must be current, maintain a counter document that is updated as a side effect of writes.

// When an order is placed:
const batch = db.batch();

// Write the order
batch.set(db.collection('orders').doc(orderId), orderData);

// Increment the user's order count
batch.update(db.collection('users').doc(userId), {
  orderCount: FieldValue.increment(1),
  lifetimeSpend: FieldValue.increment(orderData.totalAmount)
});

await batch.commit();  // Atomic — both writes succeed or both fail

// The user document now has:
// { ..., orderCount: 15, lifetimeSpend: 432.50 }
// Reading this is one document read — no aggregation query needed

For high-frequency counters (page views, likes), a single counter document becomes a write bottleneck because Firestore limits one document to approximately 1 write per second under sustained load. The sharded counter pattern distributes writes across multiple shard documents and sums them on read.

Collection group queries for cross-hierarchy queries

When data in subcollections must be queried across all parent documents, collection group queries work if the data is structured correctly:

// Query all items across all orders for a specific productId
const query = db.collectionGroup('items')
  .where('productId', '==', 'prod_789');

// This requires a Firestore composite index:
// Collection group: items, field: productId, field: orderId
// Create in firestore.indexes.json

Collection group queries require that the collectionId (the subcollection name) is the same across all parent documents — all subcollections named items, regardless of their parent order.

The update problem

Denormalization means the same data exists in multiple places. When that data changes, all copies must be updated. This is the main operational cost of Firestore denormalization.

The approaches:

Batch writes for small fan-out. If a product name changes and that name is denormalized in order items, query for all affected items and update them in a batch. Batches are limited to 500 writes; multiple batches are required for larger fan-out.

async function updateProductNameInOrders(productId, newName) {
  const affected = await db.collectionGroup('items')
    .where('productId', '==', productId)
    .get();

  // Process in batches of 500
  const chunks = chunkArray(affected.docs, 500);
  for (const chunk of chunks) {
    const batch = db.batch();
    chunk.forEach(doc => batch.update(doc.ref, { productName: newName }));
    await batch.commit();
  }
}

Eventual consistency for non-critical data. If the product name in order history does not need to be current (historical orders showed the name at the time of purchase), do not update it at all. The denormalized name is a snapshot at order time, not a reference.

Server-side triggers for automatic sync. A Cloud Function triggered by writes to the products collection can automatically propagate name changes to order items. The denormalized copies update eventually — appropriate for non-critical display data.

The developer who came from PostgreSQL found Firestore's model limiting until she understood that it is optimized for a different trade-off: fast, predictable reads at the cost of more complex writes and more storage. For applications with high read-to-write ratios and real-time update requirements — mobile apps, collaborative tools, dashboards — those trade-offs are often correct. For applications with complex querying needs or high write throughput with complex aggregations, they are often not.

Common mistakes when modelling data in Firestore

Designing the data model before defining the queries. The relational habit of starting with a normalized entity model and then writing queries to fit it produces Firestore schemas that require multiple reads for every screen. Before designing any collection structure, list the five most common read operations the application requires. The data model should make those five queries fast and cheap — even if that means duplicating data.

Putting unbounded data inside documents. Firestore documents have a maximum size of 1MB. An order document that stores items as an array field works at launch (3-5 items). At scale, orders with 50 line items or a chat document that stores all messages as an array approach the limit. Use subcollections for data that can grow without an upper bound.

// WRONG: Items embedded as an array — grows without bound
// /orders/{orderId}
{
  id: "ord_123",
  items: [
    { productId: "prod_1", name: "Widget", qty: 2 },
    { productId: "prod_2", name: "Gadget", qty: 1 },
    // ... could be 100+ items for wholesale orders
  ]
}

// CORRECT: Items as a subcollection — each item is a separate document
// /orders/{orderId}
{
  id: "ord_123",
  itemCount: 3,      // Denormalized counter for display
  totalAmount: 89.97
}
// /orders/{orderId}/items/{itemId}
{
  productId: "prod_1",
  productName: "Widget",
  quantity: 2,
  unitPrice: 29.99
}

Not creating composite indexes before running complex queries. Firestore requires composite indexes for queries that filter or order on multiple fields. The error message when a missing index is the cause is clear — it includes a direct link to create the required index. But composite indexes are not created automatically and the error only appears at runtime. Identify all compound queries during development and add the indexes to firestore.indexes.json so they are deployed with the code:

// firestore.indexes.json
{
  "indexes": [
    {
      "collectionGroup": "orders",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "userId", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "orders",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "userId", "order": "ASCENDING" },
        { "fieldPath": "status", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    }
  ]
}

Deploy indexes before deploying the code that uses them with firebase deploy --only firestore:indexes. Index creation takes several minutes; code deployed before index creation completes will produce query errors.

Ignoring the write bottleneck on high-frequency counters. Firestore allows approximately one write per second to a single document under sustained load. An order count field on a user document is fine — orders are placed infrequently. A view counter on a viral post that receives 500 increments per second creates a write bottleneck where most writes fail or queue. The sharded counter pattern distributes writes across N shard documents and sums them on read:

const SHARD_COUNT = 10;

// Write to a random shard — distributes load
async function incrementViewCount(postId) {
  const shardId = Math.floor(Math.random() * SHARD_COUNT).toString();
  const shardRef = db.collection('posts').doc(postId)
    .collection('counters').doc(shardId);

  await shardRef.set(
    { count: FieldValue.increment(1) },
    { merge: true }
  );
}

// Read by summing all shards
async function getViewCount(postId) {
  const shards = await db.collection('posts').doc(postId)
    .collection('counters').get();

  return shards.docs.reduce((sum, doc) => sum + (doc.data().count || 0), 0);
}

Not understanding how FieldValue.arrayUnion and FieldValue.arrayRemove interact with document reads. Developers new to Firestore sometimes store small sets of IDs as arrays inside documents (the set of users who liked a post, the set of product IDs in a wishlist). FieldValue.arrayUnion is appropriate for these when the array is small and bounded. The mistake is using this pattern for sets that can grow without bound or for sets that must be queried efficiently. Querying "all posts where user X is in the likes array" requires a composite index and scans all documents. For large sets, a subcollection is better than an embedded array.

Not planning for the eventual consistency of Cloud Functions triggers. Triggers (like a Cloud Function triggered by an order creation) execute asynchronously. The order document exists in Firestore before the trigger runs. Client code that polls for a field that the trigger is supposed to set will see the field absent immediately after the order write. If the client needs confirmation that a side effect happened, either poll with a listener, have the trigger update a status field, or use a different pattern (like a callable function that performs the work synchronously and returns the result).

Choosing the right Firestore access pattern

A summary of access patterns matched to use cases:

Access patternWhen to use
Single document read (getDoc)Fetching a specific known document
Collection query with filtersFetching a list where query fields are indexed
Subcollection queryFetching child data for a specific parent
Collection group queryCross-parent subcollection queries
Real-time listener (onSnapshot)Data that changes and must stay fresh in UI
Batch writeMultiple writes that must succeed or fail together
TransactionRead-then-write where the write depends on the read value

The developer who normalized her schema into four separate collections was applying the right tool to the wrong problem. Firestore's query constraints — no joins, limited aggregations, no cross-collection queries — are not deficiencies. They are the consequence of an architecture that scales horizontally without complex coordination. Working with those constraints, rather than against them, produces applications that are fast at launch and fast at ten million documents.

Adapting as Firestore evolves

Firestore has added features that reduce the friction for engineers migrating from relational backgrounds. Native aggregate queries — count(), sum(), and average() — are now available without reading individual documents. Vector search support enables similarity search directly in Firestore without an external vector database. These additions do not change the fundamental data modeling principles: denormalize for reads, use subcollections for unbounded relationships, design for your queries first. But they reduce the surface area where Firestore falls short for common application patterns.

The engineer who spent two weeks understanding why her relational schema failed in Firestore emerged with a better mental model: Firestore optimizes for predictable, horizontally scalable read performance at the cost of query flexibility. Accepting that trade-off — and designing for it — is the transition that turns a Firestore skeptic into someone who can build effectively with it. For teams coming from a relational background, the most useful reframe is this: Firestore is not a worse relational database. It is a different kind of database that is better at different things. Designing around its strengths — real-time listeners, horizontal scaling, offline persistence — produces applications that take full advantage of what the platform uniquely offers.

Comments

No comments yet. Be the first!

Sign in to leave a comment.