---
name: django-admin
description: "Django Admin pitfalls - save_formset, get_search_results, get_formset, admin queryset optimization, db_index"
metadata:
  author: mte90
  version: 1.0.0
  tags:
    - python
    - django
    - admin
    - orm
    - optimization
---

# Django Admin

Specialized guide for Django Admin customization - save hooks, formsets, queryset optimization, and security considerations.

## Overview

Django Admin provides a powerful interface for managing data, but customizing it requires understanding several critical patterns:

- **Save Hooks** - `save_model`, `save_formset` order and tuple shapes
- **Search Validation** - `get_search_results` field validation
- **Formset Restrictions** - `get_formset` per-role child filtering
- **Queryset Optimization** - `select_related`, `prefetch_related`, `annotate`
- **Indexing Strategy** - `db_index` on filter/search fields
- **Audit Logging** - Tracking changes via formset hooks

> **Security Note**: Admin customization touches authorization boundaries. For comprehensive security patterns (CSRF, authentication, permissions), see the main [Django skill](../django/SKILL.md).

### Common Admin Pitfalls

| Pitfall | Impact | Fix |
|---------|--------|-----|
| `.count()` in `list_display` | N+1 queries | Use `annotate()` |
| Wrong `save_formset` shape | Lost change tracking | Expect `[(obj, dict)]` |
| `save_model` after `save_formset` | FK violations | Order is fixed: parent first |
| Unvalidated search fields | SQL injection risk | Validate against `search_fields` |
| No `get_formset` filtering | Data leakage | Filter queryset by role |

---

## Save Hooks

### save_formset Tuple Shape

Django admin formsets return a specific tuple shape in `save_formset`:

```python
from django.contrib import admin
from myapp.models import Parent, Child

@admin.register(Parent)
class ParentAdmin(admin.ModelAdmin):
    inlines = [ChildInline]
    
    def save_formset(self, request, parent, formset, save_model_kwargs):
        """Handle inline child objects.
        
        formset.changed_objects = [(instance, {field: old_value}), ...]
        formset.new_objects = [(instance,), ...]  # Bare tuples
        formset.deleted_objects = [instance, ...]  # Bare objects
        """
        changed_objects = []
        
        for obj, changed_data in formset.changed_objects:
            # changed_data is a dict: {'name': 'old_value', 'status': 'old_status'}
            changed_objects.append((obj, changed_data))
            
            # Log the change
            self.log_change(request, obj, changed_data)
        
        # Process new objects (bare tuples, no changed_data)
        for new_obj in formset.new_objects:
            # new_obj is just (instance,) - no change tracking
            pass
        
        # Process deleted objects (bare instances)
        for deleted_obj in formset.deleted_objects:
            # deleted_obj is just the instance
            pass
```

**Critical distinction**:
- `changed_objects` → `[(obj, {field: old_value})]` - dict with old values
- `new_objects` → `[(obj,)]` - bare tuple, no tracking
- `deleted_objects` → `[obj, ...]` - bare instances

### Testing save_formset Shape

Always test with real tuple shape - mocks often get this wrong:

```python
from django.test import TestCase
from django.contrib import admin
from myapp.models import Parent, Child

class ParentAdminTest(TestCase):
    def setUp(self):
        self.parent = Parent.objects.create(name='Parent')
        self.child1 = Child.objects.create(parent=self.parent, name='Child 1')
    
    def test_save_formset_tuple_shape(self):
        """save_formset receives [(obj, changed_data)] not bare lists."""
        admin_instance = admin.site._registry[Parent]
        
        # Track what save_formset receives
        received_changes = []
        
        def mock_save_formset(parent, formset, **kwargs):
            # Shape: [(instance, {field: old_value}), ...]
            received_changes.extend(formset.changed_objects)
        
        # Patch and submit
        original_save = admin_instance.save_formset
        admin_instance.save_formset = mock_save_formset
        
        try:
            # Submit form with changed child
            data = {
                'child_set-0-id': self.child1.id,
                'child_set-0-name': 'Updated Child 1',
                'child_set-TOTAL_FORMS': 1,
                'child_set-INITIAL_FORMS': 1,
            }
            self.client.post(
                f'/admin/myapp/parent/{self.parent.id}/change/',
                data
            )
        finally:
            admin_instance.save_formset = original_save
        
        # Verify shape
        assert len(received_changes) == 1
        obj, changed_data = received_changes[0]
        assert obj.id == self.child1.id
        assert 'name' in changed_data
        assert changed_data['name'] == 'Updated Child 1'
```

