Build a Personal Diary API
Build a REST API for diary entries using Django REST Framework.
Introduction
30 Second Summary
Journaling apps feel personal, but under the hood they all rely on the same thing: an API that stores, retrieves, and organizes your entries. Understanding how that API works is the foundation of modern web development.
In this project, you will build a personal diary REST API using Django and Django REST Framework. You'll define models, create serializers, and expose full CRUD endpoints that you can interact with directly in your browser.
What You'll Build
Picture opening your browser, typing a URL, and seeing a clean interactive page where you can create a diary entry, tag it with "travel" or "work", and watch it appear in a formatted JSON list instantly.
By the end of this project, you'll have:
- A REST API that lets you create, read, update, and delete diary entries by POSTing JSON to your local server and seeing entries instantly appear in the list.
- A many-to-many tagging system where each entry can carry multiple labels like "work" or "personal", with tags rendered as full objects inline on each entry.
- A browsable API interface powered by Django REST Framework that lets you create, browse, update, and delete entries through a visual web page with no extra tools needed.
- Secret Mission: Add filtering by mood and full-text search so you can find specific entries without scrolling through everything.
Are there any prerequisites?
This project assumes basic Python knowledge (variables, functions, classes) and familiarity with the command line (navigating folders, running commands). You do not need any prior Django experience.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Django Project
Every REST API needs a foundation before you can build endpoints. Without a working Django project and Django REST Framework installed, there is nothing to serve or serialize.
In this step, you will create a project directory, set up a Python virtual environment, install Django and DRF, and scaffold the project structure. By the end, a running development server proves everything is wired up correctly.
In this step, get ready to:
- Create a project directory with a virtual environment.
- Install Django and Django REST Framework.
- Scaffold the Django project and confirm it runs.
Create the project directory and install dependencies
You need an isolated folder on your Desktop and a virtual environment to keep this project's packages separate from everything else on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir diary-api
cd diary-api
Your terminal prompt now shows you are inside the diary-api folder.
- Create and activate a virtual environment by running these commands:
macOS
python3 -m venv .venv
source .venv/bin/activate
Windows
python -m venv .venv
.venv\Scripts\activate
You should see (.venv) at the start of your terminal prompt. This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated Python installation. Packages you install inside it stay separate from your system Python, so different projects never clash over dependency versions.
- Install Django and Django REST Framework by running:
pip install django==6.0.7 djangorestframework==3.17.1
After a few seconds, pip prints a line starting with Successfully installed followed by the package names and versions.
Seeing 'pip: command not found'?
- Make sure you activated the virtual environment first. You should see (.venv) in your prompt.
- On some systems, try pip3 instead of pip.
Help me troubleshoot my pip installation inside a virtual environment.
Scaffold the Django project and create the entries app
Django provides a command-line tool that generates the boilerplate files every project needs. You will also create an app called entries to hold your diary entry code.
- Scaffold the Django project by running:
django-admin startproject diary .
What does the trailing dot do?
The dot tells Django to place manage.py and the diary/ package directly in your current folder. Without it, Django creates an extra nested directory that makes paths confusing.
- Create the entries app by running:
python manage.py startapp entries
- Confirm both were created by running:
ls
You should see manage.py, a diary/ folder, and an entries/ folder in the output.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the diary-api folder on your Desktop, then click Open.
- In the Explorer sidebar, expand the diary folder and open settings.py.
- Find the INSTALLED_APPS list. Add "rest_framework" and "entries" at the end so it matches this:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"entries",
]
What do these two entries do?
- "rest_framework" activates Django REST Framework. It gives you serializers, viewsets, routers, and the browsable API.
- "entries" registers your new app with Django so it can discover models, migrations, and admin config inside that folder.
- Save diary/settings.py.
Seeing a typo error later when you run the server?
- Double-check that each app name is in quotes and separated by a comma.
- The names are case-sensitive. Use "rest_framework" with an underscore, not a hyphen.
- Make sure there is a comma after "django.contrib.staticfiles" and after "rest_framework".
Help me fix my INSTALLED_APPS configuration.
✔️ Awesome, I've got everything!
Great. Double check you have saved diary/settings.py before continuing.
ⓧ I'd like to double check the full code
Your INSTALLED_APPS in diary/settings.py should look exactly like this:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"entries",
]
Run the development server
Everything is installed, scaffolded, and registered. Time to start the server and see Django respond in your browser.
- Start the development server by running:
python manage.py runserver
What about migration warnings?
You will see a warning about unapplied migrations. That is expected. You will run migrations in the next step when you define your first model.
Before you open the browser, what do you think you will see at the root URL of a brand new Django project?
- Open your browser and navigate to http://127.0.0.1:8000/.
You should see Django's default welcome page with the message "The install worked successfully!" This confirms Django is running and serving responses.
Don't see the welcome page?
- Check that the terminal still shows the server running (no crash output). If it crashed, read the error message for a clue about a typo in settings.py.
- Make sure you are visiting http://127.0.0.1:8000/ and not https://.
- If you see "port already in use", another process is on port 8000. Stop it or run python manage.py runserver 8001 and visit port 8001 instead.
Help me fix my Django development server.
Your Django project is alive and responding. Next up, you will define the Entry model and create the database tables your API will read and write.
Define the Entry Model and Run Migrations
Your Django project is running, but it has no data to serve yet. A REST API needs something to read and write.
In this step, you will define an Entry model that represents a single diary entry in the database. Then you will run migrations to create the actual database tables that store your entries.
In this step, get ready to:
- Define an Entry model with title, body, mood, and timestamp fields.
- Run migrations to create the database table.
- Register the model in the Django admin.
Define the Entry model
A Django model is a Python class that maps to a database table. Each attribute on the class becomes a column in the table.
- In VS Code, open entries/models.py inside your diary-api folder.
- Replace the file's contents with the following code:
from django.db import models
class Entry(models.Model):
# Mood options the user can pick from
MOOD_CHOICES = [
("happy", "Happy"),
("sad", "Sad"),
("neutral", "Neutral"),
("excited", "Excited"),
("anxious", "Anxious"),
]
title = models.CharField(max_length=200)
body = models.TextField()
# Restrict mood to predefined choices
mood = models.CharField(max_length=10, choices=MOOD_CHOICES, default="neutral")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
verbose_name_plural = "entries"
def __str__(self):
return self.title
What does this code do?
Each attribute defines a database column:
- title is a short string (max 200 characters) for the entry heading.
- body is unlimited-length text for the diary content.
- mood is locked to five choices so the API rejects invalid values.
- created_at is automatically set when an entry is first saved. updated_at refreshes on every save.
- The Meta class tells Django to return entries newest-first by default.
- Save entries/models.py.
Run migrations to create the database table
Django does not touch the database until you explicitly run a migration. The makemigrations command detects model changes and writes a migration file. The migrate command applies that file to create the actual table.
- If the development server is still running in your terminal, press Ctrl+C to stop it.
- Generate the migration file by running:
python manage.py makemigrations entries
You should see output showing that Django detected the new Entry model and created a migration file.
- Apply the migration to create the database table by running:
python manage.py migrate
You should see a list of migrations being applied, ending with OK.
What is a migration?
A migration is a version-controlled record of a database change. Instead of writing raw SQL to create tables, Django generates migration files that describe the change in Python.
This means you can track database changes in Git just like code changes. If you ever add or remove a field, Django handles the ALTER TABLE for you.
Seeing "No changes detected"?
This means Django did not find a new model to migrate. Check that:
- You saved entries/models.py before running the command.
- entries is listed in INSTALLED_APPS in diary/settings.py.
- Your class inherits from models.Model (not just object).
help me fix "No changes detected" when running makemigrations
Register the model in the Django admin
Registering the model makes it visible in Django's built-in admin interface. This gives you a quick way to manage entries without building a custom UI.
- Open entries/admin.py and replace its contents with:
from django.contrib import admin
from .models import Entry
# Make Entry visible in the Django admin panel
admin.site.register(Entry)
What does this code do?
admin.site.register(Entry) tells Django to add an Entry section to the admin dashboard at /admin/. You will not use the admin in this project, but registering models is a good habit for debugging and data inspection.
- Save entries/admin.py.
Before you run the next command, what do you think will happen if you query Entry.objects.count() on a table that exists but has no rows?
- Verify the table exists by opening the Django shell:
python manage.py shell
- In the shell, run this query:
from entries.models import Entry
print(Entry.objects.count())
It prints 0 without errors. That confirms the entries_entry table exists in your database and is ready to accept data.
- Exit the shell by typing:
exit()
Seeing a "no such table" error?
This means the migration did not apply successfully. Run python manage.py migrate again and look for error messages. If it says there are no migrations to apply, check that you ran python manage.py makemigrations entries first.
help me fix a "no such table" error in Django shell
✔️ Awesome, I've got everything!
Great. Double check you have saved both entries/models.py and entries/admin.py.
ⓧ I'd like to double check the full code
Here is entries/models.py in full:
from django.db import models
class Entry(models.Model):
MOOD_CHOICES = [
("happy", "Happy"),
("sad", "Sad"),
("neutral", "Neutral"),
("excited", "Excited"),
("anxious", "Anxious"),
]
title = models.CharField(max_length=200)
body = models.TextField()
mood = models.CharField(max_length=10, choices=MOOD_CHOICES, default="neutral")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
verbose_name_plural = "entries"
def __str__(self):
return self.title
Here is entries/admin.py in full:
from django.contrib import admin
from .models import Entry
admin.site.register(Entry)
Your database table is live and ready for data. Next up, you will build the serializer and viewset that expose this model as a REST API endpoint you can interact with in the browser.
Create a Serializer and ViewSet for Entries
Your Entry model is defined and your database tables are ready. But right now, there is no way to access your entries over HTTP.
A serializer converts Entry objects to JSON (and back). A viewset handles all CRUD operations in one class. Together with a router, they give your API working endpoints that you can interact with directly in the browser.
In this step, get ready to:
- Create a serializer that converts Entry objects to JSON.
- Create a viewset that provides full CRUD for entries.
- Wire up URL routing so the API is accessible in the browser.
Create the entry serializer
A serializer defines how your model's fields map to JSON. Django REST Framework's ModelSerializer reads your model and generates all the conversion logic automatically.
- Create a new file called entries/serializers.py in your entries app folder, then add the following code:
from rest_framework import serializers
from .models import Entry
class EntrySerializer(serializers.ModelSerializer):
class Meta:
model = Entry
fields = '__all__'
What does this code do?
- EntrySerializer extends ModelSerializer, which automatically generates fields matching your Entry model.
- fields = '__all__' tells DRF to include every field on the model (title, body, mood, created_at, updated_at) in the JSON representation.
- Save entries/serializers.py.
- Confirm the serializer loads correctly by running this in your terminal:
python manage.py shell -c "from entries.serializers import EntrySerializer; print(EntrySerializer())"
You should see a printout listing all the fields on your serializer (id, title, body, mood, created_at, updated_at).
Seeing an ImportError?
- Check that your file is named exactly serializers.py (not serializer.py) inside the entries/ folder.
- Make sure 'rest_framework' is in your INSTALLED_APPS in diary/settings.py.
- Confirm your virtual environment is still active (you should see (.venv) in your terminal prompt).
Why isn't my serializer importing correctly?
✔️ Awesome, I've got everything!
Great. Make sure you have saved entries/serializers.py before moving on.
ⓧ I'd like to double check the full code
from rest_framework import serializers
from .models import Entry
class EntrySerializer(serializers.ModelSerializer):
class Meta:
model = Entry
fields = '__all__'
Create the entry viewset
A viewset groups all the actions you can perform on a resource (list, create, retrieve, update, delete) into one class. ModelViewSet provides all five actions out of the box.
- Open entries/views.py and replace its contents with the following code:
from rest_framework import viewsets
from .models import Entry
from .serializers import EntrySerializer
class EntryViewSet(viewsets.ModelViewSet):
queryset = Entry.objects.all()
serializer_class = EntrySerializer
What does this code do?
- EntryViewSet extends ModelViewSet, which automatically provides list, create, retrieve, update, and delete actions.
- queryset tells DRF which database records this viewset operates on (all entries).
- serializer_class tells DRF how to convert those records to and from JSON.
- Save entries/views.py.
- Verify the viewset loads correctly by running this in your terminal:
python manage.py shell -c "from entries.views import EntryViewSet; print(EntryViewSet.queryset.model.__name__)"
You should see Entry printed, confirming the viewset correctly points to your model through the serializer.
Seeing a ModuleNotFoundError?
- The import from .serializers import EntrySerializer in views.py depends on the file you created in the previous substep. Confirm entries/serializers.py exists and is saved.
- Check that the class is named EntrySerializer (capital E, capital S) in your serializers file.
Why is my viewset failing to import the serializer?
✔️ Awesome, I've got everything!
Great. Make sure you have saved entries/views.py before moving on.
ⓧ I'd like to double check the full code
from rest_framework import viewsets
from .models import Entry
from .serializers import EntrySerializer
class EntryViewSet(viewsets.ModelViewSet):
queryset = Entry.objects.all()
serializer_class = EntrySerializer
Wire up URL routing
Your viewset knows how to handle requests, but Django does not know which URLs should route to it. DRF's DefaultRouter automatically generates URL patterns for all the standard CRUD actions.
- Create a new file called entries/urls.py in your entries app folder, then add the following code:
from rest_framework.routers import DefaultRouter
from .views import EntryViewSet
router = DefaultRouter()
router.register(r"entries", EntryViewSet)
urlpatterns = router.urls
What does this code do?
- DefaultRouter() creates a router that automatically builds URL patterns for list, detail, create, update, and delete.
- router.register(r"entries", EntryViewSet) registers your viewset under the /entries/ path prefix.
- urlpatterns = router.urls exposes the generated patterns so Django can find them.
- Save entries/urls.py.
Now you need to include these app-level URLs in the main project URL configuration.
- Open diary/urls.py and replace its contents with the following code:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("entries.urls")),
]
What does this code do?
- include("entries.urls") tells Django to delegate any URL starting with api/ to the URL patterns you defined in entries/urls.py.
- The full path to your entry list becomes /api/entries/ (the api/ prefix from here, plus the entries/ prefix from the router).
- Save diary/urls.py.
Before you run the server, what do you think you will see when you visit http://127.0.0.1:8000/api/entries/ in the browser? An error, a blank page, or something else?
- Start the development server by running:
python manage.py runserver
- Open http://127.0.0.1:8000/api/entries/ in your browser.
You see the DRF browsable API page with an empty list of entries and a form at the bottom to create one. This is DRF's built-in web interface for testing your endpoints directly in the browser.
Seeing a Page not found (404) error?
- Make sure the URL is exactly http://127.0.0.1:8000/api/entries/ with the trailing slash.
- Check that diary/urls.py has path("api/", include("entries.urls")) and that you imported include from django.urls.
- Confirm entries/urls.py has urlpatterns = router.urls (not just the router registration).
Why is my browsable API returning a 404?
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved.
ⓧ I'd like to double check the full code
from rest_framework.routers import DefaultRouter
from .views import EntryViewSet
router = DefaultRouter()
router.register(r"entries", EntryViewSet)
urlpatterns = router.urls
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("entries.urls")),
]
Your API is live. Next up, you will add a Tag model with a many-to-many relationship so you can categorize your entries.
Add a Tag Model with a Many-to-Many Relationship
Your entries currently exist in isolation. If you had dozens of diary entries, there would be no way to group them by topic or category.
A Tag model with a many-to-many relationship solves this. One entry can have multiple tags, and one tag can belong to multiple entries. In this step, you will define the Tag model, connect it to entries, and expose it through a new API endpoint.
In this step, get ready to:
- Define a Tag model and link it to entries with a ManyToManyField.
- Create a TagSerializer, TagViewSet, and register the new endpoint.
- Observe how DRF displays many-to-many relationships by default.
Define the Tag model and link it to entries
The Tag model needs to exist before Entry can reference it. You will add it above the Entry class in the same file.
- In VS Code, open entries/models.py.
- Add the following Tag class above the Entry class (below the from django.db import models import):
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.name
What does this code do?
- Tag is a simple model with one field: name.
- unique=True prevents duplicate tag names. You cannot create two tags both called "work".
- __str__ returns the tag name so it displays readably in the admin and shell.
Now link Tag to Entry. Each entry can have zero or more tags.
- In the Entry class, find the mood field line:
mood = models.CharField(max_length=10, choices=MOOD_CHOICES, default="neutral")
- Add this new tags field on the next line directly below it, so it looks like this:
mood = models.CharField(max_length=10, choices=MOOD_CHOICES, default="neutral")
tags = models.ManyToManyField(Tag, blank=True, related_name="entries")
What is a ManyToManyField?
A ManyToManyField creates a join table behind the scenes. Django manages this table automatically so you never write SQL for it.
blank=True means entries are allowed to have no tags. related_name="entries" lets you query all entries for a given tag with tag.entries.all().
- Save entries/models.py.
- Create the migration by running:
python manage.py makemigrations entries
You should see output confirming a new migration was created for the Tag model and the tags field on Entry.
- Apply the migration by running:
python manage.py migrate
You should see Django applying the new migration successfully.
Seeing an error about Tag not being defined?
The Tag class must appear above the Entry class in entries/models.py. Python reads the file top to bottom, so Entry cannot reference Tag if Tag is defined below it.
help me fix a NameError in my Django models file
✔️ Awesome, I've got everything!
Great. Double check you have saved entries/models.py and both migration commands ran without errors.
ⓧ I'd like to double check the full code
Your entries/models.py should have these sections in order:
- The from django.db import models import at the top.
- The Tag class with a name field and __str__ method.
- The Entry class with MOOD_CHOICES, all original fields, plus the new tags ManyToManyField between mood and created_at.
- The Meta class and __str__ method on Entry unchanged.
Create the Tag serializer and viewset
The Tag model exists in the database, but it has no API endpoint yet. You need a serializer to convert Tag objects to JSON, and a viewset to handle requests.
- Open entries/serializers.py and replace its entire contents with the following:
from rest_framework import serializers
from .models import Entry, Tag
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ["id", "name"]
class EntrySerializer(serializers.ModelSerializer):
class Meta:
model = Entry
fields = '__all__'
What does this code do?
- TagSerializer exposes the id and name fields of each tag as JSON.
- EntrySerializer still uses fields = '__all__', which now automatically includes the new tags field from the model.
- Save entries/serializers.py.
- Open entries/views.py and replace its entire contents with the following:
from rest_framework import viewsets
from .models import Entry, Tag
from .serializers import EntrySerializer, TagSerializer
class EntryViewSet(viewsets.ModelViewSet):
queryset = Entry.objects.all()
serializer_class = EntrySerializer
class TagViewSet(viewsets.ModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagSerializer
What does TagViewSet do?
TagViewSet gives you full CRUD (create, read, update, delete) for tags with zero custom logic. ModelViewSet provides all the actions automatically based on the queryset and serializer you specify.
- Save entries/views.py.
- Confirm your code has no issues by running:
python manage.py check
You should see System check identified no issues (0 silenced). confirming your models, serializers, and views are wired correctly.
Seeing an ImportError?
Make sure you updated the import line in entries/views.py to include both Tag and TagSerializer. A missing import causes Django to fail the system check.
help me fix an ImportError in my Django views file
- Open entries/urls.py and replace its entire contents with:
from rest_framework.routers import DefaultRouter
from .views import EntryViewSet, TagViewSet
router = DefaultRouter()
router.register(r"entries", EntryViewSet)
router.register(r"tags", TagViewSet)
urlpatterns = router.urls
- Save entries/urls.py.
- Open entries/admin.py and replace its entire contents with:
from django.contrib import admin
from .models import Entry, Tag
admin.site.register(Entry)
admin.site.register(Tag)
Why register Tag in the admin?
Registering Tag in the admin gives you a fallback UI for creating and managing tags outside the API. It is optional for the API to work, but helpful during development.
- Save entries/admin.py.
- Start the development server by running:
python manage.py runserver
- Open http://127.0.0.1:8000/api/tags/ in your browser.
You should see the DRF browsable API page for tags with an empty list and a form to create a new tag.
Seeing a 404 at /api/tags/?
Check that entries/urls.py has router.register(r"tags", TagViewSet) and that TagViewSet is imported from .views. Also confirm diary/urls.py still includes entries.urls under the api/ prefix.
help me fix my DRF router registration for the tags endpoint
✔️ Awesome, I've got everything!
Great. You now have both /api/entries/ and /api/tags/ endpoints working.
ⓧ I'd like to double check the full code
Here is the full entries/serializers.py:
from rest_framework import serializers
from .models import Entry, Tag
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ["id", "name"]
class EntrySerializer(serializers.ModelSerializer):
class Meta:
model = Entry
fields = '__all__'
Here is the full entries/views.py:
from rest_framework import viewsets
from .models import Entry, Tag
from .serializers import EntrySerializer, TagSerializer
class EntryViewSet(viewsets.ModelViewSet):
queryset = Entry.objects.all()
serializer_class = EntrySerializer
class TagViewSet(viewsets.ModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagSerializer
Here is the full entries/urls.py:
from rest_framework.routers import DefaultRouter
from .views import EntryViewSet, TagViewSet
router = DefaultRouter()
router.register(r"entries", EntryViewSet)
router.register(r"tags", TagViewSet)
urlpatterns = router.urls
Here is the full entries/admin.py:
from django.contrib import admin
from .models import Entry, Tag
admin.site.register(Entry)
admin.site.register(Tag)
Test the tag relationship in the browsable API
Both endpoints work individually. Now test what happens when you connect them through the many-to-many relationship.
- In your browser at http://127.0.0.1:8000/api/tags/, use the HTML form at the bottom to create a tag with the name work.
- Click POST to save it.
- Navigate to http://127.0.0.1:8000/api/entries/.
- In the HTML form, fill in a title and body. In the Tags field, select the work tag you just created.
- Click POST to create the entry.
Before you look at the JSON response, predict: what do you think the tags field will look like? Will it show the tag name, the full tag object, or something else?
Look at the JSON response. The tags field shows [1]. That is just the tag's database ID. It tells you nothing about what that tag actually is.
Why does DRF show IDs instead of names?
By default, DRF represents a ManyToManyField using PrimaryKeyRelatedField. This outputs the integer IDs of related objects. It is efficient for machines but useless for humans reading the API response.
This is the gap you will close in the next step using nested serialization.
Your API now has two connected models and two working endpoints. In the next step, you will swap those bare IDs for full tag objects using nested serialization so the API response is actually readable.
Improve Tag Display with Nested Serialization
In the previous step, you connected tags to entries and saw them appear in your API responses. The problem is that they show up as bare IDs like [1, 2]. A number tells you nothing about what the tag actually means.
Nested serialization solves this by embedding the full tag object inline in each entry's response. This is the standard Django REST Framework pattern for making relationship data human-readable. You will set up separate read and write paths so the API shows rich tag objects when you fetch entries, but still accepts simple IDs when you create or update them.
In this step, get ready to:
- Create a read-only nested serializer for tags.
- Add a write-only field that accepts tag IDs for creating and updating entries.
- Verify nested tag objects appear in API responses.
Create a read-only TagNestedSerializer
A nested serializer is a dedicated class whose only job is to render related objects inline. By marking all its fields as read-only, you guarantee it is used purely for display and never accidentally writes data.
- In entries/serializers.py, find the existing TagSerializer class. Add the following new class directly below it:
class TagNestedSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ["id", "name"]
read_only_fields = ["id", "name"]
What does this code do?
TagNestedSerializer defines how a tag looks when embedded inside an entry response. It exposes id and name so readers see both the identifier and the human-friendly label.
The read_only_fields line ensures this serializer is never used to accept input. It exists purely for display.
- Save entries/serializers.py. This serializer takes effect in the next chunk when you plug it into EntrySerializer.
Update EntrySerializer with read and write fields
DRF needs two paths for the same relationship. The read path uses your nested serializer to display full tag objects. The write path uses a PrimaryKeyRelatedField so clients can still send simple IDs when creating or updating entries.
- In the same file, find the EntrySerializer class. Replace it entirely with this updated version:
class EntrySerializer(serializers.ModelSerializer):
# Display full tag objects when reading entries
tags = TagNestedSerializer(many=True, read_only=True)
# Accept tag IDs when creating or updating entries
tag_ids = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Tag.objects.all(),
source="tags",
write_only=True,
required=False,
)
class Meta:
model = Entry
fields = [
"id",
"title",
"body",
"mood",
"tags",
"tag_ids",
"created_at",
"updated_at",
]
What does this code do?
- tags = TagNestedSerializer(many=True, read_only=True) tells DRF to render each tag as a full object (with id and name) whenever you read an entry.
- tag_ids is a write-only field. When you POST or PUT an entry, you send tag IDs (like [1, 2]) and source="tags" maps those IDs to the actual tags relationship on the model.
- The explicit fields list replaces '__all__' so that both tags (read) and tag_ids (write) are included in the serializer.
- Save entries/serializers.py.
- Switch back to the terminal running your server. Stop it with Ctrl+C and restart it by running:
python manage.py runserver
- Open http://127.0.0.1:8000/api/entries/ in your browser. You should see the DRF browsable API page load without errors.
Seeing an error when the page loads?
- Check that TagNestedSerializer is defined ABOVE EntrySerializer in the file. Python reads top to bottom, so the nested serializer must exist before it is referenced.
- Confirm source="tags" on the tag_ids field matches the model field name exactly. A typo here causes a startup error.
Help me debug my EntrySerializer nested serialization setup.
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
Your entries/serializers.py should contain these three classes in order:
- TagSerializer (unchanged from the previous step).
- TagNestedSerializer (the new read-only class you just added).
- EntrySerializer (updated with tags and tag_ids fields, plus the explicit fields list in Meta).
Scroll up and confirm each class matches the code blocks above.
Test nested tags in the browsable API
Time to see nested serialization in action. You will create a tag, assign it to an entry, and confirm the response shows the full tag object instead of a bare ID.
- Open http://127.0.0.1:8000/api/tags/ in your browser.
- If you do not already have a tag, use the form at the bottom of the page to create one. Enter work in the Name field and click POST.
- Note the id value from the response (likely 1).
- Open http://127.0.0.1:8000/api/entries/.
- In the form at the bottom, fill in a Title and Body. In the Tag ids field, enter the tag ID you noted (e.g. 1).
- Click POST.
Before you look at the response, predict what the tags field will show. Will it be a bare ID like [1], or something richer?
The JSON response now shows nested tag objects instead of IDs:
{
"id": 1,
"title": "My first entry",
"body": "Today was productive.",
"mood": "neutral",
"tags": [
{"id": 1, "name": "work"}
],
"created_at": "...",
"updated_at": "..."
}
Why does tag_ids not appear in the response?
The tag_ids field is marked write_only=True. DRF only includes it during input (POST/PUT). On output, it is hidden and tags takes over with the nested display.
Tags field still showing empty or IDs?
- If tags is an empty list, confirm you entered a valid tag ID in the Tag ids field when creating the entry.
- If you see IDs instead of objects, make sure your EntrySerializer has tags = TagNestedSerializer(many=True, read_only=True) defined above the Meta class.
- Restart the server after saving to pick up code changes.
Help me debug why my tags aren't showing as nested objects.
Secret mission
Add Filtering and Search
Your diary API works great for a few entries, but what happens when you have dozens? Right now there is no way to narrow results without scrolling through everything. In this mission, you will add mood filtering and full-text search so you can find exactly what you are looking for.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The diary-api project folder on your Desktop (contains all source code, the virtual environment, and the SQLite database).
- Python packages (Django, Django REST Framework, django-filter) installed inside the .venv virtual environment.
- The db.sqlite3 database file containing your diary entries and tags.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your diary API.
- Your project folder, virtual environment, and database all stay on your Desktop.
- To resume work later, open a terminal in the diary-api folder, activate the virtual environment, and run python manage.py runserver.
Pause - I'll come back to this later
Stop the development server to free up the terminal. Your files and database stay intact for next time.
- If the development server is still running, press Ctrl+C in the terminal to stop it.
- Deactivate the virtual environment by running:
deactivate
Your project folder stays on your Desktop. When you come back, navigate to the folder, activate the virtual environment, and start the server again.
Delete - I don't want to use this again
Remove all project files, the virtual environment, and the database permanently.
- If the development server is still running, press Ctrl+C in the terminal to stop it.
- Deactivate the virtual environment by running:
deactivate
- Delete the entire project folder by running:
macOS
cd ~/Desktop
rm -rf diary-api
Windows
cd ~/Desktop
rmdir /s /q diary-api
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see diary-api in the output. This removes all code, the virtual environment with its packages, and the SQLite database.
That's a Wrap!
That's a Wrap!
Nice work. You just built a fully functional personal diary REST API using Django and Django REST Framework. You can create, read, update, and delete diary entries right from your browser, complete with tagged categories and nested JSON responses.
You've learned how to:
- Define Django models with field types, choices, and a many-to-many relationship, then run migrations to create database tables.
- Create serializers that convert between Python objects and JSON, including nested serialization for human-readable relationship data.
- Use ModelViewSet and DefaultRouter to expose full CRUD endpoints with minimal code, all testable through DRF's browsable API.
- Secret Mission: Added filtering by mood and full-text search using django-filter and DRF's SearchFilter.
Ready to quiz yourself?