Compass Navigation Streamline Icon: https://streamlinehq.com
applied cartography

Outgrowing Django admin

For a bit of dessert work this week, I'm working on a full-fledged attempt at replacing the majority of our stock Django admin usage with something purposeful.

I say majority and not totality because even though I am an unreasonable person, I am not that unreasonable. We have over a hundred Django models, and the idea of trying to rip and replace each and every one of them — or worse yet, to design some sort of DSL by which we do that — is too quixotic even for me. The vast majority of our admin usage coalesces around three main models, and they're the ones you might guess: the user/newsletter model, the email model, and the subscriber model. My hope is that building out a markedly superior interface for interacting with these three things and sacrificing the long tail still nets out for a much happier time for myself and the support staff.

Django admin is a source of both much convenience as much frustration: the abstractions make it powerful and cheap when you're first scaling, but the bill for those abstractions come due in difficult and intractable ways.

When I talk with other Django developers, they divide cleanly into one of two camps: either "what are you talking about, Django admin is perfect as-is" or "oh my God, I can't believe we didn't migrate off of it sooner." Ever the annoying centrist, I find myself agreeing with both camps:

  1. Django admin is an amazing asset;
  2. I am excited to be, if not rid of it, to be seeing much less of it in the future.

Let's set aside the visual design of the admin for a second, because arguing about visual design is not compelling prose. To me, the core issue with Django's admin interface, once you get more mature, is the fact that it's a very simple request-response lifecycle. Django pulls all the data, state, and information you might need and throws it up to a massive behemoth view for you to digest and interact with. It is by definition atomic: you are looking at a specific model, and the only way to bring in other models to the detail view is by futzing around with inlines and formsets.

The classic thing that almost any Django developer at scale has run into is the N+1 problem — but not even necessarily the one you're thinking about. Take a fairly standard admin class:

class EmailAdmin(admin.ModelAdmin):
    list_display = ["subject", "user", "status", "created_at"]

If you've got an email admin object and one of the fields on the EmailAdmin is a user — because you want to be able to change and see which user wrote a given email — Django by default will serialize every single possible user into a nice <select> tag for you. Even if this doesn't incur a literal N+1, you're asking the backend to generate a select with thousands (or more) options; the serialization overhead alone will timeout your request. And so the answer is, nowadays, to use autocomplete_fields or raw_id_fields, which pulls in a jQuery 1.9 package Yes, in 2026. No, I don't want to talk about it. to call an Ajax endpoint instead:

class EmailAdmin(admin.ModelAdmin):
    list_display = ["subject", "user", "status", "created_at"]
    autocomplete_fields = ["user", "newsletter"]
    list_select_related = ["user", "newsletter"]

This is the kind of patch that feels like a microcosm of the whole problem: technically correct, ergonomically awkward, and aesthetically offensive.


But the deeper issue is composability rather than performance. A well-defined data model has relationships that spread in every direction. A subscriber has Stripe subscriptions and Stripe charges. It has foreign keys onto email events and external events. When you're debugging an issue reported by a subscriber, you want to see all of these things in one place, interleaved and sorted chronologically.

Django admin's answer to this is inlines:

class StripeChargeInline(admin.TabularInline):
    model = StripeCharge
    extra = 0
    readonly_fields = ["amount", "created_at", "status"]

class EmailEventInline(admin.TabularInline):
    model = EmailEvent
    extra = 0
    readonly_fields = ["event_type", "created_at"]

class SubscriberAdmin(admin.ModelAdmin):
    inlines = [StripeChargeInline, EmailEventInline]
    list_display = ["email", "newsletter", "created_at"]
    list_select_related = ["newsletter"]

This works — until it doesn't. You start to run into pagination issues; you can't interleave those components with one another because they're rendered as separate, agnostic blocks; you can't easily filter or search within a single inline. You could create a helper method on the subscriber class to sort all related events and present them as a single list, but you once again run into the non-trivial problem of this being part of a fixed request-response lifecycle. And that kind of serialized lookup can get really expensive:

class SubscriberAdmin(admin.ModelAdmin):
    readonly_fields = ["recent_activity"]

    def recent_activity(self, obj):
        charges = obj.stripe_charges.order_by("-created_at")[:10]
        events = obj.email_events.order_by("-created_at")[:10]
        # Merging, sorting, rendering to HTML...
        # All of this happens synchronously, on every page load.
        combined = sorted(
            [*charges, *events],
            key=lambda x: x.created_at,
            reverse=True
        )
        return format_html_join(
            "\n",
            "<div>{} — {}</div>",
            [(item.created_at, item) for item in combined]
        )

You can do more bits of cleverness — parallelizing lookups, caching aggressively, using select_related and prefetch_related everywhere — but now you're fighting the framework rather than using it. The whole point of Django admin was to not build this stuff from scratch, and yet here you are, building bespoke rendering logic inside readonly_fields callbacks.


I still love Django admin. On the next Django project I start, I will not create a bespoke thing from day one but instead rely on my trusty, outdated friend until it's no longer bearable.

But what grinds my gears is the fact that, as far as I can tell, every serious Django company has this problem and has had to solve it from scratch. There's no blessed graduation path, whether in the framework itself or the broader ecosystem. I think that's one of the big drawbacks of Django relative to its peer frameworks. As strong and amazing as its community is, it's missing a part of the flywheel from more mature deployments upstreaming their findings and discoveries back into the zeitgeist.


About the Author

I'm Justin Duke — a software engineer, writer, and founder. I currently work as the CEO of Buttondown, the best way to start and grow your newsletter, and as a partner at Third South Capital.

Colophon

You can view a markdown version of this post here.