Designing a Flexible URL & Content Model

Website Rebuild with Django

Chapter 2 · Designing a Flexible URL & Content Model

Everything else in this course rests on getting one design decision right: how to store a content tree that can genuinely go five levels deep — programming/general-purpose-languages/java/fundamentals/chapter-1 — without the schema itself assuming a fixed depth. There are four standard ways to represent a tree in a relational database, each with real, different tradeoffs. This chapter picks one, deliberately, rather than reaching for the first option that comes to mind.

Four Ways to Store a Tree

StrategyReading a full pathMoving a subtree
Adjacency listSlow — walk one parent link at a time, one query per level, to reconstruct a deep pathFast — change one row's parent_id
Materialized pathFast — one lookup by the stored path stringSlow — every descendant's stored path has to be rewritten
Nested setsFast for whole-subtree queriesVery slow — nearly every other row's left/right values shift on any change
Closure tableFast, flexible — a separate ancestor-descendant join table answers most queries directlyModerate — real extra complexity and an extra table to keep in sync

This site's own real traffic pattern is heavily read-weighted — pages get rendered constantly, and reorganized rarely, by one admin, deliberately. That single fact rules out nested sets outright (punishing exactly the operation that matters least here) and makes a closure table's own extra complexity hard to justify for a project this size. A pure adjacency list alone would make every single page render slow, which is precisely backwards for a read-heavy site.

The design this chapter settles on: both, together
A self-referential adjacency list stays the real, authoritative structure — the actual parent/child relationships the admin interface (Chapter 10) edits directly. A materialized path, stored as an ordinary indexed column, exists purely as a fast-lookup cache derived from that structure — the thing Chapter 3's URL dispatch actually queries against. Reads stay fast (one indexed lookup by path); the real tree structure stays explicit and query-able for anything the adjacency list is naturally better at, like rendering a page's own breadcrumb or a sibling list. This is the same hybrid shape Website Rebuild with Next.js reached with Prisma, arrived at independently here for the identical reason: this specific site's own read-heavy, write-rare traffic profile, not a framework preference.

The Model

# content/models.py from django.db import models class Page(models.Model): title = models.CharField(max_length=200) slug = models.SlugField(max_length=100) parent = models.ForeignKey( 'self', null=True, blank=True, on_delete=models.PROTECT, related_name='children', ) full_path = models.CharField(max_length=500, unique=True, db_index=True) body = models.TextField(blank=True) class Meta: unique_together = ('parent', 'slug') def __str__(self): return self.full_path def save(self, *args, **kwargs): if self.parent: self.full_path = f"{self.parent.full_path}/{self.slug}" else: self.full_path = self.slug super().save(*args, **kwargs)

parent = models.ForeignKey('self', ...) is Django's own syntax for a self-referential relationship — the string 'self' stands in for "this same model," since the class isn't fully defined yet at the point the field itself is declared. related_name='children' is what makes some_page.children.all() work later, walking downward instead of only upward through parent.

on_delete=PROTECT is a deliberate choice, not the default
Django's own default for a ForeignKey is on_delete=CASCADE — deleting a parent silently deletes every one of its children too, recursively, all the way down. For a content tree that's a genuinely dangerous default: deleting one page by mistake could wipe out an entire subject's worth of course chapters without any warning. PROTECT instead refuses the deletion outright while children still exist, forcing a deliberate decision (delete the children first, or reparent them) rather than an accidental cascade.

Two Fields, Two Different Jobs

slug is just this one page's own single URL segment — "chapter-1", not the whole path. full_path is the complete route, built by walking up through parent once, at save time, and stored so nothing downstream ever has to walk that chain again. SlugField also comes with real, built-in validation — letters, numbers, hyphens, and underscores only — rejecting anything that couldn't cleanly become a URL segment before it ever reaches the database.

A cost being deliberately deferred, not avoided
Recomputing full_path in save() only ever updates the one page being saved — moving a page that already has children doesn't yet cascade that update down through its descendants' own stored full_path values. That's a real gap, left open on purpose: Chapter 10's own admin move/reparent feature is exactly where this gets paid off properly, once there's an actual UI action ("move this page") to hang the cascade logic on. Noting it now, rather than only discovering it later, is the point.

Where This Course Is Headed

Django's own URL dispatch mechanism built directly on this model (a genuinely different approach from Next.js's file-based catch-all routes), the MVT view/template model, dark-theme styling, the database migration that makes this model real, rendering content (including the kanji edge case), dynamic content and forms, admin authentication, the admin CRUD interface that finally pays off this chapter's own deferred move-cascade cost, deployment, and a capstone building a genuinely five-level-deep chain live.

Hands-On Exercises

Exercise 1

Explain why a pure adjacency list alone, with no materialized path, would make this site's own page rendering slow — and why nested sets were ruled out for the opposite reason. Use this chapter's own read-heavy/write-rare framing.

📄 View solution
Exercise 2

Explain what would happen if Page.parent used Django's own default on_delete=CASCADE instead of PROTECT, and why that default is described as "genuinely dangerous" for this specific model.

📄 View solution
Exercise 3

Explain the deferred cost this chapter names around moving a page that already has children. Why is it acceptable to leave that gap open in this chapter rather than solving it here?

📄 View solution

Chapter 2 Quick Reference

  • Four tree strategies — adjacency list, materialized path, nested sets, closure table — each with a different read/write tradeoff
  • This project's choice: both — a self-referential adjacency list as the real structure, a stored materialized full_path as a fast-lookup cache
  • ForeignKey('self', ...) — Django's syntax for a self-referential relationship; related_name='children' enables walking downward
  • on_delete=PROTECT — chosen deliberately over Django's own default CASCADE, to prevent silently deleting an entire subtree
  • slug vs. full_path — one URL segment vs. the complete, precomputed route
  • Deferred cost: moving a page with children doesn't yet cascade-update descendants' own full_path — Chapter 10's own job
  • Next chapter: URL Dispatch: Django's Own Mechanism for Arbitrary-Depth Routing