### Inline Save Order

`save_model` is called BEFORE `save_formset` - sync state in formset:

```python
@admin.register(Parent)
class ParentAdmin(admin.ModelAdmin):
    inlines = [ChildInline]
    
    def save_model(self, request, obj, form, change):
        """Parent is saved first."""
        super().save_model(request, obj, form, change)
        # obj.pk is now available
    
    def save_formset(self, request, parent, formset, save_model_kwargs):
        """Children are saved after parent.
        
        Use parent.pk here - it's guaranteed to exist.
        """
        instances = formset.save(commit=False)
        
        for obj in instances:
            # parent.pk is available because save_model ran first
            if not obj.parent_id:
                obj.parent = parent
            # Sync any parent-dependent state
            obj.sync_with_parent(parent)
            obj.save()
        
        formset.save_m2m()  # Save M2M relationships
```

### save_model Immutability Guard

Prevent accidental overwrites in `save_model`:

```python
@admin.register(Document)
class DocumentAdmin(admin.ModelAdmin):
    def save_model(self, request, obj, form, change):
        """Guard against accidental overwrites."""
        if not change:
            # New object - safe to save
            super().save_model(request, obj, form, change)
            return
        
        # Existing object - check for stale data
        old_obj = self.model.objects.get(pk=obj.pk)
        
        # Check critical fields weren't changed by another user
        if old_obj.version != obj.version:
            messages.warning(
                request,
                f"Document was modified by another user. "
                f"Old version: {old_obj.version}, Your version: {obj.version}"
            )
            # Option 1: Reject save
            # raise ValidationError("Stale data detected")
            
            # Option 2: Force refresh and re-apply changes
            # obj.refresh_from_db()
            # obj.apply_changes(form.cleaned_data)
        
        super().save_model(request, obj, form, change)
```

---

## Search & Formset Security

### get_search_results Field Validation

Validate that `get_search_results` searches only valid fields:

```python
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    search_fields = ['name', 'sku', 'category__name']
    
    def get_search_results(self, request, queryset, search_term):
        """Custom search with field validation.
        
        ⚠️ search_fields must be validated - arbitrary field access!
        """
        # Validate search fields exist
        allowed_fields = {'name', 'sku', 'category__name'}
        
        # Django's search_fields are already validated at admin init
        # But custom search logic needs validation
        
        queryset, use_distinct = super().get_search_results(
            request, queryset, search_term
        )
        
        return queryset, use_distinct
```

### get_formset Per-Role Child Restriction

Restrict inline children based on user role:

```python
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
    inlines = [TaskInline]
    
    def get_formset(self, request, obj=None, change=False, **kwargs):
        """Restrict visible tasks based on user role."""
        formset = super().get_formset(request, obj, change, **kwargs)
        
        original_queryset = formset.queryset
        
        def restricted_queryset(obj):
            qs = original_queryset(obj)
            
            if request.user.is_superuser:
                return qs
            
            if request.user.has_perm('myapp.view_all_tasks'):
                return qs
            
            # Regular users see only their tasks
            return qs.filter(assigned_to=request.user)
        
        formset.queryset = restricted_queryset
        return formset
```

---

## Indexing Strategy

**db_index on filter/search fields** - Critical for admin and API performance:

```python
class Product(models.Model):
    # Add db_index to frequently filtered fields
    sku = models.CharField(max_length=50, db_index=True, unique=True)
    category = models.ForeignKey(Category, db_index=True)
    status = models.CharField(max_length=20, db_index=True)  # Filter by status
    created_at = models.DateTimeField(db_index=True)  # Date range queries
    
    # Composite index for common query patterns
    class Meta:
        indexes = [
            models.Index(fields=['category', 'status']),
            models.Index(fields=['-created_at']),
        ]
```

### When to Add db_index

| Field Type | Add Index When |
|------------|----------------|
| `ForeignKey` | Always (Django adds it, but verify) |
| `CharField` | Used in `list_filter`, `search_fields`, or API filters |
| `DateTimeField` | Used in date range queries or ordering |
| `IntegerField` | Used in status/type filters |

### Composite Index Guidelines

```python
class Meta:
    indexes = [
        # Leftmost prefix rule: (a, b) covers queries on (a) but not (b)
        models.Index(fields=['category', 'status']),  # Good for category+status filter
        
        # Single-field descending for common ordering
        models.Index(fields=['-created_at']),  # Good for "newest first"
        
        # Partial index (PostgreSQL) for filtered queries
        # models.Index(
        #     fields=['status'],
        #     condition=Q(status='active')
        # )
    ]
```

