Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • A OneToOne Profile model with bio and avatar
  • post_save signals versus explicit creation
  • Editing profile and user data in one page
  • Public author pages for the blog

Lesson notes

1. Extra data without forking auth

Your blog now has accounts, and the next wish arrives immediately: a short bio under every post, an avatar next to the author name, maybe a link to a personal site. You could add those fields to the user model from lesson 4.1 - and for two or three simple fields that is the right answer. A separate Profile model earns its place when:

  • you did not swap the user model in time and cannot add fields to auth.User;
  • the extra data is optional, large, or has nothing to do with authentication (uploads, preferences, social links);
  • different kinds of profile exist - an author profile and a moderator profile - and you do not want twenty mostly empty columns on the user table.
Fields on the custom userSeparate Profile model
Queries one row, always loaded a JOIN or a second query - use select_related
Existence guaranteed can be missing, which is the classic bug (section 3)
Works on a legacy project no yes, this is its main selling point
Admin a fieldset an inline
User id (pk) username email password OneToOneField (unique) user.profile / profile.user Profile user_id (pk, unique fk) bio avatar website ForeignKey (many) Post author_id, title, text One user has at most one profile, so post.author.profile.avatar is a single value

2. A OneToOne Profile model

# accounts/models.py
from django.conf import settings
from django.db import models


class Profile(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        primary_key=True,
        related_name="profile",
    )
    bio = models.TextField(max_length=500, blank=True)
    website = models.URLField(blank=True)
    avatar = models.ImageField(upload_to="avatars/%Y/%m/", blank=True)

    def __str__(self):
        return f"Profile of {self.user}"

    @property
    def display_name(self):
        return self.user.get_full_name() or self.user.get_username()

OneToOneField is a ForeignKey with unique=True and a friendlier accessor: user.profile returns the object itself, not a manager, so there is no .all() and no .first(). Django would derive that accessor from the lowercased model name anyway, but writing related_name="profile" makes it explicit. primary_key=True is optional; it saves a column and guarantees one profile per user at the database level.

ImageField needs Pillow and a place to write files:

pip install Pillow
# mysite/settings.py
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# mysite/urls.py - development only
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [...]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

3. Signals versus explicit creation

A profile row does not appear by itself. If it is missing, user.profile raises Profile.DoesNotExist - and in a template that failure is silent, so the avatar simply never shows up. There are two ways to make sure the row exists.

Option A - a post_save signal. Implicit but automatic:

# accounts/signals.py
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Profile


@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)
# accounts/apps.py
class AccountsConfig(AppConfig):
    name = "accounts"

    def ready(self):
        from . import signals  # noqa: F401

Option B - create it where the user is created. Explicit:

# accounts/views.py
from django.db import transaction

from .models import Profile


class SignUpView(CreateView):
    ...

    @transaction.atomic
    def form_valid(self, form):
        response = super().form_valid(form)
        Profile.objects.create(user=self.object)
        login(self.request, self.object)
        return response
post_save signalExplicit creation
Covers createsuperuser, fixtures, the admin, the shell yes - anything that saves a user no - you must remember every path
Easy to follow when reading the code no, the call is invisible yes
Behaviour in tests and data migrations fires unexpectedly; loaddata that contains profiles then conflicts predictable
Failure mode a signal that raises breaks user creation everywhere a forgotten call leaves a user without a profile

The pragmatic middle ground: create profiles explicitly in your own code, and make reading them impossible to break with a helper on the user or with get_or_create:

def get_profile(user):
    profile, _created = Profile.objects.get_or_create(user=user)
    return profile

Whichever you choose, backfill the users that already exist with a data migration rather than by hand:

# accounts/migrations/0003_backfill_profiles.py
from django.db import migrations


def create_missing_profiles(apps, schema_editor):
    User = apps.get_model("accounts", "User")
    Profile = apps.get_model("accounts", "Profile")
    for user in User.objects.filter(profile__isnull=True):
        Profile.objects.create(user=user)


class Migration(migrations.Migration):
    dependencies = [("accounts", "0002_profile")]
    operations = [
        migrations.RunPython(create_missing_profiles, migrations.RunPython.noop),
    ]

