Nested Structure Responses#

This is the 14th post in the Django Ninja tutorial series.
In API development, we often encounter situations where data between related models needs to be returned simultaneously.
When dealing with one-to-one or one-to-many relationships in particular, a multi-layered structure is often the norm.
We hope to return data in a nested structure (Nested Objects) so that API users can retrieve the necessary information at once without making multiple requests.
This article will continue to use and extend the "Get Single Post" API example to explain how to implement nested structure responses in Django Ninja, making our API responses richer and more systematic.
The code changes made to the example project in this article are concentrated in this PR.
GitHub Example Project#
1. Background of the Problem#
In the previous API design, the response for "Get Single Post" included post information and the author's ID:
class PostResponse(Schema):
id: int
title: str
content: str
author_id: int
created_at: datetime
updated_at: datetime
Experienced developers know that whether it is id or author_id, they are usually not for the service users to view—but for frontend developers to utilize flexibly.
For example, on a system screen, a post might include a link to the author's personal profile. Clicking it allows viewing the author's information. At this point, the frontend must use the ID to call another API "Get User Info" to obtain additional content.
If there is a lot of additional information, this "decoupled" design is very reasonable. However, if we want to present the author's "necessary information" together, the design of calling APIs multiple times feels a bit sluggish.
Therefore, we need nested structures!
The API can directly embed the author's "necessary information" in the response, so users don't have to make multiple requests anymore. Here, we take displaying the author's "username" and "email" together as an example.
2. API Improvement: Redefining Schema#
Changing the Response content and structure requires only one step: redefining PostResponse:
from ninja import Schema
from datetime import datetime
class _AuthorInfo(Schema):
id: int
username: str
email: str
class PostResponse(Schema):
id: int
title: str
content: str
author: _AuthorInfo # Nested structure containing author info
created_at: datetime
updated_at: datetime
_AuthorInfo contains the author's id, username, and email, and embeds this structure into the author field of PostResponse (renamed from author_id because the information content is now different).
As a reminder, we only modified PostResponse, and the view function remains the same as before without any changes:
@router.get(path='/posts/{int:post_id}/', response=PostResponse)
def get_post(request: HttpRequest, post_id: int) -> Post:
"""
Get Single Post
"""
post = Post.objects.get(id=post_id)
return post
As a result, we can simultaneously obtain the necessary information of both the post and the author.
Aside: A Small Naming Suggestion#
You might have noticed that I used the "leading underscore" naming convention for _AuthorInfo. In Python, this is a convention used to indicate that this attribute, function, or class is primarily for internal use.
The so-called "internal" can have many interpretations, and my intention here is: it is only a part of one or more Schemas and is not directly called by view functions.
Do not underestimate this naming detail. As your number of Schemas increases, when developing new APIs, you always need to browse the existing Schemas first to decide whether to redefine them or reuse existing ones.
This naming distinction is a small but considerate touch: it saves you from repeatedly scanning Schemas of every size to determine how they are used.
In practice, there are many opportunities to write nested Schemas, so I believe it is worthwhile to develop this good habit.
Updated Response: Nested Response#
Let's look at the API response:
// http://127.0.0.1:8000/posts/2/
{
"id": 2,
"title": "Alice's Django Ninja Post 1",
"content": "Alice's Django Ninja Post 1 content",
"author": {
"id": 1,
"username": "Alice",
"email": "alice@example.com"
},
"created_at": "2024-09-12T02:28:16.801Z",
"updated_at": "2024-09-12T02:28:16.801Z"
}
The new author field is a Nested Structure—exactly what we wanted.
Users can directly see the post author's name and email. If they want to see more author information, they can still use the id field to have the frontend call another API.
This is an ideal compromise.
3. "Flattening" Nested Information#
The previous "compromise" was indeed ideal. However, sometimes our needs are simpler.
For instance, in the "Get Post List" API, we might also need to display the author's information—but this time, just the name is enough.
We do not need the author ID or email; the username alone is enough.
So, why is it called "flattening nested information"? Because the author's name is not a direct attribute of the Post model; it actually comes from the related model—User.
We must simplify the nested information about the author.
Originally, it was like this:
Now it becomes:
Reducing the structure from two layers to one, while replacing the author ID with the username, is why this is called Flattening.
Schema Decoupling#
Remember that the response format of the "Get Post List" API is actually shared with "Get Single Post":
@router.get(path='/posts/', response=list[PostResponse])
def get_posts(...) -> QuerySet[Post]:
"""
Get Post List
"""
...
Both use PostResponse.
The modification of the "Get Single Post" response in the first half of this article will also affect "Get Post List"—which is usually not the result we want.
We therefore need a dedicated Response Schema for the "Get Post List" API, simplified to match the requirements above.
I plan to:
- Omit the post content (
content) and updated time (updated_at) fields, as they are not needed in the list. - Keep only the "username" for the author part.
4. Implementing Flattened Nested Information — Using @property#
Let's first see how the new Schema is defined:
You might think it is strange: where does the author_name attribute come from? The Post model doesn't have it!
Exactly! Because we defined it ourselves—using @property:
# post/models.py
class Post(models.Model):
...
@property
def author_name(self) -> str:
return self.author.username
By doing so, your Post model object will have the author_name attribute.
However, note that accessing this attribute usually means triggering a second query (since it is an attribute on a related model), so the view function should be paired with the Django QuerySet method select_related:
This is a common "N+1" issue in Django ORM, which we will not expand on here.
A Better Approach#
You might feel that this approach is not very elegant (at least that's what I thought when I first saw it!)—especially compared to how it's done in Django REST framework.
Django REST framework would write it like this in a serializer:
Isn't that much cleaner?
But this is indeed the way the author of Django Ninja initially recommended.
Don't worry, in Post 16 we will introduce a better and more modern approach. However, @property is still very useful in certain scenarios.
Flattened Response#
Finally, let's look at the new response of the "Get Post List" API:
// http://127.0.0.1:8000/posts/
[
{
"id": 1,
"title": "Alice's Django Ninja Post 1",
"created_at": "2024-09-12T02:28:16.801Z",
"author_name": "Alice" // Flattened author username
},
{
"id": 2,
"title": "Alice's Django Ninja Post 2",
// ...(hereafter omitted)
},
// ...(hereafter omitted)
]
Beautiful!
Summary#
In this article, we demonstrated how to use Schema to implement nested structure responses in Django Ninja.
Then, we introduced how to "flatten" this nested structure, replacing the original author ID with the name field.
These methods greatly increase the flexibility of API responses.
In the next article, we will discuss the different design philosophies of Django Ninja and Django REST framework in handling serialization and response structures, and compare the pros and cons of both.