Fast to build
You can manage models without writing separate pages for every form and table.
Django Admin is a built-in interface for managing your app data. It is not a ready-made public dashboard. It is a CRUD tool that Django builds from your model definitions and admin settings.
Fast to build
You can manage models without writing separate pages for every form and table.
Safe by default
It uses Django login, permissions, and groups instead of a custom auth system.
Easy to extend
You can add search, filters, actions, inlines, and custom links when you need them.
Not for public UI
It is meant for staff users and internal workflows, not for a polished customer-facing app.
Use Django Admin for internal tools, content management, support work, operations panels, and back-office tasks. It is a strong choice when staff need to edit data quickly and the workflow is simple.
Do not use it as the main public interface when you need a custom design, custom user flow, or a public-facing product experience.
You want to manage model data fast, you trust Django’s default workflow, and the users are internal staff.
You need a public site, complex approval flows, or a design that must look very different from Django’s default admin.
The admin is powerful, but it is still a generic tool. Heavy customization can become harder than building a custom dashboard.
At a simple level, admin does this:
The admin site is included with Django, but it depends on other built-in apps too. A normal project usually needs all of these:
INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles",]auth handles users, groups, and permissions. contenttypes helps Django understand models in a generic way. sessions keeps the login session alive. messages lets admin show success and error messages.
from django.contrib import adminfrom django.urls import path
admin.site.site_header = "Project Admin Control"admin.site.index_title = "Admin Panel"admin.site.site_title = "Project Admin"
urlpatterns = [ path("admin/", admin.site.urls),]site_header, index_title, and site_title are small branding changes. They are useful when your admin is part of a real product and you want the page to feel like your own app.
python manage.py createsuperuserpython manage.py changepassword admincreatesuperuser makes the first staff user who can log in to admin. changepassword is useful when you need to reset a local or test account.
INSTALLED_APPS.There are many ways to register a model in admin, here are the most common patterns.
from django.contrib import adminfrom .models import Product
admin.site.register(Product)This is the fastest way to expose a model in admin. Django will create the list page, the add page, the edit page, and the delete flow for you.
This is most used way to register a model. It gives you a class where you can customize the admin behavior.
from django.contrib import adminfrom .models import Product
@admin.register(Product)class ProductAdmin(admin.ModelAdmin): passModelAdmin is where you control how the model looks and behaves in admin. In practice, most useful admin work happens here.
Use plain registration when you only need basic CRUD. Use ModelAdmin when you need search, filters, custom columns, inlines, or permissions-based behavior.
Do not register every model blindly. If a model is private, temporary, or never edited by staff, it may not need an admin page at all.
The list page is the main table you see after opening a model in admin. This is where most admin usage happens.
list_displayList display is responsible for which columns show up on the list page. By default, admin shows only the __str__() value of each record. To show more fields, you need to set list_display on the admin class.
from django.contrib import admin
@admin.register(Product)class ProductAdmin(admin.ModelAdmin): list_display = ("id", "name", "price", "is_active")list_display decides which columns appear on the table.
Use it for the fields that matter most to staff. Keep it short and useful. Too many columns make the page harder to read.
list_displayYou cannot write something like category__name directly inside list_display. Instead, add a method on the admin class.
from django.contrib import admin
@admin.register(Product)class ProductAdmin(admin.ModelAdmin): list_display = ("name", "category_name", "stock_status")
@admin.display(description="Category") def category_name(self, obj): return obj.category.name
@admin.display(ordering="inventory", description="Stock") def stock_status(self, obj): return "OK" if obj.inventory > 10 else "Low"@admin.display decorator@admin.display lets you set a column title and sorting behavior for a method column.
In this decorator, description sets the column header, and ordering tells admin which field to use when sorting by this column. It also helps in generating computed columns at runtime without needing to define a separate method for display purposes.
@admin.register(Product)class ProductAdmin(admin.ModelAdmin): list_display = ("name", "category_name")
@admin.display(description="Category") def category_name(self, obj): return obj.category.name
@admin.display(ordering="inventory", description="Stock") def stock_status(self, obj): return "OK" if obj.inventory > 10 else "Low"list_display_linksIt controls which columns are clickable to open the edit page for that record.
class ProductAdmin(admin.ModelAdmin): list_display = ("name", "price", "is_active") list_display_links = ("name",)This decides which column opens the edit page when you click it. Usually one column should be clickable so users can move from the list to the edit form easily.
list_editableIt allows you to edit a field directly from the list page without opening the edit form.
class ProductAdmin(admin.ModelAdmin): list_display = ("name", "price", "is_active") list_display_links = ("name",) list_editable = ("is_active",)This allows quick inline editing from the table.
Use it when staff often change a small set of fields directly from the list page. Do not use it for fields that need a lot of context or careful review.
list_per_pageIt controls how many rows show up on each page of the list view.
class ProductAdmin(admin.ModelAdmin): list_per_page = 50This controls pagination. Lower values make the page lighter. Higher values reduce clicking but can make the page slower.
orderingit sets the default sort order for the list page.
class ProductAdmin(admin.ModelAdmin): ordering = ("-created_at", "first_name")This sets the default sort order. The first field controls the main order, and the next field is used when the first value is the same.
Use them when the list page is the main working area for staff. Keep the table focused on the few values they need to make quick decisions.
Do not overload the page with every field in the model. That makes the table slow, wide, and hard to scan.
Sometimes the table needs a value that does not exist as a model field. A computed column is a method that returns a value for each row.
from django.contrib import admin
@admin.register(Product)class ProductAdmin(admin.ModelAdmin): list_display = ("name", "price", "discounted_price")
@admin.display(description="Discounted Price") def discounted_price(self, obj): return obj.price * 0.9This is useful for display-only values such as formatted labels, status text, or a related object’s name.
Computed columns run in Python for each row. If the method does database work, the page can become slow very quickly.
If a value can be calculated in the database, use annotate() in get_queryset() instead of doing the work in Python. By using annotate(), you can add a computed value to the queryset that is calculated in the database, which is much faster than doing it in Python.
from django.contrib import adminfrom django.db.models import Count
@admin.register(Collection)class CollectionAdmin(admin.ModelAdmin): list_display = ("name", "product_count")
@admin.display(ordering="product_count", description="Products") def product_count(self, obj): return obj.product_count
# this method adds a product_count annotation to the queryset, which is calculated # in the database and can be used for sorting and display without extra queries per row def get_queryset(self, request): qs = super().get_queryset(request) return qs.annotate(product_count=Count("products"))If your table shows data from a foreign key field, admin may query the related object one row at a time. That creates the N+1 problem.
list_select_relatedclass ProductAdmin(admin.ModelAdmin): list_display = ("name", "category_name") list_select_related = ("category",)
@admin.display(description="Category") def category_name(self, obj): return obj.category.namelist_select_related tells admin to join the related table in the same query. It is best for foreign keys and one-to-one fields.
Use it when your list page shows foreign key data, such as a product’s category name or a blog post’s author name.
Do not expect it to help with many-to-many data. For many-to-many relationships, use prefetch_related() in get_queryset().
It helps performance, but it is not a magic fix. You still need to think about the full query count of the page.
get_queryset()get_queryset() lets you change which records admin shows. i.e. By the help of this get_queryset() method, you can filter the list page to show only active products, or only the records that belong to the logged-in user.
from django.contrib import admin
@admin.register(Product)class ProductAdmin(admin.ModelAdmin): def get_queryset(self, request): qs = super().get_queryset(request) return qs.filter(is_active=True)
# or@admin.register(Collection)class CollectionAdmin(admin.ModelAdmin): list_display = ("name", "product_count")
@admin.display(ordering="product_count", description="Products") def product_count(self, obj): return obj.product_count
# this method adds a product_count annotation to the queryset, which is calculated # in the database and can be used for sorting and display without extra queries per row def get_queryset(self, request): qs = super().get_queryset(request) return qs.annotate(product_count=Count("products"))You can use this to hide soft-deleted rows, show only a user’s own records, add annotations, or pre-load related data.
The request parameter lets you look at the logged-in user and their permissions.
This is useful when different staff members should see different data.
Use it when the admin list should be filtered by business rules or when you need to prepare better query data for the table.
Do not use it to hide data in a confusing way. If staff need to understand why records are missing, the rule should be clear.
Admin pages often need links to related records or filtered views.
from django.contrib import adminfrom django.urls import reversefrom django.utils.html import format_html, urlencode
@admin.register(Product)class ProductAdmin(admin.ModelAdmin): list_display = ("name", "category_link")
@admin.display(description="Category") def category_link(self, obj): # urls.py name + : + appname + _ + modelname + _ + page url = reverse("admin:store_category_change") + urlencode({'products__id': str(obj.id)}) return format_html('<a href="{}">{}</a>', url, obj.category.name)reverse() builds the admin URL safely. format_html() escapes the values before sending HTML to the page, which helps prevent XSS problems.
Use links when staff need to move quickly between related records.
Do not add links that send people to confusing places. If the target page is not obvious, the list page becomes harder to use.
search_fields adds the search box at the top of the admin list page.
class ProductAdmin(admin.ModelAdmin): search_fields = ("name", "description", "sku__icontains", "id__exact")By default, admin searches with a text match. You can also guide the search behavior with prefixes:
startswith means starts withexact means exact matchicontains means case-insensitive containsUse search when staff already know a name, email, code, or short text they want to find quickly.
Do not expect search to work well on every huge text field without planning. Long text search can be slow if you do not have a good database strategy.
list_filterlist_filter adds a sidebar with quick filters.
class ProductAdmin(admin.ModelAdmin): list_filter = ("is_active", "category")This is great when staff often want to narrow the list by status, category, date, or a similar field.
from django.contrib.admin import SimpleListFilter
class InventoryFilter(SimpleListFilter): title = 'inventory status' parameter_name = 'inventory_status'
def lookups(self, request, model_admin): return ( ('<10', 'Low'), )
def queryset(self, request, queryset): if self.value() == '<10': return queryset.filter(inventory__lte=10)
class ProductAdmin(admin.ModelAdmin): list_filter = (InventoryFilter,)Use filters when staff repeatedly ask the same question, such as “show active items only” or “show low stock products.”
Do not add too many filters. A crowded filter sidebar can slow people down instead of helping them.

