Django permissions and groups
last modified November 13, 2026 Python 3.15
In this article we control what the users may do in a Django project. We look at the permissions that Django creates for every model, add a custom permission, collect permissions in groups, check them in code, protect function and class-based views, hide the actions that a user may not use in a template, restrict a view to the owner of an object, and test it. The examples use Django 6.1 and Python 3.15. The login part is described in Django authentication.
Authorization in Django
Authentication tells who the user is; authorization decides what the user may do. Django has a
simple permission system in django.contrib.auth. A permission is a row of the table
auth_permission with a name, a code name, and the content type of a model. For each model, Django creates four
permissions, add, change, delete, and view, for example
library.add_document; the format of the string is app_label.codename. The permissions are created by
migrate, right after the migrations are applied. A user receives permissions in two ways: directly, through
user_permissions, and through groups, which are named sets of permissions to which we add users. The
method user.has_perm() checks both. A superuser passes every check, and an inactive user none.
The permissions are model-wide: can change documents is not the same as can change this document. Django offers no per-object permissions out of the box; the last parts of the article show how to write such checks.
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 app library, and create the folder demos for demo scripts.
$ mkdir permissions $ cd permissions $ uv init --bare $ uv add django $ uv run django-admin startproject config . $ uv run python manage.py startapp library $ 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 app 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',
'library',
]
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 library has one model, a document with an owner. We use the default user model of Django, which is
enough here; see Django custom user model for a custom one. The users in
the examples are alice, who has no permissions, bob, an editor, and carol, who is both an editor
and a publisher.
Model permissions
The model Document has a foreign key to the owner and a flag that tells whether it is published.
Besides the four default permissions we need one that says who may publish a document; that is not an
operation on the model that the default permissions describe. Custom permissions are declared in the option
permissions of the Meta class as pairs of a code name and a human-readable name.
from django.conf import settings
from django.db import models
class Document(models.Model):
title = models.CharField(max_length=120)
body = models.TextField(blank=True)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name='documents')
published = models.BooleanField(default=False)
class Meta:
ordering = ['title']
permissions = [
('publish_document', 'Can publish document'),
]
def __str__(self):
return self.title
The option is a part of the model state, so changing it creates a migration and a new row after migrate. The
commands generate and apply the initial migration; the last lines of the output of migrate are enough for us.
$ uv run python manage.py makemigrations library
Migrations for 'library':
library/migrations/0001_initial.py
+ Create model Document
$ uv run python manage.py migrate | tail -n 4
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying library.0001_initial... OK
Applying sessions.0001_initial... OK
The permission table
The script lists the permissions that belong to the app library and counts all of them. Each
permission has a content type, which is a row of the table django_content_type that identifies a model, so the permission
code names need to be unique only within a model.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.contrib.auth.models import Permission
for permission in Permission.objects.filter(content_type__app_label='library'):
print(f'{permission.pk:3} {permission.content_type.app_label}.{permission.codename:18} {permission.name}')
print()
print('permissions of the auth app:', Permission.objects.filter(content_type__app_label='auth').count())
print('permissions in total:', Permission.objects.count())
There are five permissions for the model: the four default ones and our own. The numbers show that the
default permissions were created first, and our publish_document comes last because it is created
after the default ones of its model. The other apps have their own permissions; the auth app alone has twelve, for the models
User, Group, and Permission.
$ uv run python -m demos.catalog 25 library.add_document Can add document 26 library.change_document Can change document 27 library.delete_document Can delete document 29 library.publish_document Can publish document 28 library.view_document Can view document permissions of the auth app: 12 permissions in total: 29
Groups
Assigning permissions to every user one by one does not scale. A Group has a name and a many-to-many relation to
permissions, and a user can be a member of any number of groups, getting the union of the permissions. The seed script creates
two groups, editors with the permissions to view, add and change documents, and publishers with the permission
to publish, and three users. We import the function from the other demo scripts, so that every script starts from the same data.
The code that creates groups belongs to a script or a management command, not to a data migration, because the permissions
of an app are created after all migrations are applied, so on a new database they do not exist yet when a migration of that app runs.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.contrib.auth.models import Group, Permission, User
from library.models import Document
def perm(codename):
return Permission.objects.get(content_type__app_label='library', codename=codename)
def seed():
"""Create the groups, three users, and two documents."""
Document.objects.all().delete()
User.objects.all().delete()
Group.objects.all().delete()
editors = Group.objects.create(name='editors')
editors.permissions.add(perm('add_document'), perm('change_document'), perm('view_document'))
publishers = Group.objects.create(name='publishers')
publishers.permissions.add(perm('publish_document'))
users = {}
for name in ('alice', 'bob', 'carol'):
users[name] = User.objects.create_user(name, password='secret-pass-1')
users['bob'].groups.add(editors)
users['carol'].groups.add(editors, publishers)
Document.objects.create(title='Draft by Bob', owner=users['bob'])
Document.objects.create(title='Draft by Carol', owner=users['carol'])
return users
if __name__ == '__main__':
seed()
print('groups:', list(Group.objects.values_list('name', flat=True)))
for user in User.objects.all():
print(user.username, list(user.groups.values_list('name', flat=True)))
Run as a script, the module prints the groups and the memberships; imported, it only defines the functions. Bob is an editor, and Carol belongs to both groups.
$ uv run python -m demos.seed groups: ['editors', 'publishers'] alice [] bob ['editors'] carol ['editors', 'publishers']
Checking permissions
The method has_perm() takes the permission string, and has_perms() a list that must be satisfied
entirely. The methods get_user_permissions(), get_group_permissions(), and get_all_permissions() return sets of strings.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.contrib.auth.models import Permission, User
from demos.seed import seed
from library.models import Document
users = seed()
alice, bob, carol = users['alice'], users['bob'], users['carol']
print('alice:', alice.has_perm('library.add_document'))
print('bob: ', bob.has_perm('library.add_document'), bob.has_perm('library.publish_document'))
print('carol:', carol.has_perm('library.add_document'), carol.has_perm('library.publish_document'))
print('all of bob:', sorted(bob.get_all_permissions()))
print('group permissions of carol:', sorted(carol.get_group_permissions()))
print('own permissions of carol:', sorted(carol.get_user_permissions()))
print('has_perms:', bob.has_perms(['library.add_document', 'library.view_document']))
document = Document.objects.get(title='Draft by Bob')
print('with an object:', bob.has_perm('library.change_document', document))
print()
perm = Permission.objects.get(content_type__app_label='library', codename='add_document')
alice.user_permissions.add(perm)
print('after adding the permission to the same object:', alice.has_perm('library.add_document'))
alice = User.objects.get(pk=alice.pk)
print('after fetching the user again:', alice.has_perm('library.add_document'))
print('permission without app label:', alice.has_perm('add_document'))
print()
root = User.objects.create_superuser('root', password='secret-pass-1')
print('superuser, any string:', root.has_perm('library.publish_document'), root.has_perm('nothing.at_all'))
print('superuser permissions:', len(root.get_user_permissions()), 'of', Permission.objects.count())
carol.is_active = False
carol.save()
carol = User.objects.get(pk=carol.pk)
print('inactive user:', carol.has_perm('library.add_document'))
Alice has no permissions, Bob gets his through the group, and Carol's own set is empty, because everything comes from her groups. A
trap shows in the middle: the permissions of a user object are cached on the object after the first check, so after adding a
permission to the same Python object the answer is still False; a new object, or the next request, sees it. The permission string without the app
label never matches. The superuser passes every check, even for a permission that does not exist, and an inactive user has none.
The last difference is the check with an object: the default backend does not implement object permissions and answers
False whenever the second argument is given, even to a user who has the model permission.
$ uv run python -m demos.checks alice: False bob: True False carol: True True all of bob: ['library.add_document', 'library.change_document', 'library.view_document'] group permissions of carol: ['library.add_document', 'library.change_document', 'library.publish_document', 'library.view_document'] own permissions of carol: [] has_perms: True with an object: False after adding the permission to the same object: False after fetching the user again: True permission without app label: False superuser, any string: True True superuser permissions: 29 of 29 inactive user: False
Protecting views
The decorator permission_required protects a function view, and PermissionRequiredMixin a class-based
one; the mixin has the attribute permission_required, a string or a list of strings. Both redirect an anonymous
user to the login page, and give an authenticated user without the permission a 403 response (the mixin) or another redirect to
the login page (the decorator, unless raise_exception=True). The view publish uses the decorator with the argument
raise_exception=True and the decorator require_POST, because an action that changes data must not
run on a GET request.
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import (
LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin)
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.views.decorators.http import require_POST
from django.views.generic import CreateView, ListView, UpdateView
from .models import Document
class DocumentList(LoginRequiredMixin, ListView):
model = Document
class DocumentCreate(PermissionRequiredMixin, CreateView):
permission_required = 'library.add_document'
model = Document
fields = ['title', 'body']
success_url = reverse_lazy('library:list')
def form_valid(self, form):
form.instance.owner = self.request.user
return super().form_valid(form)
class DocumentUpdate(PermissionRequiredMixin, UserPassesTestMixin, UpdateView):
permission_required = 'library.change_document'
model = Document
fields = ['title', 'body']
success_url = reverse_lazy('library:list')
def test_func(self):
return self.get_object().owner == self.request.user
@require_POST
@permission_required('library.publish_document', raise_exception=True)
def publish(request, pk):
document = get_object_or_404(Document, pk=pk)
document.published = True
document.save(update_fields=['published'])
return redirect('library:list')
The list needs only a login. The create view requires library.add_document and records the owner in
form_valid(). The update view combines two mixins. PermissionRequiredMixin is the model-wide check. For the object-level check we use
UserPassesTestMixin: its method test_func() returns whether the current user may use the view, and the owner
of the document does. When the two mixins are combined, both must come before the view class; each one checks in its
dispatch() and calls the next. A failed test gives 403 to an authenticated user. This is the simplest way to write an object-level
rule; when the rules grow, the third-party packages django-guardian (permissions stored per object) and django-rules (permissions as
predicates) are the usual tools.
from django.urls import path
from . import views
app_name = 'library'
urlpatterns = [
path('', views.DocumentList.as_view(), name='list'),
path('add/', views.DocumentCreate.as_view(), name='add'),
path('<int:pk>/edit/', views.DocumentUpdate.as_view(), name='edit'),
path('<int:pk>/publish/', views.publish, name='publish'),
]
The URL configuration of the app is included under the prefix documents/; the names are used in the templates.
from django.urls import include, path
urlpatterns = [
path('documents/', include('library.urls')),
]
The root URL configuration contains only the include. We do not add the URLs of the login views, because the examples log in
with the test client; the redirect of an anonymous user goes to the default LOGIN_URL, /accounts/login/.
Permissions in templates
When the context processor django.contrib.auth.context_processors.auth, which is enabled by default, runs, every
template gets the variable perms. The expression perms.library.add_document checks the permission
library.add_document of the current user and can be used in an {% if %} tag. We hide the links and buttons that
the user may not use. This only improves the interface; the views still have to check the permissions, because a user can type any URL.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Documents</title>
</head>
<body>
<h1>Documents</h1>
<ul>
{% for document in object_list %}
<li>
{{ document.title }} ({{ document.owner.username }}){% if document.published %}, published{% endif %}
{% if perms.library.change_document and document.owner == user %}
<a href="{% url 'library:edit' document.pk %}">Edit</a>
{% endif %}
{% if perms.library.publish_document and not document.published %}
<form method="post" action="{% url 'library:publish' document.pk %}">
{% csrf_token %}
<button type="submit">Publish</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% if perms.library.add_document %}
<p><a href="{% url 'library:add' %}">Add document</a></p>
{% endif %}
</body>
</html>
The edit link is displayed only for the owner who has the change permission, and the publish button for the users who may publish. The form of the create and update views is the generic one.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Document</title>
</head>
<body>
<h1>Document</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save</button>
</form>
</body>
</html>
The form template renders the model form with as_p and the CSRF token.
Trying the views
The script visits the URLs as an anonymous visitor and as each of the three users, with the test client. The users are logged in with
force_login(). The last part fetches the list page and checks which of the words appear in the HTML. We reduce the log level
of django.request, because Django logs every 403 response with its traceback, which would hide our output.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
import logging
from demos.seed import seed
from library.models import Document
logging.getLogger('django.request').setLevel(logging.ERROR)
users = seed()
bobs = Document.objects.get(title='Draft by Bob')
carols = Document.objects.get(title='Draft by Carol')
def visit(client, method, url, data=None):
response = getattr(client, method)(url, data or {})
return f'{method.upper():4} {url:22} {response.status_code} {response.get("Location", "")}'
anonymous = Client()
print('anonymous')
print(' ', visit(anonymous, 'get', '/documents/'))
print(' ', visit(anonymous, 'get', '/documents/add/'))
print(' ', visit(anonymous, 'post', f'/documents/{bobs.pk}/publish/'))
for name in ('alice', 'bob', 'carol'):
client = Client()
client.force_login(users[name])
print(name)
print(' ', visit(client, 'get', '/documents/'))
print(' ', visit(client, 'get', '/documents/add/'))
print(' ', visit(client, 'get', f'/documents/{bobs.pk}/edit/'))
print(' ', visit(client, 'post', f'/documents/{bobs.pk}/publish/'))
print()
print('Bob edits Carol\'s document:')
client = Client()
client.force_login(users['bob'])
print(' ', visit(client, 'get', f'/documents/{carols.pk}/edit/'))
print()
print('published:', list(Document.objects.filter(published=True).values_list('title', flat=True)))
print()
print('what each user sees on the list page')
for name in ('alice', 'bob', 'carol'):
client = Client()
client.force_login(users[name])
html = client.get('/documents/').content.decode()
print(f' {name:6}', {word: word in html for word in ('Add document', 'Edit', 'Publish')})
The anonymous visitor is redirected to the login page by the list and create views; the publish view, which
uses raise_exception=True, answers 403 even to an anonymous user. Alice may open the list but nothing else. Bob, as an editor, may add and edit a
document, but not publish, whereas Carol, who is also a publisher, can publish Bob's document but not edit it, since the
object-level test fails for the document of another user. The same happens to Bob with Carol's document. After
the runs, only the document that Carol published is marked as published. The list page offers each user exactly
the actions that they may use.
$ uv run python -m demos.flow
anonymous
GET /documents/ 302 /accounts/login/?next=/documents/
GET /documents/add/ 302 /accounts/login/?next=/documents/add/
POST /documents/5/publish/ 403
alice
GET /documents/ 200
GET /documents/add/ 403
GET /documents/5/edit/ 403
POST /documents/5/publish/ 403
bob
GET /documents/ 200
GET /documents/add/ 200
GET /documents/5/edit/ 200
POST /documents/5/publish/ 403
carol
GET /documents/ 200
GET /documents/add/ 200
GET /documents/5/edit/ 403
POST /documents/5/publish/ 302 /documents/
Bob edits Carol's document:
GET /documents/6/edit/ 403
published: ['Draft by Bob']
what each user sees on the list page
alice {'Add document': False, 'Edit': False, 'Publish': False}
bob {'Add document': True, 'Edit': True, 'Publish': False}
carol {'Add document': True, 'Edit': True, 'Publish': True}
Testing permissions
The tests create the groups and users in setUpTestData() and check the responses: the redirect of the anonymous user, the 403
of the user without the permission, the owner who is recorded, the object-level test, the custom permission, and the template.
The permissions are assigned once for the class, and the users are fetched freshly by the client on every request, so the cache
of permissions causes no surprises.
from django.contrib.auth.models import Group, Permission, User
from django.test import TestCase
from django.urls import reverse
from .models import Document
def perm(codename):
return Permission.objects.get(content_type__app_label='library', codename=codename)
class PermissionTests(TestCase):
@classmethod
def setUpTestData(cls):
editors = Group.objects.create(name='editors')
editors.permissions.add(perm('add_document'), perm('change_document'))
cls.alice = User.objects.create_user('alice', password='x')
cls.bob = User.objects.create_user('bob', password='x')
cls.bob.groups.add(editors)
cls.carol = User.objects.create_user('carol', password='x')
cls.carol.user_permissions.add(perm('publish_document'))
cls.document = Document.objects.create(title='Draft', owner=cls.bob)
def test_custom_permission_exists(self):
self.assertTrue(Permission.objects.filter(codename='publish_document').exists())
def test_group_grants_permissions(self):
self.assertTrue(self.bob.has_perm('library.add_document'))
self.assertFalse(self.alice.has_perm('library.add_document'))
def test_anonymous_user_is_redirected(self):
response = self.client.get(reverse('library:add'))
self.assertEqual(response.status_code, 302)
self.assertIn('/accounts/login/', response['Location'])
def test_user_without_permission_gets_403(self):
self.client.force_login(self.alice)
self.assertEqual(self.client.get(reverse('library:add')).status_code, 403)
def test_editor_can_add_and_becomes_the_owner(self):
self.client.force_login(self.bob)
response = self.client.post(reverse('library:add'), {'title': 'New', 'body': 'Text'})
self.assertRedirects(response, reverse('library:list'))
self.assertEqual(Document.objects.get(title='New').owner, self.bob)
def test_editor_edits_only_own_documents(self):
other = Document.objects.create(title='Other', owner=self.carol)
self.client.force_login(self.bob)
self.assertEqual(self.client.get(reverse('library:edit', args=[self.document.pk])).status_code, 200)
self.assertEqual(self.client.get(reverse('library:edit', args=[other.pk])).status_code, 403)
def test_publish_needs_the_custom_permission(self):
url = reverse('library:publish', args=[self.document.pk])
self.client.force_login(self.bob)
self.assertEqual(self.client.post(url).status_code, 403)
self.client.force_login(self.carol)
self.assertEqual(self.client.post(url).status_code, 302)
self.document.refresh_from_db()
self.assertTrue(self.document.published)
def test_template_hides_actions(self):
self.client.force_login(self.alice)
self.assertNotContains(self.client.get(reverse('library:list')), 'Add document')
self.client.force_login(self.bob)
self.assertContains(self.client.get(reverse('library:list')), 'Add document')
All eight tests pass. Test code can also grant a permission with user.user_permissions.add() and then fetch the user again.
$ uv run python manage.py test Found 8 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ........ ---------------------------------------------------------------------- Ran 8 tests in 0.718s OK Destroying test database for alias 'default'...
Source
Django documentation: Permissions and authorization
Django documentation: Authentication mixins
Django documentation: Custom permissions
In this article we have seen the permissions that Django creates for each model, added a custom one, collected permissions in
groups, protected views with permission_required and PermissionRequiredMixin, written an object-level check
with UserPassesTestMixin, and used the variable perms in a template. Related articles are
Django authentication, Django custom user model, and
Django class-based views.
Author
List all Django tutorials.