Rediscovering the Backend with Django

There's a strange appeal to building websites the way we did in 2010: a real SQL database, HTML rendered on the server, and very little JavaScript. For someone who came up on static site generators and single-page Vue apps with Go or Lambda backends, this feels like a shift back to basics. But it's not necessarily easy. The logic has to live somewhere, and for a multi-page site, putting it in the backend keeps things in one place — the same reason a one-page JS app might push logic to the client.

Django has made this transition more practical than Flask or Go's standard library did. Here's what's working, and what isn't.

Query Builders Win for Readability

The ability to define a QuerySet class with named methods that encapsulate different WHERE clauses has been a pleasant surprise. The view code becomes remarkably clean:

Events.objects.approved()
  .for_tab(tab)
  .with_festivals(tab_params.festival_slugs)
  .is_free(tab_params.free)
  .is_outdoors(tab_params.outdoors)

The method definitions themselves are less pretty — the filter syntax isn't my favorite — but the payoff comes in the view layer, where the intent is clear at a glance. It's changed my mind about query builders. I always thought "I know SQL, why would I need this?" But a structure like this makes the code genuinely nice to read, and it's worth exploring more minimal versions of the idea.

Template Filters: Small Tools, Big Impact

Django's built-in template filters are full of small quality-of-life wins. Some that get heavy use:

  • Converting plain-text URLs into links or line breaks into <br> tags: {{ event.description|urlize|linebreaksbr }}
  • Date formatting in views: {{ row.date|date:"M j" }}
  • json_script, which safely turns a Python dictionary into JSON inside a <script> tag

Individually they're trivial, but having them available changes the feel of template work entirely.

A standout is the querystring filter for building links to the same page with a modified query string. For a site that filters content via parameters like ?date=2026-06-01, linking to the previous date becomes straightforward:

<a href="{% querystring date=nav.prev_date%}">

Or removing a filter parameter entirely:

<a href="{% querystring outdoors=None %}">

Migrations Are Still a Killer Feature

The automatic migration system remains the best part of Django. Editing a model to add a field, then letting Django generate the migration, is a huge timesaver. This project has already gone through 19 migrations, and the ability to reshape the database as understanding of the problem evolves is one of the strongest arguments for the framework.

Where Class-Based Views Fall Short

Django's documentation often suggests class-based views with inheritance to share code. Four views with common logic seemed like a perfect case. It wasn't. Using inheritance to share view code failed to click, and switching to plain function-based views — an approach others advocate — was far more direct.

That said, inheritance is fine when it's Django's own interface. Writing class EventQuerySet(SearchableQuerySetMixin, models.QuerySet) works without overthinking it. The problem is custom inheritance hierarchies between my own classes, not the framework's contracts.

The Performance Unknown

Performance thinking is different with Django. With Go backends, everything is usually just fast enough; with Django, capacity is a real question. A brief load test using ab -n 1000 -c 1 against a ~$10/month VM showed only about 2-3 requests per second — slow enough to make LLM scraper traffic (about 10 requests per second) a genuine concern.

Tempting as it is to dive into profiling — py-spy makes that easy and fun — the bigger issue is the lack of a mental model. Some things still aren't clear:

  • Should a site with occasional traffic bursts be designed to scale up?
  • Is aggressive caching necessary, given how error-prone caches are to get right?
  • Jinja is faster for templating, according to the Django performance docs — is switching worth it?
  • Those docs also suggest {% block %} is faster than {% include %} — by how much, and why?

Template Caching Was the Easy Win

The most instructive lesson so far has been about configuration. A CPU profile showed significant time spent rendering templates, and the performance docs pointed to a potential culprit:

Enabling the cached template loader often improves performance drastically, as it avoids compiling each template every time it needs to be rendered.

It turned out the cached loader — supposedly on by default — had been disabled accidentally while fiddling with settings. The settings.py file remains confusing territory, and this was a real example of a framework-level footgun.

Enabling template caching changed the picture. The same VM can now handle roughly 12 requests per second without maxing out CPU. The before-and-after wasn't carefully benchmarked, but the difference is obvious.

The broader lesson is that "check your database queries" isn't the only starting point. Slow template rendering is a different class of problem. With SQLite, any database issue shows up in a CPU profile anyway, so profiling from the start has been more useful than chasing indexes. There's plenty more to learn, but the direction is becoming clearer with each fix.