ZetCode

Django custom user model

last modified November 13, 2026 Python 3.15

In this article we replace the default Django user with a custom user model. The new model logs in with an email address instead of a user name and has an extra field. We write a manager for it, point the project to it with AUTH_USER_MODEL, refer to it from other models, adapt the forms and the admin site, test it, and see what happens when the switch is made too late. The examples use Django 6.1 and Python 3.15. The authentication basics are in Django authentication.

Why a custom user model

The default model django.contrib.auth.models.User is good enough for many sites, but it cannot be changed: it has a user name that must be unique, an optional email, and fixed fields for the first and the last name. The day we need to log in with an email address, to add a phone number, or to remove the user name, we would have to live with workarounds. Django therefore recommends to start every project with a custom user model, even an empty one, as the documentation says. The reason is that the user table is referenced by foreign keys of the other models and by the migrations of the admin and auth apps, so changing it in a project that already has data is a painful operation; at the beginning of a project it costs nothing.

There are two ways to write the model. The class AbstractUser is the full default user as an abstract model: we subclass it and add or override fields, and everything else, including the permissions and the admin support, keeps working. The class AbstractBaseUser provides only the password and the last login; we then define all the other fields and the permission handling ourselves. We take the first way, which is right for almost every project. A different approach, which does not replace the model, is a separate profile model with a OneToOneField to the user; it is fine for optional data, but it does not allow to change the login field.

The example project

The project is created with uv; the steps are explained in Django introduction. The commands below create a project directory, install Django, generate the project with the configuration package config, start the apps accounts and notes, and create the folder demos for demo scripts.

$ mkdir usermodel
$ cd usermodel
$ uv init --bare
$ uv add django
$ uv run django-admin startproject config .
$ uv run python manage.py startapp accounts
$ uv run python manage.py startapp notes
$ mkdir demos

The trailing dot in startproject config . creates manage.py and the config package in the current directory instead of one level deeper. A new app is ignored by Django until it is listed in INSTALLED_APPS, so we add the apps to the list in config/settings.py; the other entries are generated by startproject.

config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'accounts',
    'notes',
]

The listings in this article are of two kinds. A listing with a solid blue border and the label Project file is a file of the application: models, views, templates, tests, and settings. A listing with a dashed amber border and the label Demo script is a small helper in the demos folder; it sets up Django, runs a few lines of code, and prints the results, so that we can watch the behaviour. Demo scripts are not part of the application; we run one from the project directory with uv run python -m demos.name.

The app accounts holds the user model and the app notes has a model that refers to the user. The one setting that activates the custom model is AUTH_USER_MODEL, which has the form app_label.ModelName. It must be present before the first migration is applied.

config/settings.py (user model setting)
AUTH_USER_MODEL = 'accounts.User'

From now on, django.contrib.auth and the rest of the framework use our model wherever they need a user, the function get_user_model() returns it, and request.user is its instance. The old model auth.User is not installed any more: there is no auth_user table.

The user model

The model subclasses AbstractUser. Setting username = None removes the inherited field, and the field email is redefined, because the inherited one is optional and not unique. The attribute USERNAME_FIELD names the field that identifies the user at the login; it must be unique. REQUIRED_FIELDS lists the fields, other than the username field and the password, that the command createsuperuser asks for; here it is empty.

accounts/models.py
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models.functions import Lower


class UserManager(BaseUserManager):
    use_in_migrations = True

    def create_user(self, email, password=None, **extra_fields):
        if not email:
            raise ValueError('The email address must be set')
        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)
        if not extra_fields['is_staff'] or not extra_fields['is_superuser']:
            raise ValueError('A superuser must have is_staff and is_superuser set to True')
        return self.create_user(email, password, **extra_fields)

    def get_by_natural_key(self, email):
        return self.get(email__iexact=email)