Actions let staff perform one operation on many selected rows at once.
from django.contrib import admin
@admin.register(Product)class ProductAdmin(admin.ModelAdmin): actions = ["clear_inventory"]
@admin.action(description="Clear inventory") def clear_inventory(self, request, queryset): updated_count = queryset.update(inventory=0) self.message_user(request, f"{updated_count} products had their inventory cleared.")They are good for bulk status changes, cleanup jobs, and simple one-step updates.
save() is not called for each object. But sometimes save() is automatically called by the ORM when you use update(), so be careful with side effects.Use actions for fast staff workflows where the same change applies to many rows.
Do not use actions for anything that needs careful object-by-object review, complex validation, or a custom approval step.
Admin forms control which fields are visible and how staff edit them.
fieldsclass ProductAdmin(admin.ModelAdmin): fields = ("name", "price", "category")This sets the form layout and shows only the chosen fields.
excludeclass ProductAdmin(admin.ModelAdmin): exclude = ("created_at", "updated_at")This hides fields from the form.
readonly_fieldsclass ProductAdmin(admin.ModelAdmin): readonly_fields = ("created_at", "updated_at")This shows the field but prevents editing. It is useful for timestamps, IDs, or computed values.
prepopulated_fieldsclass ProductAdmin(admin.ModelAdmin): prepopulated_fields = {"slug": ("name",)}This fills one field from another field, usually for slugs.
Use it when the value should usually come from another field and staff should only adjust it when needed.
autocomplete_fields
class ProductAdmin(admin.ModelAdmin): autocomplete_fields = ("category",)This gives a search box instead of a long dropdown. It is the better choice when the related table has many rows.
The related model admin must expose search_fields. i.e. it need search_fields on that related model admin to work. For example, if you want to use autocomplete_fields for the category field in ProductAdmin, then the CategoryAdmin must have search_fields defined.
class CategoryAdmin(admin.ModelAdmin): search_fields = ("name",)filter_horizontal and filter_verticalfilter_horizontal and filter_vertical can make many-to-many selection easier by showing a dual-list widget.
Use autocomplete_fields instead. It is easier to use when the number of choices is large.

