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
| Strategy | Reading a full path | Moving a subtree |
|---|---|---|
| Adjacency list | Slow — walk one parent link at a time, one query per level, to reconstruct a deep path | Fast — change one row's parent_id |
| Materialized path | Fast — one lookup by the stored path string | Slow — every descendant's stored path has to be rewritten |
| Nested sets | Fast for whole-subtree queries | Very slow — nearly every other row's left/right values shift on any change |
| Closure table | Fast, flexible — a separate ancestor-descendant join table answers most queries directly | Moderate — 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 Model
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.
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.
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
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 solutionExplain 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.
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 solutionChapter 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_pathas a fast-lookup cache ForeignKey('self', ...)— Django's syntax for a self-referential relationship;related_name='children'enables walking downwardon_delete=PROTECT— chosen deliberately over Django's own defaultCASCADE, to prevent silently deleting an entire subtreeslugvs.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