Content Index
- SQL vs. NoSQL
- Pros and Cons
- High performance: FastAPI + MongoDB with Motor
- Flexibility vs. Consistency
- The consistency challenge
- Key Concepts and Equivalencies
- Installation on Windows
- Installation on macOS (using Homebrew)
- Prerequisites
- Compatible macOS
- Homebrew Installation
- Required Software to Install MongoDB
- Install Xcode Command Line Tools
- Add the Official MongoDB Tap to Homebrew
- What is a Homebrew tap?
- Command to add the tap
- Choosing the MongoDB version to install
- Graphical Interface: MongoDB Compass
- Verifying MongoDB Installation
- Starting the MongoDB Service
- Starting Service for Specific Version
- Stopping or Restarting MongoDB
- Common Errors and Troubleshooting
- Localhost Connection Error
- Incompatible Version Issues
- Motor: Async MongoDB Driver for Python
- Installing Dependencies
- Connecting to MongoDB with Motor
- Client Configuration
- Removing SQLAlchemy from FastAPI
- Lifespan Management in FastAPI
- Exposing MongoDB Client via Dependency Injection
- Implementing Routers and Schemas
- From ORM to Native Driver
- Step-by-Step CRUD Operations
- 1. Create Task (POST)
- The _id and ObjectId
- 2. Read All Tasks (GET)
- 3. Read Specific Task (GET by ID)
- 4. Update and Delete (PUT / DELETE)
- Verification in MongoDB Compass
- Relationships in MongoDB: Embedded Schema with Tags
- Adding and Removing Tags
- 1. Adding Tags: the $addToSet Operator
- $addToSet + $each: the Perfect Combination
- 2. Removing Tags: the $pull Operator
- Schema Flexibility: Where is the tags table?
- How Tags Are Stored in MongoDB
- Relationships in MongoDB
- Normalized vs. Denormalized Schemas
- 1. Denormalized Schema (Embedded)
- 2. Normalized Schema (Referenced)
- When to Use Each Strategy? (1:1, 1:N, and N:N)
- Bulk Operations: update_one vs. update_many
- Conclusion and Practice
In this chapter, we are going to configure our project to integrate MongoDB with FastAPI using the Motor driver. This integration fits naturally with the project structure we built in the previous chapter on automatic CRUD with FastAPI, and we will leverage that same foundation to migrate towards a NoSQL database.
Before diving into the code, we will do a practical review of what MongoDB is, how it differs from relational databases, and why combining it with FastAPI makes so much sense. We will also see how to install MongoDB on macOS using Homebrew, the most popular package manager for macOS and Linux.
SQL vs. NoSQL
- Relational Databases (SQL): They are structured and work like interconnected Excel tables. They have a rigid, predefined schema; for example, if you have a
taskstable withidandnamefields, and then you need to save adescription, you must alter the database schema using a migration. - NoSQL Databases (MongoDB): They are unstructured (or semi-structured) databases. Data is stored in a flexible manner, usually in formats similar to
JSON. This allows changing the structure without needing prior migrations. For instance, we can inject acategorydirectly into ataskdocument without touching any schema.
Pros and Cons
Pros:
- Flexibility: Ideal for rapid prototyping and schemas that evolve constantly.
- Massive scalability: Designed to handle gigantic volumes of data horizontally.
- Speed: They tend to be more efficient for simple read/write operations, especially when complex
JOINs are not required.
Cons:
- Lower consistency: Since there is no fixed schema, data can become inconsistent if application logic is not managed properly in code.
- Complex queries: Performing joins or intricate queries is harder, whereas in SQL these are trivial using a
JOIN. - Ecosystem maturity: Although highly popular, the SQL ecosystem has decades more support, consolidated tools, and battle-tested production stability.
High performance: FastAPI + MongoDB with Motor
One goal of this chapter is to help you break away from the relational SQL mindset and step fully into the NoSQL world. The combination of FastAPI + MongoDB + Motor is not random:
- FastAPI is a framework recognized for its high performance and native support for asynchronous operations with
async/await. - MongoDB is a database designed for high performance and horizontal scalability.
- Motor is the official asynchronous driver for MongoDB in Python, making it the perfect match for FastAPI.
This stack is ideal for high-demand projects where response speed and concurrency are critical.
Flexibility vs. Consistency
One key point I will emphasize throughout this chapter is the trade-off between lower data consistency and greater flexibility. In MongoDB, everything is a JSON (technically BSON — Binary JSON), giving us complete structural freedom, but also requiring higher developer responsibility.
The consistency challenge
Imagine we have a task with a category_id field. Due to MongoDB's flexibility, you could end up with a collection where these three documents coexist:
- A task with the
category_idfield properly defined. - Another task that, due to a CRUD bug, lacks the
category_idfield entirely. - A task with embedded tags directly inside it, without any external relational collection.
If you do not manage logic carefully in code, attempting to access the category of a document missing that field could trigger a 500 error. In MongoDB, data integrity relies much more on application code and CRUD quality than on the database itself.
Key Concepts and Equivalencies
Before starting the project, speaking the same language is essential. If you come from a SQL background, here are the core equivalencies we will use throughout the chapter:
| SQL Concept | MongoDB Equivalent |
|---|---|
| Table | Collection (Collection) |
| Record / Row | Document (Document) |
| Column | Field (Field) |
| Auto-incrementing Primary Key | ObjectId (auto-generated) |
Installation on Windows
Installation on Windows is straightforward:
- Search Google for MongoDB Community Server or go directly to
mongodb.com/try/download/community. - Download the
.msiinstaller and follow the wizard steps (Next → Next → Finish). - Environment Variables Setup: You will likely need to add the installation's
binfolder to the systemPATHvariable.- Tip: Right-click "This PC" → Properties → Advanced system settings → Environment Variables → select
Path→ Edit → Add the path to thebindirectory.
- Tip: Right-click "This PC" → Properties → Advanced system settings → Environment Variables → select
- Restart your machine, and you will be able to run
mongoshfrom any terminal.
Installation on macOS (using Homebrew)
Installing MongoDB on macOS might seem tricky at first, but using Homebrew keeps the process clean and structured. Below is a step-by-step guide to installing MongoDB on macOS with Homebrew, starting it properly, and beginning to work with the database.
This is the workflow I use every time I set up a new Mac development environment. It prevents most common issues when starting MongoDB for the first time.
Prerequisites
Before installing MongoDB, ensure your system meets all requirements.
Compatible macOS
MongoDB works properly on modern macOS releases (Catalina and newer). If you are using an old release, upgrading the operating system or installing a compatible MongoDB version is recommended.
Homebrew Installation
macOS does not ship with Homebrew by default, but it is one of the most essential Mac development tools. Homebrew is a package manager that simplifies software installations directly from the terminal in a reproducible way.
To install it, follow the official instructions on its site:
Required Software to Install MongoDB
Install Xcode Command Line Tools
When running any brew command for the first time, macOS will likely ask you to install Command Line Tools for Xcode. Accept the prompt and complete the installation, as these tools are required to compile and run many Homebrew packages.
Add the Official MongoDB Tap to Homebrew
What is a Homebrew tap?
A tap is simply an external repository Homebrew uses to locate packages outside its core index. MongoDB maintains its official tap on GitHub (mongodb/homebrew-brew), which helps avoid unsupported or third-party builds.
Command to add the tap
$ brew tap mongodb/brewThis step is key. Many installation issues stem directly from trying to install MongoDB without adding the official tap first.
Choosing the MongoDB version to install
MongoDB releases several stable versions. Here, we will install a specific version that has yielded great results on macOS:
$ brew install mongodb-community@8.0You can also install other stable versions based on your requirements:
$ brew install mongodb-community@8.0
$ brew install mongodb-community@7.0Installing a specific version prevents breaking changes with libraries or the OS, saving time and headaches in production setups.
Graphical Interface: MongoDB Compass
To work comfortably with a visual interface instead of relying solely on the terminal, we will install MongoDB Compass: the official GUI tool for MongoDB. It serves a role similar to TablePlus or DBeaver in SQL environments.
- On Windows: It can be selected during the server installation wizard or downloaded separately from the official website.
- On macOS (via Homebrew):
$ brew install --cask mongodb-compass- (The
--caskflag specifies that we are installing a GUI application rather than a command-line tool).
Once installed, launch it from Applications, connect to your local instance (mongodb://localhost:27017), and you will be ready to visually inspect and manage your collections.
You can also download it directly from the official installer:
https://www.mongodb.com/try/download/compass
Verifying MongoDB Installation
Once complete, MongoDB will be installed on your system but not yet running. To confirm the installation succeeded, check the installed version using:
$ mongod --versionStarting the MongoDB Service
Just like services such as MySQL, you need to launch its background daemon to use MongoDB. Run:
$ brew services start mongodb-communityThis step is essential. Connecting without starting the background service causes connection errors like this:
MongoDB shell version v8.0.2
connecting to: mongodb://127.0.0.1:27017/?compressors=disabled&gssapiServiceName=mongodb
Error: couldn't connect to server 127.0.0.1:27017, connection attempt failed: SocketException: Error connecting to 127.0.0.1:27017 :: caused by :: Connection refused :
connect@src/mongo/shell/mongo.js:372:17This happens because MongoDB is not listening on port 27017. Once the service is running, start the MongoDB shell with:
$ mongoshUpon successful connection, you will see the shell prompt:
test>Starting Service for Specific Version
If you installed a targeted release, specify it when starting the service:
$ brew services start mongodb-community@8.0If you execute mongosh without starting the service, the following error appears:
Current Mongosh Log ID: 699c28e47c1b4855cf41cae5
Connecting to: mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.7.0
MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017The ECONNREFUSED error means the MongoDB process is inactive. Start the service and rerun mongosh.
Stopping or Restarting MongoDB
Useful commands to manage service status:
$ brew services stop mongodb-community
$ brew services restart mongodb-communityCommon Errors and Troubleshooting
Localhost Connection Error
This almost always indicates that the MongoDB service is stopped. Check Homebrew service statuses with:
$ brew services listFind mongodb-community and ensure its status reports started.
Incompatible Version Issues
Upgrading macOS or changing MongoDB versions may require reinstalling the target release or clearing lingering services using brew services cleanup before reinstalling.
Motor: Async MongoDB Driver for Python
While multiple Python drivers exist for MongoDB, this chapter focuses on Motor, the official async driver maintained by MongoDB's core team.
Motor is engineered specifically for async runtimes. As noted, MongoDB scales well for high concurrency and heavy data volume. Paired with FastAPI's native async/await design, incoming requests execute efficiently: while one database query waits for I/O, the server continues servicing concurrent incoming traffic. An asynchronous driver makes total sense when concurrency is the primary objective.
Installing Dependencies
Install Motor easily via pip. Make sure your virtual environment is active before running commands to avoid global installations.
If you are following from the previous chapter, you can safely remove SQLite and SQLAlchemy dependencies from your project setup, as MongoDB does not require them. This leaves us with a cleaner, async-oriented stack.
To install Motor, run:
$ pip install motorConnecting to MongoDB with Motor
Let's build our initial MongoDB connection setup. Create a db_connection.py file where we import Motor's client to manage async operations:
db_connection.py
import logging
from motor.motor_asyncio import AsyncIOMotorClient
logger = logging.getLogger("uvicorn.error")
mongo_client = AsyncIOMotorClient(
"mongodb://localhost:27017"
)
async def ping_mongo_db_server():
try:
await mongo_client.admin.command("ping")
logger.info("Connected to MongoDB")
except Exception as e:
logger.error(
f"Error connecting to MongoDB: {e}"
)
raise eClient Configuration
Before proceeding, verify that MongoDB is running. If typing mongosh opens the shell prompt (test>), you are good to go. Otherwise, start it using brew services start mongodb-community.
We set the connection string, which defaults to port 27017 (analogous to MySQL using port 3306).
We create a ping_mongo_db_server() function to validate the connection during app startup. On success, an info message is logged; otherwise, an exception is raised to stop the server and flag the issue early.
Removing SQLAlchemy from FastAPI
Inside your main API file, comment out or remove all references to SQLAlchemy and relational database drivers, since MongoDB will now serve as the sole persistence layer.
- Imports: Remove or comment out
SQLAlchemyimports and dependent routes. - Dependencies: Remove session factory helper functions (often named
get_dbor similar). - Models: Schema tables do not need initialization at startup because MongoDB does not enforce predefined rigid tables; collections are created dynamically on the first document insert.
Lifespan Management in FastAPI
To manage lifecycle connections efficiently, we use FastAPI's lifespan event handler. This provides a modern, clean pattern replacing legacy @app.on_event("startup") and @app.on_event("shutdown") decorators.
- Before
yield: Anything placed here executes before the application processes incoming requests. It is the ideal place to initialize the MongoDB client and ping the server. - After
yield: Teardown logic, such as closing connections or freeing resources upon application shutdown, belongs here.
We assign the lifespan handler when creating our FastAPI instance:
api.py
from fastapi import FastAPI, Depends, APIRouter, Query, Path
from contextlib import asynccontextmanager
from db_connection import ping_mongo_db_server
@asynccontextmanager
async def lifespan(app: FastAPI):
await ping_mongo_db_server()
yield
app = FastAPI(lifespan=lifespan)Starting the server via uvicorn should produce output containing: "Connected to MongoDB". This confirms that the connection is active and ready for handling documents and collections.
Exposing MongoDB Client via Dependency Injection
Next, create a dedicated module wrapping database access so it can be injected across FastAPI endpoints. This pattern keeps business logic decoupled and easy to test:
mongo_db.py
from db_connection import mongo_client
# Defines the database holding all application collections.
# Motor creates it automatically if it does not exist yet.
database = mongo_client.task_manager
def get_mongo_database():
"""Returns the database instance for dependency injection."""
return databaseThe get_mongo_database() helper will be supplied to route handlers using Depends(), following the same injection structure previously used for get_db in SQLAlchemy.
Implementing Routers and Schemas
In the main API file, mount the router under the Mongo Tasks tag to organize endpoints cleanly:
api.py
from mongo_task import mongo_task_router
***
app.include_router(mongo_task_router, prefix="/mongo/tasks", tags=["Mongo Tasks"])While the routing structure closely mirrors what was used for SQLAlchemy, driver methods and payload handling differ as explored below.
From ORM to Native Driver
SQLAlchemy provided an ORM abstraction layer around SQL statements. Since MongoDB is document-based, we interact directly with driver methods, shifting concepts and terminology:
- Instead of SQL statements, native driver calls like
insert_one,find,update_one, ordelete_oneare used. - Native Data Structures: MongoDB uses
JSONnatively. In Python, payloads map directly to standard dictionary instances (dict) corresponding to stored document records.
Step-by-Step CRUD Operations
Let's set up necessary module imports inside our route handler file:
from fastapi import APIRouter, Body, Depends, HTTPException, status, Path
from pymongo.database import Database
from bson import ObjectId
from mongo_db import get_mongo_database
from schemes import TaskWrite
mongo_task_router = APIRouter()1. Create Task (POST)
We convert the incoming Pydantic payload into a standard dictionary using model_dump() and insert the document. This async call returns an auto-generated ObjectId assigned by MongoDB.
mongo_task.py
# CREATE
@mongo_task_router.post("/", status_code=status.HTTP_201_CREATED, summary="Create a new task")
async def add_task(
task: TaskWrite = Body(...),
db: Database = Depends(get_mongo_database),
):
"""
Creates a new task in the database.
"""
# task_dict = task.dict() # Deprecated in Pydantic v2
task_dict = task.model_dump()
insert_result = await db.tasks.insert_one(task_dict)
return {
"message": "Task added successfully",
"id": str(insert_result.inserted_id),
}The _id and ObjectId
When inserting your first task, you will notice that primary identifiers are not simple sequential numbers (1, 2, 3...), but a 24-character hexadecimal value known as an ObjectId.
Why is it not a sequential number?
Relational databases rely on centralized sequence generators. In contrast, MongoDB is designed from the ground up for distributed systems and horizontal scaling.
If multiple MongoDB server nodes operated across a cluster simultaneously, two distinct nodes might try assigning the value 5 concurrently, causing collisions. The ObjectId addresses this gracefully by combining multiple factors:
- Timestamp: The exact creation time (first 4 bytes), ensuring uniqueness over time.
- Machine Identifier & Counter: Remaining bytes hold process-specific identifiers and atomic counters, guaranteeing unique keys even if records are created within the exact same millisecond on identical hardware.
2. Read All Tasks (GET)
We call find(), which returns a database cursor object. We cast it to a list via to_list() so FastAPI can serialize the result set into JSON.
mongo_task.py
# READ ALL
@mongo_task_router.get("/", summary="Get all tasks")
async def get_all_tasks(db: Database = Depends(get_mongo_database)):
"""
Retrieves all tasks from the 'tasks' collection.
"""
tasks_cursor = db.tasks.find()
return await tasks_cursor.to_list(length=None)3. Read Specific Task (GET by ID)
We run a double validation check before querying the database:
- Format validation: Check if the provided string is a valid
ObjectIdstructure usingObjectId.is_valid(). If invalid, return a400error immediately to avoid an unnecessary database round-trip. - Document lookup: If valid but no matching record exists, return a
404error.
mongo_task.py
# READ ONE
@mongo_task_router.get("/{task_id}", summary="Get a single task")
async def get_task(
task_id: str = Path(..., description="The ID of the task to retrieve"),
db: Database = Depends(get_mongo_database),
):
"""
Retrieves a single task by its ID.
"""
if not ObjectId.is_valid(task_id):
raise HTTPException(status_code=400, detail=f"Invalid ObjectId: {task_id}")
task = await db.tasks.find_one({"_id": ObjectId(task_id)})
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task with id {task_id} not found"
)
return task4. Update and Delete (PUT / DELETE)
When updating, we pass model_dump(exclude_none=True) to update only explicit user fields. For deletion, we match by _id and execute delete_one. If zero documents are modified or removed, we indicate missing resources with a 404 error.
mongo_task.py
mongo_task.py
# UPDATE
@mongo_task_router.put("/{task_id}", summary="Update a task")
async def update_task(
task_id: str = Path(..., description="The ID of the task to update"),
task: TaskWrite = Body(...),
db: Database = Depends(get_mongo_database),
):
"""
Updates the fields of a task.
"""
if not ObjectId.is_valid(task_id):
raise HTTPException(status_code=400, detail=f"Invalid ObjectId: {task_id}")
# update_data = task.dict(exclude_unset=True) # Deprecated in Pydantic v2
update_data = task.model_dump(exclude_none=True)
if not update_data:
raise HTTPException(status_code=400, detail="No data provided to update")
result = await db.tasks.update_one({"_id": ObjectId(task_id)}, {"$set": update_data})
if result.matched_count == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Task with id {task_id} not found")
if result.modified_count == 1:
updated_task = await db.tasks.find_one({"_id": ObjectId(task_id)})
return updated_task
return {"message": "Task data was the same, no update was performed."}
# DELETE
@mongo_task_router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a task")
async def delete_task(
task_id: str = Path(..., description="The ID of the task to delete"),
db: Database = Depends(get_mongo_database),
):
"""
Deletes a task from the database.
"""
if not ObjectId.is_valid(task_id):
raise HTTPException(status_code=400, detail=f"Invalid ObjectId: {task_id}")
result = await db.tasks.delete_one({"_id": ObjectId(task_id)})
if result.deleted_count == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Task with id {task_id} not found")
returnVerification in MongoDB Compass
Once you run the tests from FastAPI's interactive documentation (Swagger UI at /docs), you can refresh MongoDB Compass and see how documents are stored with their flexible JSON structure, including the ObjectId type _id.
This workflow demonstrates how seamless migrating from a relational schema to a NoSQL one can be when you have a well-organized architecture from the beginning.
Relationships in MongoDB: Embedded Schema with Tags
Currently, we have the Task entity. Now we are going to add a list of tags (tags), which will simply be a list of strings. Here is where it gets interesting: in a pure relational schema, tags would live in a separate table with a pivot table in between. In MongoDB, we can directly embed that list inside the task document.
In the data model (Pydantic), we simply add the tags field as a list. There is no additional table, no migration, no foreign key:
schemes.py
class Task(BaseModel):
name: str
description: Optional[str] = Field("No description",min_length=5)
status: StatusType
tags: List[str] = []
***
class TagsUpdate(BaseModel):
tags: List[str] The natural question is: where are we going to store those tags if there is no independent table?
In a relational model, we would strictly need a table for tasks, another for tags, and probably a task_tags pivot table with foreign keys. Not in MongoDB. Here we break away from that traditional schema.
MongoDB stores
JSONdocuments, and aJSONobject can contain anarray. That array is precisely thetagsfield. Simple and efficient.
Adding and Removing Tags
In the mongo_task.py file, we add two specific endpoints to manipulate a task's tags using its task_id. The fetching and inserting parts remain the same. We will focus on array manipulation, which is where MongoDB shines with its native operators.
mongo_task.py
# ADD TAGS
@mongo_task_router.put("/{task_id}/tags/add", summary="Add tags to a task")
async def add_tags_to_task(
task_id: str = Path(..., description="The ID of the task to update"),
tags_update: TagsUpdate = Body(..., example={"tags": ["new_tag_1", "new_tag_2"]}),
db: Database = Depends(get_mongo_database),
):
"""
Adds one or more tags to an existing task.
Uses $addToSet to prevent duplicates in the tags array.
"""
if not ObjectId.is_valid(task_id):
raise HTTPException(status_code=400, detail=f"Invalid ObjectId: {task_id}")
result = await db.tasks.update_one(
{"_id": ObjectId(task_id)},
{"$addToSet": {"tags": {"$each": tags_update.tags}}}
)
if result.matched_count == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Task with id {task_id} not found")
updated_task = await db.tasks.find_one({"_id": ObjectId(task_id)})
return updated_task
# REMOVE TAGS
@mongo_task_router.put("/{task_id}/tags/remove", summary="Remove tags from a task")
async def remove_tags_from_task(
task_id: str = Path(..., description="The ID of the task to update"),
tags_update: TagsUpdate = Body(..., example={"tags": ["tag_to_remove_1", "tag_to_remove_2"]}),
db: Database = Depends(get_mongo_database),
):
"""
Removes one or more tags from an existing task.
Uses $pull to remove instances of the specified tags.
"""
if not ObjectId.is_valid(task_id):
raise HTTPException(status_code=400, detail=f"Invalid ObjectId: {task_id}")
result = await db.tasks.update_one(
{"_id": ObjectId(task_id)},
{"$pull": {"tags": {"$in": tags_update.tags}}}
)
if result.matched_count == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Task with id {task_id} not found")
updated_task = await db.tasks.find_one({"_id": ObjectId(task_id)})
return updated_task1. Adding Tags: the $addToSet Operator
Notice that we receive an array of tags all at once. In a relational schema, if you wanted to add 10 tags, you would need to perform 10 separate inserts or a complex multi-table operation. In MongoDB, everything is done in a single atomic operation thanks to array update operators.
We use update_one with the $addToSet operator:
Why
$addToSetand not$push? The$addToSetoperator adds elements to an array only if they do not already exist, preventing duplicates atomically. If you used$push, you could end up with duplicate tags within the same document.
And to avoid iterating manually with a for loop in Python (which would generate multiple database operations), we use the $each modifier. This allows MongoDB to iterate internally and add all array values in a single write.
$addToSet + $each: the Perfect Combination
$addToSet: adds values to the array without generating duplicates.$each: iterates internally over the input array and applies$addToSetto each element.
This combination avoids having to:
- Make N API requests (one per tag).
- Manually iterate over values in Python.
- Execute multiple write operations in the database.
2. Removing Tags: the $pull Operator
To remove tags, the logic is similar, but we use the $pull operator:
- How does it work? We receive an array containing the values to remove.
- The
$inoperator identifies which of the sent tags actually exist in the document. - The
$pulloperator extracts them from the array atomically.
Internally, we use
$pulltogether with$in, which allows us to compare multiple values in a single instruction.
It is very fault-tolerant: if you send a tag that does not exist in the task, MongoDB simply ignores it without throwing any error. This greatly simplifies client-side logic.
If we check the collection after a few operations, we will see something like this:
tasks (collection)
{
"_id": {
"$oid": "699c6024ba20f652d828f93c"
},
"name": "Task 1",
"description": "No description",
"status": "done",
"category_id": 1,
"user_id": 0,
"id": 0,
"tags": [
"Tag 1",
"Tag 3",
"Tag 4"
]
}
{
"_id": {
"$oid": "699d86e1701f772d79b49f03"
},
"name": "string",
"description": "No description",
"status": "done",
"tags": [
"Tag 2",
"Tag 3"
],
"id": "string"
}Schema Flexibility: Where is the tags table?
Here is where I want you to ask yourself: where are the tags? In the relational world, you would have a Tags table, a Tasks table, and probably a task_tags pivot table. Here, they do not exist. The tags are embedded directly inside the task's JSON document.
This comes with clear advantages and disadvantages:
- The good: When you query a task, you retrieve its tags in the very same operation, without needing to perform any
JOIN. This is exceptionally efficient for read-heavy workloads. - The bad: There is no strict migration system. As you saw in the exercise, we added the
tagsfield to the model and MongoDB simply started saving it in new or updated documents. Previous documents without that field will continue to exist as-is, which can lead to inconsistencies if not handled properly in code.
How Tags Are Stored in MongoDB
MongoDB stores documents in JSON format (internally BSON):
{
"id": 1,
"title": "Task 1",
"tags": ["tag1", "tag2"]
}The tags are embedded inside the task document itself. There is no external table, collection, or reference. When you query the task, you get all its tags in a single read operation.
Relationships in MongoDB
Let's consolidate what we've learned. In the previous example, we are working with a Many-to-Many (N:N) relationship, although in an unconventional way for those coming from the SQL world.
Why is it Many-to-Many? Let's see it in practice:
We have a task with tags Tag 1, Tag 3, and Tag 4, and another task without any. If we add Tag 3 to that second task, both now share the same tag.
I know you might be wondering: "Isn't this a hack? There are no foreign keys or relational indexes." And this is where you need to open your mind: MongoDB is not a relational database. The link is not an ID pointing to another table; the value itself is the link. If the string "Tag 3" is identical in both documents, a functional relationship exists. MongoDB is, in essence, a manager that allows us to manipulate those JSONs with a level of flexibility that an SQL schema cannot match.
Normalized vs. Denormalized Schemas
In MongoDB, we have two primary strategies for structuring data relationships:
1. Denormalized Schema (Embedded)
This is the one we are using. We store the value directly (the tag text) inside the task document.
- Advantage: You don't need intermediate collections or
JOINoperations. Fetching the task gives you all the information in a single read. - Ideal for: Data that is frequently read together and does not change independently.
2. Normalized Schema (Referenced)
This is the equivalent of a relational schema. Instead of storing the text "Tag 3" directly, we store the ObjectId referencing a document in a separate collection named tags.
- Structure: You would have an array of
ObjectIds calledtag_idsinside the task document. - When to use it: Recommended when the related entity undergoes frequent independent updates. If you change a tag's name in its own collection, the change is automatically reflected across all referencing documents because they only store the
ObjectId.
When to Use Each Strategy? (1:1, 1:N, and N:N)
The decision depends on your business logic and how frequently related data is updated:
- One-to-Many Relationship (1:N): Like categories. If a task belongs to a category, instead of an array, you would simply have a
categoryfield with the name (denormalized) or anObjectIdreferencing thecategoriescollection. - One-to-One Relationship (1:1): Classic example: Users and Addresses. Since an address is usually unique to a user and always accessed along with them, it makes the most sense for the address schema to live embedded inside the
userobject. There's no point in creating a separate collection for something that won't be shared or queried independently.
Bulk Operations: update_one vs. update_many
If you use a denormalized schema and need to rename a tag across all tasks containing it, you cannot use update_one (which affects only a single document). That is why update_many exists.
MongoDB provides these methods precisely because of its flexible nature. If you have 1,000 tasks with the tag "Old" and want to rename them to "New", you run an update_many with a filter that matches that value and replaces it across the entire collection in one hit, without needing to iterate from your code.
Conclusion and Practice
The best way to absorb MongoDB is to permanently break away from the mental mindset of relational tables. Don't think in rows and columns; think in nested, flexible JSON documents.
As an exercise, I challenge you to try implementing the code for an update_many following the structure of this project. You can also check the official repository to compare your solution with mine. Additionally, try creating a model where addresses are an embedded object within a user document, or simulate a 1:N relationship with categories using references (ObjectId). Only by practicing will you understand when to leverage the speed of denormalization versus the integrity of normalization.
Source code:
https://github.com/libredesarrollo/fastapi-book-course-mongodb