4. Editing the user and the profile on one page

Two models, two ModelForms, one template and one POST. Nothing clever is needed - just validate both before you save either.

# accounts/forms.py
from django import forms
from django.contrib.auth import get_user_model

from .models import Profile


class UserForm(forms.ModelForm):
    class Meta:
        model = get_user_model()
        fields = ("first_name", "last_name", "email")


class ProfileForm(forms.ModelForm):
    class Meta:
        model = Profile
        fields = ("bio", "website", "avatar")
        widgets = {"bio": forms.Textarea(attrs={"rows": 4})}
# accounts/views.py
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.shortcuts import redirect, render

from .forms import ProfileForm, UserForm


@login_required
@transaction.atomic
def profile_edit(request):
    profile = get_profile(request.user)
    if request.method == "POST":
        user_form = UserForm(request.POST, instance=request.user)
        profile_form = ProfileForm(request.POST, request.FILES, instance=profile)
        if user_form.is_valid() and profile_form.is_valid():
            user_form.save()
            profile_form.save()
            messages.success(request, "Your profile has been updated.")
            return redirect("accounts:profile_edit")
    else:
        user_form = UserForm(instance=request.user)
        profile_form = ProfileForm(instance=profile)

    return render(
        request,
        "accounts/profile_edit.html",
        {"user_form": user_form, "profile_form": profile_form},
    )
<!-- templates/accounts/profile_edit.html -->
{% extends "base.html" %}

{% block content %}
  <h1>Your profile</h1>
  <form method="post" enctype="multipart/form-data">
    {% csrf_token %}
    {{ user_form.as_p }}
    {{ profile_form.as_p }}
    <button type="submit">Save</button>
  </form>
{% endblock %}

The same pair of models is one inline in the admin:

# accounts/admin.py
class ProfileInline(admin.StackedInline):
    model = Profile
    can_delete = False


@admin.register(User)
class UserAdmin(BaseUserAdmin):
    inlines = [ProfileInline]

5. Public author pages

Now put the profile to work: a page per author listing their posts. Use select_related so the profile does not cost an extra query per row.

# blog/urls.py
path("author/<str:username>/", views.author_detail, name="author_detail"),
# blog/views.py
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404, render


def author_detail(request, username):
    author = get_object_or_404(
        get_user_model().objects.select_related("profile"), username=username
    )
    posts = author.posts.filter(published_date__isnull=False).order_by("-published_date")
    return render(request, "blog/author_detail.html", {"author": author, "posts": posts})
<!-- templates/blog/author_detail.html -->
{% extends "base.html" %}

{% block content %}
  {% with profile=author.profile %}
    {% if profile.avatar %}
      <img src="{{ profile.avatar.url }}" alt="{{ author.get_username }}" width="96">
    {% endif %}
    <h1>{{ profile.display_name }}</h1>
    <p>{{ profile.bio|linebreaks }}</p>
    {% if profile.website %}
      <p><a href="{{ profile.website }}" rel="nofollow noopener">{{ profile.website }}</a></p>
    {% endif %}
  {% endwith %}

  <h2>{{ posts|length }} post(s)</h2>
  {% for post in posts %}
    <h3><a href="{% url 'blog:post_detail' post.pk %}">{{ post.title }}</a></h3>
  {% empty %}
    <p>Nothing published yet.</p>
  {% endfor %}
{% endblock %}

In the post list, link the author name to that page and prefetch in one go: Post.objects.select_related("author__profile"). Without it, a list of twenty posts fires twenty extra queries - the N+1 problem you will meet properly in module 7.

6. Exercise

  1. Add the Profile model, install Pillow, configure MEDIA_ROOT and serve media in development.
  2. Decide between the signal and explicit creation, write down why in a comment, and add a data migration that backfills the users you already have.
  3. Build the combined edit page and check that an avatar really lands in media/avatars/.
  4. Delete a user in the admin and confirm the profile row disappears with it (on_delete=models.CASCADE).
  5. Add the author page, link to it from every post, and use assertNumQueries in a test to prove select_related removed the extra queries.

Further reading

After this lesson you will

  • Store extra data about a user without forking auth
  • Create related objects reliably