class User(AbstractUser):
    username = None
    email = models.EmailField('email address', unique=True)
    display_name = models.CharField(max_length=60, blank=True)

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    objects = UserManager()

    class Meta:
        constraints = [
            models.UniqueConstraint(
                Lower('email'), name='accounts_user_email_ci_unique',
                violation_error_message='A user with this email address already exists.'),
        ]

    def __str__(self):
        return self.email

    def get_short_name(self):
        return self.display_name or self.email.split('@')[0]

Two things deserve attention. The constraint makes the email unique regardless of the case, because unique=True alone would accept both alice@example.com and Alice@example.com; violation_error_message is what forms show when it is violated. And the manager: Django creates users through the methods create_user() and create_superuser() of the manager, and the inherited manager of AbstractUser knows the field username, so we must write our own, based on BaseUserManager. The method normalize_email() lowercases the domain part of the address, set_password() hashes the password, and get_by_natural_key(), which the authentication backend calls with the value typed into the login form, is changed to ignore the case. The attribute use_in_migrations makes the manager a part of the migration, which matters when a data migration needs to create users. The method get_short_name() is one that the framework and the templates may call; we return the display name or the part of the email before the at sign.

Migrations and the first superuser

We generate and apply the migrations. Look at the order of the output of migrate: our accounts.0001_initial is applied right after the migrations of the auth app that it depends on, and before admin.0001_initial, whose log entries have a foreign key to the user table. That ordering is exactly what cannot be repaired afterwards. The command createsuperuser now asks for an email address instead of a user name; with --noinput it takes the values from the options and the environment variable DJANGO_SUPERUSER_PASSWORD.

$ uv run python manage.py makemigrations accounts notes
Migrations for 'accounts':
  accounts/migrations/0001_initial.py
    + Create model User
Migrations for 'notes':
  notes/migrations/0001_initial.py
    + Create model Note
$ uv run python manage.py migrate
Operations to perform:
  Apply all migrations: accounts, admin, auth, contenttypes, notes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0001_initial... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying accounts.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying notes.0001_initial... OK
  Applying sessions.0001_initial... OK
$ DJANGO_SUPERUSER_PASSWORD=admin-pass-123 uv run python manage.py createsuperuser --noinput --email root@example.com
Superuser created successfully.
$ uv run python manage.py shell --no-imports -c "from django.contrib.auth import get_user_model; print(list(get_user_model().objects.values_list('email', 'is_staff', 'is_superuser')))"
[('root@example.com', True, True)]

Working with the model

The demo script uses the model in the same way as the default one, except for the login field. It removes the users that earlier steps created, and then prints the metadata of the model, creates users, authenticates them, and provokes the errors.

demos/users.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.contrib.auth import authenticate, get_user_model
from django.db import IntegrityError, transaction

User = get_user_model()
User.objects.all().delete()

print('model:', User._meta.label, '| table:', User._meta.db_table)
print('USERNAME_FIELD:', User.USERNAME_FIELD, '| REQUIRED_FIELDS:', User.REQUIRED_FIELDS)
print('fields:', [f.name for f in User._meta.get_fields() if f.concrete and not f.is_relation])

alice = User.objects.create_user('alice@Example.COM', 'correct-horse-battery', display_name='Alice')
print()
print('stored email:', alice.email)
print('str():', alice, '| short name:', alice.get_short_name())
print('username attribute:', alice.username)
print('get_username():', alice.get_username())

bob = User.objects.create_user('bob@example.com')
print('short name without display_name:', bob.get_short_name())
print('no password given:', bob.has_usable_password())

print()
print('authenticate(username=...):', authenticate(username='alice@example.com', password='correct-horse-battery'))
print('authenticate(email=...):', authenticate(email='alice@example.com', password='correct-horse-battery'))
print('different case:', authenticate(username='ALICE@example.com', password='correct-horse-battery'))
print('wrong password:', authenticate(username='alice@example.com', password='guess'))

print()
try:
    with transaction.atomic():
        User.objects.create_user('ALICE@example.com', 'x')
except IntegrityError as error:
    print('duplicate with other case:', error)

try:
    User.objects.create_user('', 'x')
except ValueError as error:
    print('empty email:', error)

root = User.objects.create_superuser('root@example.com', 'admin-pass-123')
print('superuser:', root.is_staff, root.is_superuser)
try:
    User.objects.create_superuser('boss@example.com', 'x', is_staff=False)
except ValueError as error:
    print('is_staff=False:', error)

User.objects.all().delete()

The model has the table accounts_user, the login field is email, and there is no username column; the attribute exists but is None, and get_username(), which works for any user model, returns the value of USERNAME_FIELD. The manager normalized the domain of the first address. The user created without a password has an unusable one, which can never match. The function authenticate() accepts the address under the name username, as the login form passes it, or under the name of the real field, email; both reach the manager method get_by_natural_key(), so the case of the address does not matter. The database refuses a second address that differs only by case, and the manager refuses an empty address and a superuser without the staff flag.

$ uv run python -m demos.users
model: accounts.User | table: accounts_user
USERNAME_FIELD: email | REQUIRED_FIELDS: []
fields: ['id', 'password', 'last_login', 'is_superuser', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'email', 'display_name']

stored email: alice@example.com
str(): alice@example.com | short name: Alice
username attribute: None
get_username(): alice@example.com
short name without display_name: bob
no password given: False

authenticate(username=...): alice@example.com
authenticate(email=...): alice@example.com
different case: alice@example.com
wrong password: None

duplicate with other case: UNIQUE constraint failed: index 'accounts_user_email_ci_unique'
empty email: The email address must be set
superuser: True True
is_staff=False: A superuser must have is_staff and is_superuser set to True

Referring to the user model

A reusable app must not import the user model directly, because it does not know which one the project uses. In the models we use the string settings.AUTH_USER_MODEL as the target of a relation; Django resolves it lazily, when all the models are loaded. In the other code we call get_user_model(), which returns the class. The only place where the user model may be imported directly is the app that defines it.

notes/models.py
from django.conf import settings
from django.db import models


class Note(models.Model):
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
                               related_name='notes')
    text = models.CharField(max_length=200)

    def __str__(self):
        return self.text

The model Note has a foreign key to the user, with the reverse accessor notes. The script shows that the string setting and the function lead to the same class, that the relation points to our model, and that the login form has a field labeled with the verbose name of the email field.

demos/references.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import AuthenticationForm

from notes.models import Note

User = get_user_model()
print('AUTH_USER_MODEL:', settings.AUTH_USER_MODEL)
print('get_user_model():', get_user_model())

author = Note._meta.get_field('author')
print('Note.author points to:', author.related_model)
print('reverse accessor:', author.remote_field.get_accessor_name())

User.objects.all().delete()
alice = User.objects.create_user('alice@example.com', 'pw')
Note.objects.create(author=alice, text='Buy milk')
Note.objects.create(author=alice, text='Write article')
print('notes of Alice:', list(alice.notes.values_list('text', flat=True)))

form = AuthenticationForm()
print('login form label:', form.fields['username'].label)
User.objects.all().delete()

The form AuthenticationForm, which the login view uses, still names its field username in the HTML, but the label is taken from the model, so the visitor sees Email address.

$ uv run python -m demos.references
AUTH_USER_MODEL: accounts.User
get_user_model(): <class 'accounts.models.User'>
Note.author points to: <class 'accounts.models.User'>
reverse accessor: notes
notes of Alice: ['Buy milk', 'Write article']
login form label: Email address

Forms for the custom model

The forms of django.contrib.auth.forms that create or change a user are model forms of the default User. The login, password change, and password reset forms work with any model, but a sign-up form has to say which model it uses and which fields it shows. We extend BaseUserCreationForm, which provides the two password fields, the password validation, and the hashing, and set the Meta class.

accounts/forms.py
from django.contrib.auth.forms import BaseUserCreationForm

from .models import User


class SignupForm(BaseUserCreationForm):
    class Meta:
        model = User
        fields = ('email', 'display_name')

The class UserCreationForm has an additional check for the user name; for a model without that field we use its base class. The script validates four submissions.

demos/forms.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.contrib.auth import get_user_model

from accounts.forms import SignupForm

User = get_user_model()
User.objects.all().delete()
User.objects.create_user('alice@example.com', 'pw')

print('fields:', list(SignupForm().fields))

cases = {
    'valid': {'email': 'bob@example.com', 'display_name': 'Bob',
              'password1': 'purple-monkey-dishwasher', 'password2': 'purple-monkey-dishwasher'},
    'taken': {'email': 'alice@example.com', 'password1': 'purple-monkey-dishwasher',
              'password2': 'purple-monkey-dishwasher'},
    'taken, other case': {'email': 'Alice@Example.com', 'password1': 'purple-monkey-dishwasher',
                          'password2': 'purple-monkey-dishwasher'},
    'bad email': {'email': 'not-an-email', 'password1': 'purple-monkey-dishwasher',
                  'password2': 'purple-monkey-dishwasher'},
}
for name, data in cases.items():
    form = SignupForm(data)
    print(f'{name}:', form.is_valid(), dict(form.errors))
User.objects.all().delete()

The form has our two fields and the two password fields. The valid submission passes. The exact duplicate is caught by the field's unique=True; the duplicate that differs by the case is caught by the constraint, since Django validates the constraints of a model in full_clean(), which model forms call, and it shows our message as a form-level error. The email field validates the format.

$ uv run python -m demos.forms
fields: ['email', 'display_name', 'password1', 'password2']
valid: True {}
taken: False {'email': ['User with this Email address already exists.']}
taken, other case: False {'__all__': ['A user with this email address already exists.']}
bad email: False {'email': ['Enter a valid email address.']}

The admin site

The admin site has a ready-made UserAdmin with the pages for the user list, the change form, and a two-step creation of a user. Its fieldsets list the field username, which our model does not have, so we subclass it and redefine the layout: fieldsets for the change form and add_fieldsets for the creation form. The fields usable_password, password1, and password2 belong to the creation form, which allows to create a user without a password. The other attributes are the usual options of the admin; they are explained in the article about the admin site.

accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin

from .models import User


@admin.register(User)
class UserAdmin(DjangoUserAdmin):
    ordering = ('email',)
    list_display = ('email', 'display_name', 'is_staff')
    search_fields = ('email', 'display_name')
    fieldsets = (
        (None, {'fields': ('email', 'password')}),
        ('Personal info', {'fields': ('display_name', 'first_name', 'last_name')}),
        ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser',
                                    'groups', 'user_permissions')}),
        ('Important dates', {'fields': ('last_login', 'date_joined')}),
    )
    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': ('email', 'display_name', 'usable_password', 'password1', 'password2'),
        }),
    )

The forms of the base class need no change here, because the admin builds its forms for the model of the admin class. The tests in the next section request the pages of the admin and create a user through the creation form.

Testing the user model

The tests cover the manager, the case-insensitive uniqueness, the login with an email address, the short name, and the three admin pages. The test client method login() passes its keyword arguments to authenticate(), so we can use the email name.

accounts/tests.py
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from django.test import TestCase
from django.urls import reverse

User = get_user_model()


class UserModelTests(TestCase):

    def test_create_user_normalizes_email_and_hashes_password(self):
        user = User.objects.create_user('alice@Example.COM', 'secret-pass-1')
        self.assertEqual(user.email, 'alice@example.com')
        self.assertTrue(user.check_password('secret-pass-1'))
        self.assertFalse(user.is_staff)
        self.assertFalse(user.is_superuser)

    def test_email_is_required(self):
        with self.assertRaises(ValueError):
            User.objects.create_user('', 'secret-pass-1')

    def test_create_superuser_sets_flags(self):
        admin = User.objects.create_superuser('root@example.com', 'secret-pass-1')
        self.assertTrue(admin.is_staff and admin.is_superuser)

    def test_emails_are_unique_ignoring_case(self):
        User.objects.create_user('alice@example.com', 'x')
        with self.assertRaises(IntegrityError), transaction.atomic():
            User.objects.create_user('ALICE@example.com', 'x')

    def test_login_with_email_in_any_case(self):
        User.objects.create_user('alice@example.com', 'secret-pass-1')
        self.assertTrue(self.client.login(email='ALICE@example.com', password='secret-pass-1'))
        self.assertFalse(self.client.login(email='alice@example.com', password='wrong'))

    def test_short_name(self):
        named = User(email='a@example.com', display_name='Alice')
        unnamed = User(email='bob@example.com')
        self.assertEqual(named.get_short_name(), 'Alice')
        self.assertEqual(unnamed.get_short_name(), 'bob')


class AdminTests(TestCase):

    @classmethod
    def setUpTestData(cls):
        cls.admin = User.objects.create_superuser('root@example.com', 'secret-pass-1')
        cls.alice = User.objects.create_user('alice@example.com', 'secret-pass-1')

    def setUp(self):
        self.client.force_login(self.admin)

    def test_changelist_lists_emails(self):
        response = self.client.get(reverse('admin:accounts_user_changelist'))
        self.assertContains(response, 'alice@example.com')

    def test_change_form_opens(self):
        response = self.client.get(reverse('admin:accounts_user_change', args=[self.alice.pk]))
        self.assertEqual(response.status_code, 200)

    def test_add_form_creates_a_user(self):
        response = self.client.post(reverse('admin:accounts_user_add'), {
            'email': 'bob@example.com', 'display_name': 'Bob', 'usable_password': 'true',
            'password1': 'purple-monkey-dishwasher', 'password2': 'purple-monkey-dishwasher'})
        self.assertEqual(response.status_code, 302)
        self.assertTrue(User.objects.get(email='bob@example.com').check_password(
            'purple-monkey-dishwasher'))

All nine tests pass. The tests that need the admin log in with force_login(), which skips the password hashing.

$ uv run python manage.py test
Found 9 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.........
----------------------------------------------------------------------
Ran 9 tests in 2.768s

OK
Destroying test database for alias 'default'...

Switching late is painful

To see why the model must be chosen at the start, we use a throwaway project that is created in the same way as the example project, only with the single app accounts. We first run migrate with the default user. Then we copy the model from this article to accounts/models.py, add the app to INSTALLED_APPS, set AUTH_USER_MODEL, and generate its first migration. Django does not complain while it creates the migration, but migrate fails, because the admin migration, which is already recorded as applied, depends on a model that did not exist when it ran. The pipe to tail keeps only the last line of the long output.

The last command fails with the error that people meet when they change AUTH_USER_MODEL in a project that has been migrated. The remedies, such as editing the migration history by hand or recreating the database, are costly; with data in production they need a careful plan. In a new project we avoid the problem by writing the model before the first migrate. If a project already runs on the default model and has no important data, the simplest way is to delete the database file and the migrations of the apps, then start again.

$ uv run python manage.py migrate 2>&1 | tail -n 1
  Applying sessions.0001_initial... OK

$ uv run python manage.py makemigrations accounts
Migrations for 'accounts':
  accounts/migrations/0001_initial.py
    + Create model User

$ uv run python manage.py migrate 2>&1 | tail -n 1
django.db.migrations.exceptions.InconsistentMigrationHistory: Migration admin.0001_initial is applied before its dependency accounts.0001_initial on database 'default'.

Source

Django documentation: Customizing authentication in Django

Django documentation: django.contrib.auth

Django documentation: Managers

In this article we have replaced the default user with a custom model that logs in with an email address, written its manager, set AUTH_USER_MODEL before the first migration, referred to the model from other models, and adapted the forms and the admin. Related articles are Django authentication, Django models, and Django migrations.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.