Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 094 / 100

Django custom user model: a hands-on guide for engineers

Set it up on day one.

Expona Admin7 min read

Getting custom user models right from day one

Most Django projects start with the default User model and regret it by month three. We've seen production systems where adding a phone_number field meant coordinating migrations across three services, or where authentication logic became a tangled mess of profile models and signal handlers. This Django custom user model: a hands-on guide for engineers walks through the decisions you need to make before your first migration, the trade-offs between extending AbstractUser versus AbstractBaseUser, and why changing this later costs weeks of work.

The difference between getting this right and getting it wrong isn't academic—it's the difference between a clean authentication layer that scales with your product and technical debt that slows down every team iteration.

AUTH_USER_MODEL before the first migration

The moment you run python manage.py migrate for the first time, Django locks the default User model into your database schema. Changing it afterward requires either a data migration nightmare or a full reset. This is why the custom user model decision belongs on day one, before any migrations exist.

Start by defining AUTH_USER_MODEL in your settings immediately:

python
# settings.py
AUTH_USER_MODEL = 'accounts.CustomUser'

This single line tells Django to use your custom model instead of the built-in one. Every foreign key, every authentication backend, and every permission system will reference this model. If you forget this setting, you'll end up with the default User model in your database, and switching later requires serious migration work.

Create your accounts app early:

python
python manage.py startapp accounts

Then define your custom user model before you touch anything else in the project. Even if you're not sure what fields you need beyond email and password, create it. You can add fields incrementally with migrations—but you can't change the fundamental model structure without pain.

Here's the critical order:

  1. Set AUTH_USER_MODEL in settings.py
  2. Create your accounts app
  3. Define CustomUser in models.py
  4. Register it in admin.py
  5. Run your first migration
  6. Build everything else

Skip this sequence and you'll spend hours untangling foreign key constraints and migration conflicts.

Extend AbstractUser for most apps

For most projects—internal tools, e-commerce platforms, SaaS products—extending AbstractUser is the right choice. You get all the built-in Django authentication machinery: password hashing, permissions, groups, and admin integration. You're just adding your own fields on top.

A typical e-commerce example:

python
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models

class CustomUser(AbstractUser):
    phone_number = models.CharField(max_length=20, blank=True)
    date_of_birth = models.DateField(null=True, blank=True)
    is_vendor = models.BooleanField(default=False)
    verification_token = models.CharField(max_length=255, blank=True)
    
    def __str__(self):
        return f"{self.get_full_name()} ({self.email})"

This approach keeps all the standard Django user behavior—password validation, permission checking, the admin interface—while letting you add business-specific fields. The user model stays responsible for authentication; other concerns stay in separate models.

Set up a proper manager and ensure email is unique:

python
from django.contrib.auth.models import AbstractUser, UserManager

class CustomUserManager(UserManager):
    pass

class CustomUser(AbstractUser):
    email = models.EmailField(unique=True)
    phone_number = models.CharField(max_length=20, blank=True)
    
    objects = CustomUserManager()
    
    USERNAME_FIELD = 'email'  # Optional, if you want email as login
    REQUIRED_FIELDS = ['username']  # Required if using USERNAME_FIELD

Register it in admin so your team can manage users effectively:

python
# accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import CustomUser

@admin.register(CustomUser)
class CustomUserAdmin(BaseUserAdmin):
    fieldsets = BaseUserAdmin.fieldsets + (
        ('Custom Fields', {'fields': ('phone_number', 'date_of_birth', 'is_vendor')}),
    )
    list_display = ('email', 'first_name', 'last_name', 'is_vendor', 'date_joined')

This pattern scales well. You can add fields as your product evolves without rearchitecting authentication.

Email as username with AbstractBaseUser

Some projects need finer control—particularly trading platforms, ride-hailing services, or systems where you're not using Django's permission system heavily. In these cases, extend AbstractBaseUser instead. You'll build more plumbing, but you get a completely custom user model.

The difference: AbstractUser gives you username, email, password, and the full permission system. AbstractBaseUser gives you only password hashing and session support—you define everything else.