### Admin get_queryset Optimization

Prevent N+1 in list display:

```python
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ['name', 'category', 'status', 'created_at', 'order_count']
    list_filter = ['status', 'category', 'created_at']  # These fields need db_index
    
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        # select_related for ForeignKey (1 query instead of N)
        # prefetch_related for ManyToMany/Reverse FK (2 queries instead of N+1)
        return qs.select_related('category').prefetch_related('tags')
    
    def order_count(self, obj):
        # NEVER use .count() here - it triggers a query per row!
        # Use prefetch_related with Prefetch object instead
        return obj.orders.count() if hasattr(obj, '_prefetched_objects_cache') else 'N/A'
    order_count.short_description = 'Orders'
```

---

## Per-Row .count() Antipattern

Avoid `.count()` in admin `list_display` methods - it triggers N+1 queries:

```python
# BAD - N+1 queries
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
    list_display = ['name', 'post_count']
    
    def post_count(self, obj):
        return obj.posts.count()  # Query per row!
```

**Solution: Annotate once**

```python
from django.db.models import Count

class UserAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        return super().get_queryset(request).annotate(
            post_count=Count('posts')
        )
    
    list_display = ['name', 'post_count']
```

### Prefetch for Complex Aggregations

For more complex aggregations, use `Prefetch` with filtered querysets:

```python
from django.db.models import Prefetch, Count

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ['customer', 'total_items', 'completed_items']
    
    def get_queryset(self, request):
        return super().get_queryset(request).prefetch_related(
            Prefetch(
                'orderitems',
                queryset=OrderItem.objects.annotate(
                    item_count=Count('id')
                )
            ),
            Prefetch(
                'orderitems',
                queryset=OrderItem.objects.filter(status='completed').annotate(
                    completed_count=Count('id')
                ),
                to_attr='completed_items'
            )
        )
    
    def total_items(self, obj):
        # Uses cached prefetch, no extra query
        return sum(item.quantity for item in obj.orderitems.all())
    
    def completed_items(self, obj):
        # Uses to_attr cache
        return sum(item.quantity for item in obj.completed_items)
```

### Benchmarking Admin Performance

Measure query count to catch N+1 issues:

```python
from django.test import TestCase
from django.contrib.admin import site

class AdminQueryCountTest(TestCase):
    def test_list_view_query_count(self):
        """Admin list view should not exceed N+2 queries."""
        # Create test data
        UserFactory.create_batch(10)
        
        with self.assertNumQueries(12):  # Adjust based on your admin config
            self.client.get('/admin/myapp/user/')
```

---

---

## Audit Logging with Formsets

Track changes via `save_formset` for audit trails:

```python
from django.contrib import admin
from django.utils import timezone
from myapp.models import AuditLog

@admin.register(Parent)
class ParentAdmin(admin.ModelAdmin):
    inlines = [ChildInline]
    
    def save_formset(self, request, parent, formset, save_model_kwargs):
        """Save formset and log all changes."""
        # Capture changes before saving
        changes_to_log = []
        
        for obj, changed_data in formset.changed_objects:
            changes_to_log.append({
                'object': obj,
                'field_changes': changed_data,
                'action': 'change'
            })
        
        for new_obj in formset.new_objects:
            changes_to_log.append({
                'object': new_obj[0],  # Tuple unpacking
                'field_changes': None,
                'action': 'create'
            })
        
        for deleted_obj in formset.deleted_objects:
            changes_to_log.append({
                'object': deleted_obj,
                'field_changes': None,
                'action': 'delete'
            })
        
        # Call parent save first
        super().save_formset(request, parent, formset, save_model_kwargs)
        
        # Log after successful save
        for change in changes_to_log:
            AuditLog.objects.create(
                user=request.user,
                content_type=ContentType.objects.get_for_model(change['object']),
                object_id=change['object'].id,
                action=change['action'],
                field_changes=change['field_changes'],
                timestamp=timezone.now()
            )
```

---

## References

- Main Django security patterns: [frameworks/django/SKILL.md](../django/SKILL.md)
- Django Admin documentation: https://docs.djangoproject.com/en/stable/ref/contrib/admin/
- Django ORM optimization: https://docs.djangoproject.com/en/stable/topics/db/optimization/