ZetCode

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.

config/settings.py
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.

library/models.py
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.

demos/catalog.py
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.

demos/seed.py
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.

demos/checks.py
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.

library/views.py
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.

library/urls.py
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.

config/urls.py
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.

library/templates/library/document_list.html
<!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.

library/templates/library/document_form.html
<!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.

demos/flow.py
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.

library/tests.py
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

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.