DEV Community

Cover image for The Custom User Model Mistake 90% of Django Projects Make
Houssem Reggai
Houssem Reggai

Posted on

The Custom User Model Mistake 90% of Django Projects Make

PROFESSIONAL DJANGO ENGINEERING SERIES #3

Change your user model after the first migration and you will spend a painful day fixing it. Here is what to do instead — before your first commit.

This is the single piece of Django advice that appears in the official documentation, in every serious Django resource, and from every experienced Django developer who has ever ignored it on an early project and paid the price: create a custom user model before your first migration.

If you are starting a new project today, this article will save you hours. If you are working on an existing project that uses the default user model, this article will help you understand exactly what you are dealing with.

The best time to create a custom user model was before your first commit. The second best time is right now, before you run makemigrations for the first time.

Why the Default User Model Is a Problem?

Django’s default auth.User model makes decisions for you: username is the primary identifier, the field names are fixed, and the model lives in Django’s own package where you cannot modify it. The most common pain point:

  • Most modern applications want email as the login identifier. The default model has both a username field and an email field, and uses username for authentication. Working around this requires a custom authentication backend — possible, but inelegant.
  • Every field you want to add to the user (bio, avatar, preferences, organisation) requires either a separate Profile model (an extra database join on every user access) or monkey-patching (an anti-pattern).
  • Once you have migrations that reference auth.User, changing to a custom model requires either a fresh database or a complex, risky surgical migration.

AbstractUser vs AbstractBaseUser — Which to Choose

AbstractUser: extend the default behaviour

If you are happy with Django’s authentication behaviour and just want to add fields, inherit from AbstractUser. You get everything Django provides, plus the ability to add your own fields:

# apps/users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    """
    Custom user model. Add project-specific fields here.
    Even if you add nothing now, this gives you flexibility later.
    """
    bio = models.TextField(blank=True)
    avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)

    def __str__(self):
        return self.email

Enter fullscreen mode Exit fullscreen mode

AbstractBaseUser: full control

If you need fundamentally different logic like authentication, inherit from AbstractBaseUser. You build everything yourself, which gives full control at the cost of more setup.

Email-as-username

If you want to create email-based-authentication system (instead of the default username) it's achievable with both AbstractBaseUser and AbstractUser:

# apps/users/managers.py — email-as-username pattern
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import UserManager
from django.utils.translation import gettext_lazy as _

class EmailBasedUserManager(UserManager):
    """
    Custom user model manager where email is the unique identifier
    for authentication instead of username.
    """

    def create_user(self, email, password, **extra_fields):
        """
        create and save new user with the given email and password
        """
        if not email:
            raise ValueError(_("Email must be set"))
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.password = make_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email, password, **extra_fields):
        """
        create and save new superuser with the given email and password
        """
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)
        extra_fields.setdefault("is_active", True)

        if extra_fields.get("is_staff") is not True:
            raise ValueError(_("Superuser must have is_staff=True."))
        if extra_fields.get("is_superuser") is not True:
            raise ValueError(_("Superuser must have is_superuser=True."))
        return self.create_user(email, password, **extra_fields)
Enter fullscreen mode Exit fullscreen mode

then in models.py:

# apps/users/models.py — email-as-username pattern
# using AbstractUser
from django.contrib.auth.models import AbstractUser
from .managers import EmailBasedUserManager


class User(AbstractUser):
    username    = None
    email       = models.EmailField(unique=True)
    first_name  = models.CharField(max_length=150, blank=True)
    last_name   = models.CharField(max_length=150, blank=True)
    is_active   = models.BooleanField(default=True)
    is_staff    = models.BooleanField(default=False)
    date_joined = models.DateTimeField(auto_now_add=True)

    USERNAME_FIELD  = 'email'   # email is the login identifier
    REQUIRED_FIELDS = []        # email is already required via USERNAME_FIELD

    objects = EmailBasedUserManager()
Enter fullscreen mode Exit fullscreen mode

The Three Things You Must Do

  1. Register it in settings
# config/settings/base.py
AUTH_USER_MODEL = 'users.User'

# This one line must be in place BEFORE running the very first makemigrations.
# Everything in Django resolves 'the user model' through this setting.
Enter fullscreen mode Exit fullscreen mode
  1. Reference it correctly in code
# In Python code: use get_user_model()
from django.contrib.auth import get_user_model
User = get_user_model()   # respects AUTH_USER_MODEL

# In model ForeignKey fields: use settings.AUTH_USER_MODEL
from django.conf import settings

class Order(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,   # string reference, not the class
        on_delete=models.CASCADE,
    )

# NEVER: from django.contrib.auth.models import User
# NEVER: from apps.users.models import User  (in ForeignKey fields)
Enter fullscreen mode Exit fullscreen mode
  1. Update the Admin When you replace the default user model, Django’s built-in UserAdmin breaks because it references the username field. Subclass it and remove the references:
# apps/users/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import User

@admin.register(User)
class UserAdmin(BaseUserAdmin):
    list_display  = ('email', 'first_name', 'last_name', 'is_staff')
    search_fields = ('email', 'first_name', 'last_name')
    ordering      = ('-date_joined',)
    fieldsets = (
        (None, {'fields': ('email', 'password')}),
        ('Personal info', {'fields': ('first_name', 'last_name')}),
        ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser',
                                    'groups', 'user_permissions')}),
    )
    add_fieldsets = (
        (None, {'classes': ('wide',), 'fields': ('email', 'password1', 'password2')}),
    )
Enter fullscreen mode Exit fullscreen mode

⚠ Already past the first migration?

If your project has existing migrations that reference auth.User, you have two options. In development: drop the database, delete all migration files (not Django's own), set AUTH_USER_MODEL, and run makemigrations and migrate fresh. In production with existing data: this is a documented but complex multi-step migration that requires a maintenance window. Django's official documentation covers it, but it is a significant operation.

The lesson for every future project: AUTH_USER_MODEL before the first makemigrations.

Top comments (0)