class ProductAdmin(admin.ModelAdmin): filter_horizontal = ("category",)
class ProductAdmin(admin.ModelAdmin): filter_vertical = ("category",)Use form customization when you want a cleaner staff workflow and you already know exactly which fields belong on the page.
Do not hide important business fields just to make the form look smaller. Staff still need the right information to make good decisions.
Admin uses normal Django validation. That means field validators, form validation, and model validation still matter.
from django.db import modelsfrom django.core.validators import MinValueValidator, MaxValueValidator
class Product(models.Model): price = models.DecimalField( max_digits=10, decimal_places=2, validators=[MinValueValidator(0), MaxValueValidator(10000)], )These validators check a field before the model is saved.
clean()from django.core.exceptions import ValidationError
class Product(models.Model): price = models.DecimalField(max_digits=10, decimal_places=2)
def clean(self): if self.price < 0: raise ValidationError({"price": "Price cannot be negative."})clean() is for rules that need more than one field or a custom business check.
clean() runs during model form validation.Use validation when the data must be correct no matter whether it comes from admin, a form, or another entry point.
Do not put validation logic only in the admin class if the same model can be written from other parts of the app.
Inlines let you edit related child records on the same page as the parent record. For example, you can edit order items right on the order page instead of opening a separate page for each one.
TabularInlinefrom django.contrib import admin
class OrderItemInline(admin.TabularInline): model = OrderItem extra = 0
@admin.register(Order)class OrderAdmin(admin.ModelAdmin): inlines = [OrderItemInline]TabularInline shows child rows in a compact table.
StackedInlineclass OrderItemInline(admin.StackedInline): model = OrderItem extra = 0
@admin.register(Order)class OrderAdmin(admin.ModelAdmin): inlines = [OrderItemInline]StackedInline shows each child item in a larger block. It is easier to read when each child has many fields.
Use inlines when the parent and child objects are usually edited together, such as orders and order items, articles and images, or invoices and line items.
Do not use inlines for very large child lists. The page can become long and slow.
Generic relations let one model point to different kinds of models (not a field or not a foreign key i.e. it is used for unrelated models). This is useful for comments, tags, attachments, and activity logs.
from django.db import modelsfrom django.contrib.contenttypes.fields import GenericForeignKeyfrom django.contrib.contenttypes.models import ContentType
class TaggedItem(models.Model): tag = models.CharField(max_length=50) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() content_object = GenericForeignKey("content_type", "object_id")from django.db import modelsclass Product(models.Model): name = models.CharField(max_length=100) # other fields...from django.contrib import adminfrom django.contrib.contenttypes.admin import GenericTabularInlinefrom tags.models import TaggedItem
class TaggedItemInline(GenericTabularInline): model = TaggedItem autocomplete_fields = ("tag",)
@admin.register(Product)class ProductAdmin(admin.ModelAdmin): list_display = ("name", "price", "is_active") search_fields = ("name",) # other admin settings... inlines = [TaggedItemInline]from django.contrib import adminfrom .models import TaggedItem
@admin.register(TaggedItem)class TaggedItemAdmin(admin.ModelAdmin): list_display = ("tag", "content_object") search_fields = ("tag",)It gives you one tagging or comment system that can be attached to many model types.
Pluggable apps means moving the admin customization for a model into a separate app. The model stays the same. Only the admin behavior changes.
Use this pattern when one app should not depend too much on another app’s admin code. A common example is a store app that needs tagging behavior from a tags app. Putting that admin code in a separate app keeps the original app smaller and easier to reuse.
In short:
This is useful when you want to reduce coupling between apps without changing the database model or the stored data.
from django.contrib import adminfrom .models import Product
@admin.register(Product)class ProductAdmin(admin.ModelAdmin): list_display = ("name", "price", "is_active") search_fields = ("name",) # other admin settings...from django.contrib import adminfrom .models import TaggedItem
@admin.register(TaggedItem)class TaggedItemAdmin(admin.ModelAdmin): list_display = ("tag", "content_object") search_fields = ("tag",)Create a new Django app for the admin customization.
uv run manage.py startapp store_tags_plugableRegister the new app in INSTALLED_APPS after the original app.
INSTALLED_APPS = [ # other apps... "store_tags_plugable", # new app for admin customization]Create a new admin class for Product, then unregister the old admin and register the new one.
from django.contrib import adminfrom django.contrib.contenttypes.admin import GenericTabularInlinefrom tags.models import TaggedItemfrom store.admin import ProductAdminfrom store.models import Product
class CustomTaggedItemInline(GenericTabularInline): autocomplete_fields = ('tag',) model = TaggedItem
class CustomProductAdmin(ProductAdmin): inlines = [CustomTaggedItemInline]
admin.site.unregister(Product)admin.site.register(Product, CustomProductAdmin)Use this pattern when the model already has an admin class, but you want to move the admin rules into another app for cleaner separation.
Do not use this pattern if a normal ModelAdmin registration already solves the problem. In that case, keep the admin code in the same app and avoid the extra layer.
Django Admin is protected by Django’s login and permission system, but you still need to write safe code inside your admin class.
format_html() for HTML output.