~/TechPurAI
~/tutorials/django-from-scratch/model-relationships
intermediate·part 11 of 22·3 min read

Model relationships: ForeignKey and related lookups

Updated Aug 31, 2026Python · Django

Post.author has been a ForeignKey since part 5 without much explanation of what that buys beyond storing a reference. This part adds a second model, Comment, explicitly to make relationships — and querying across them in both directions — concrete.

The Comment model

python
# blog/models.py
class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    name = models.CharField(max_length=80)
    body = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at"]

    def __str__(self):
        return f"Comment by {self.name} on {self.post}"
bash
python manage.py makemigrations blog
python manage.py migrate

related_name="comments" is what makes the reverse lookup below read as post.comments instead of Django's default guess, post.comment_set — worth setting explicitly on every ForeignKey, since the default is a genuinely awkward name to write in a template.

Querying forward: from comment to post

python
comment = Comment.objects.first()
comment.post          # <Post: My First Post>
comment.post.title    # "My First Post"

Going from the "many" side to the "one" side — many comments, one post each — is just attribute access. comment.post runs a query the first time it's accessed and caches the result, so comment.post.title right after doesn't trigger a second one.

Querying backward: from post to its comments

python
post = Post.objects.get(slug="my-first-post")
post.comments.all()
post.comments.count()

This is exactly what related_name set up: post.comments is a manager, the same kind Post.objects is, scoped to only this post's comments. Without related_name, this would be post.comment_set.all() instead — functionally identical, just harder to read.

Showing comments on the detail page

django
{# blog/templates/blog/post_detail.html — inside {% block content %} #}
<h3>Comments ({{ post.comments.count }})</h3>
{% for comment in post.comments.all %}
  <p><strong>{{ comment.name }}</strong>: {{ comment.body }}</p>
{% empty %}
  <p>No comments yet.</p>
{% endfor %}

post.comments.all in a template — no parentheses. Django's template engine calls a method automatically when it encounters one with no arguments; post.comments.all() in Python becomes post.comments.all in the template.

Registering Comment in the admin

python
# blog/admin.py
from .models import Post, Comment

admin.site.register(Comment)
on_delete matters

on_delete=models.CASCADE on Comment.post means deleting a post deletes every comment on it too — almost always the right call here. The alternative, models.SET_NULL (which requires the field to allow null=True), keeps the comment but clears the link — better suited to something like a post's optional "featured image" reference, where losing the image shouldn't delete the post.

FAQ

When would I use ManyToManyField instead of ForeignKey? ForeignKey is for a "many-to-one" relationship — many comments, one post each, as here. ManyToManyField fits a relationship where each side can have several of the other — tags on a post, where one post has multiple tags and one tag applies to multiple posts. Django creates a hidden join table automatically for a ManyToManyField, which a ForeignKey never needs.

Does looping over post.comments.all() in a template run one query per comment? No, in this specific case — post.comments.all() runs one query returning every comment for that post, then the template loop iterates over the already-fetched results in memory. The N+1 query problem shows up in a different pattern: looping over many posts and accessing post.comments.all inside that loop, which does run one query per post unless prefetch_related() is used.

What does related_name actually change if I don't set it? Nothing breaks without it — Django generates comment_set automatically. related_name="comments" only changes what that reverse accessor is called, purely for readability in templates and code; it has no effect on what data is returned.

Next: a real form for submitting a comment, instead of only the admin being able to add one.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous10. URLs with parameters: primary keys, slugs, and get_object_or_404next →12. Django forms: rendering, validation, and cleaned data