Firestore data modeling best practices including subcollections vs root collections, document structure, relationships, query optimization, composite indexes, and atomic operations (transactions vs batches). Keywords: "subcollection", "root collection", "data model", "relationship", "index", "transaction", "batch"
Firestore is a NoSQL document database that requires careful modeling to optimize for query patterns, scalability, and cost. This skill provides patterns for structuring data effectively.
Pattern: users/{userId}/orders/{orderId}
Use When:
Example:
// User's private sessions (accessed only via user)
users/{userId}/sessions/{sessionId}
// User's notification preferences
users/{userId}/settings/notifications
Critical Limitation: Deleting a parent document does NOT delete subcollections. You must implement cleanup logic (e.g., Cloud Function).
Pattern: Separate users and posts collections with reference fields
Use When:
Example:
// posts collection
{
id: "post1",
authorId: "user123", // Reference to users collection
title: "...",
createdAt: Timestamp
}
// Query all posts by a user
postsRef.where('authorId', '==', 'user123').get()
// Query all posts globally
postsRef.orderBy('createdAt', 'desc').limit(10).get()
Decision Matrix:
| Criterion | Subcollection | Root Collection |
|---|---|---|
| Query across parents | ❌ Requires Collection Group Query | ✅ Simple query |
| Deletion cascade | ❌ Manual cleanup needed | ✅ Independent lifecycle |
| Document limit (1MB) | ✅ Spreads data | ⚠️ Risk if embedding arrays |
| Semantic hierarchy | ✅ Clear parent-child | ⚠️ Relies on references |
Embed When:
// User profile with embedded address
{
id: "user1",
name: "Alice",
address: {
street: "123 Main St",
city: "NYC",
zip: "10001"
}
}
Reference When:
// Post references author
{
id: "post1",
title: "My Post",
authorId: "user1", // Reference
categoryIds: ["cat1", "cat2"] // Many-to-many
}
Anti-Pattern:
// ❌ BAD: Embedding large comment array
{
postId: "post1",
comments: [ /* 1000s of comments */ ] // Will exceed 1MB!
}
Solution:
// ✅ GOOD: Comments as separate collection
posts/post1
comments/comment1 { postId: "post1", ... }
comments/comment2 { postId: "post1", ... }
Firestore automatically creates single-field indexes. No action needed for:
// Automatic indexes
postsRef.where('status', '==', 'published').get()
postsRef.orderBy('createdAt', 'desc').get()
Required For:
where clauseswhere and orderBy on different fieldsExample Requiring Index:
// Query: Published posts sorted by creation date
postsRef
.where('status', '==', 'published')
.orderBy('createdAt', 'desc')
.get()
Generate Index (firestore.indexes.json):
{
"indexes": [
{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
]
}
Deploy:
firebase deploy --only firestore:indexes
Development Tip: Firestore returns an error with a direct link to create the index in the Firebase Console during development.
To query across subcollections with the same name:
// Query all comments across all posts
db.collectionGroup('comments')
.where('authorId', '==', 'user1')
.get()
Requires: Collection Group index (created automatically or via console)
Use When: Write depends on current document state
Example: Increment a counter
import { runTransaction } from 'firebase/firestore';
await runTransaction(db, async (transaction) => {
const postRef = doc(db, 'posts', 'post1');
const postDoc = await transaction.get(postRef);
if (!postDoc.exists()) {
throw new Error('Post does not exist');
}
const newViewCount = postDoc.data().viewCount + 1;
transaction.update(postRef, { viewCount: newViewCount });
});
Characteristics:
Use When: Multiple independent write operations need atomicity
Example: Create user + settings document
import { writeBatch } from 'firebase/firestore';
const batch = writeBatch(db);
const userRef = doc(db, 'users', 'user1');
batch.set(userRef, {
name: 'Alice',
email: '[email protected]',
createdAt: serverTimestamp(),
});
const settingsRef = doc(db, 'users', 'user1', 'settings', 'notifications');
batch.set(settingsRef, {
emailNotifications: true,
pushNotifications: false,
});
await batch.commit(); // All succeed or all fail
Characteristics:
Decision Rule: Default to batched writes (simpler, faster). Use transactions only when reads are required.
Option 1: Subcollection (Parent → Children)
users/user1
users/user1/orders/order1
users/user1/orders/order2
Option 2: Root Collection with Reference (More flexible)
users/user1
orders/order1 { userId: "user1" }
orders/order2 { userId: "user1" }
Pattern: Store array of IDs or use join collection
Option 1: Array of IDs (Best for small, stable lists)
// Post with categories
posts/post1 {
categoryIds: ["cat1", "cat2", "cat3"]
}
// Query posts in category
postsRef.where('categoryIds', 'array-contains', 'cat1').get()
Option 2: Join Collection (Best for large or dynamic relationships)
users/user1
courses/course1
enrollments/enroll1 { userId: "user1", courseId: "course1" }
✅ Do:
❌ Don't:
.limit() for lists.startAfter() for cursor-based paginationRelated Skills: zod-firestore-type-safety, firebase-nextjs-integration-strategies
Token Estimate: ~1,200 tokens