Here's a minimal ride-hailing example where email is the username:

python
# accounts/models.py
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
from django.db import models

class CustomUserManager(BaseUserManager):
    def create_user(self, email, password=None, **extra_fields):
        if not email:
            raise ValueError('Email is required')
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user
    
    def create_superuser(self, email, password=None, **extra_fields):
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)
        return self.create_user(email, password, **extra_fields)

class CustomUser(AbstractBaseUser):
    email = models.EmailField(unique=True)
    phone_number = models.CharField(max_length=20)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    is_superuser = models.BooleanField(default=False)
    date_joined = models.DateTimeField(auto_now_add=True)
    
    objects = CustomUserManager()
    
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []
    
    def __str__(self):
        return self.email

You're now responsible for managing is_active, is_staff, is_superuser, and the admin integration. Use this only if you have a specific reason—custom permission models, multiple authentication methods, or a very different user concept than Django's default. Otherwise, you're adding complexity without benefit.

Admin setup requires more work with AbstractBaseUser:

python
# accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import CustomUser

@admin.register(CustomUser)
class CustomUserAdmin(BaseUserAdmin):
    fieldsets = (
        (None, {'fields': ('email', 'password')}),
        ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser')}),
        ('Important dates', {'fields': ('last_login', 'date_joined')}),
    )
    add_fieldsets = (
        (None, {'classes': ('wide',), 'fields': ('email', 'password1', 'password2')}),
    )
    list_display = ('email', 'is_staff', 'is_superuser', 'date_joined')
    search_fields = ('email',)

Use AbstractBaseUser sparingly. The cognitive load and maintenance burden usually outweigh the flexibility.

Changing it later is painful

We've seen teams try to migrate from the default User model to a custom one mid-project. It's always messy, and it's always avoidable.

The technical challenge: Django stores foreign keys by content type and model name. Every foreign key pointing to User gets baked into migration files. Changing the user model means rewriting those migrations, potentially recreating the entire database schema, and coordinating changes across all your apps.

In a real scenario from a CRM system: adding a CustomUser model meant writing a complex data migration that:

  • Created the new CustomUser model
  • Copied data from the old User model
  • Updated every foreign key in accounts, contacts, companies, and activities tables
  • Ran in production with a maintenance window
  • Required rollback procedures in case of failure

This took two weeks of engineering time and introduced subtle bugs in permission handling that surfaced weeks later.

The cost compounds if you've been using the default User in related_name attributes or permission checks across the codebase. Your authentication layer becomes entangled with business logic, and refactoring becomes risky.

The solution is simple: don't be lazy on day one. Spend 30 minutes setting up AbstractUser properly now, or spend weeks fixing it later.

Common mistakes

Forgetting to set AUTH_USER_MODEL before migrations: If you set it after running migrations, Django will still use the default User model in your database. Always set it first.

Using AbstractBaseUser when AbstractUser is enough: Engineers often over-engineer the user model, thinking they need full control. You almost always don't. AbstractUser + custom fields handles 95% of real projects.

Adding user-related fields to other models: Resist the urge to create a UserProfile model and bolt on fields. Extend CustomUser directly. Profile models fragment your user concept and create migration headaches.

Skipping the manager: Always define a custom manager with create_user and create_superuser methods. Django's admin and management commands depend on this, and you'll avoid mysterious "no create_user method" errors later.

Not making email unique: If you're treating email as a username or unique identifier, enforce it at the model level with unique=True. Database constraints catch bugs that application logic misses.

Key takeaways

  • Set AUTH_USER_MODEL in settings before your first migration—this is non-negotiable
  • Extend AbstractUser for almost every project; it gives you Django's authentication system plus room for custom fields
  • Use AbstractBaseUser only if you have a concrete reason to override the entire user model
  • Changing the user model after your first migration is painful and costly; get it right on day one
  • Ensure email is unique and set up a proper manager with create_user and create_superuser
  • Keep the user model focused on authentication and identity; don't bolt business logic onto it
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles