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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
List all Django tutorials.