With Flask you assembled every piece of the Papyrus website by hand: you chose where to store the catalog (M6's JSON), you wrote the POST validation field by field, and if tomorrow you wanted a panel so Marta could edit books without touching code, you'd have to build it. Django is the other school's answer: a batteries-included framework that ships with persistence, an admin panel, validated forms, user management and more. In this lesson you'll start a Django project from scratch, understand its MTV architecture and its file structure (which it imposes rather than suggests), redefine M5's Book as a model with persistence thrown in for free, and finish by editing the catalog from the admin — without having written a single line of HTML. The same Papyrus, the other road.

Contents

  1. Philosophy: batteries included (when Django, when Flask)
  2. Installation and startproject: papyrus_web is born
  3. Project vs app: startapp catalog
  4. The file structure, file by file
  5. The MTV pattern (and its cousin MVC)
  6. First view: HttpResponse and urls.py
  7. The Book model: from dataclass to models.Model
  8. Migrations: makemigrations and migrate
  9. The Django admin: the star gift
  10. runserver and the complete map

Philosophy: batteries included (when Django, when Flask)

Flask gives you a minimal core and total freedom; Django gives you a complete appliance and its instruction manual. Neither is "better": they solve different contexts.

Criterion Flask Django
Persistence You choose it (our M6 JSON) ORM included: models that save themselves
Admin panel You build it Included and automatic
Validated forms By hand (the 10-03 POST) ModelForm included (10-05)
Users and sessions Third-party extensions Included
Project structure You decide it Django imposes it (and that's good in a team)
Ideal for APIs, small projects, learning piece by piece Complete applications with standard parts
Typical risk Reinventing wheels Carrying pieces you don't use

Practical rule: if your project is mostly an API or something small and unusual, Flask; if it's "a classic web application" — catalog, data entry, users, administration — Django saves you weeks. Papyrus, which is exactly that, is a textbook case (pun intended).

Installation and startproject: papyrus_web is born

In a fresh virtual environment (M1: one venv per project):

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install django
django-admin startproject papyrus_web
cd papyrus_web

pip install django also installed the django-admin command, the project generator. startproject papyrus_web creates a folder with the complete skeleton — the first cultural difference from Flask, where you started with an empty app.py: Django hands you the structure ready-made because its philosophy is that all Django projects should look alike.

Project vs app: startapp catalog

Django distinguishes two concepts worth pinning down early:

  • The project (papyrus_web) is the whole: global configuration, root URLs, the entire site.
  • An app is a functional component with its own models, views and templates: the catalog, someday the reservations, perhaps the members. A project contains several apps; a well-built app could be reused in another project.

We create the first one:

python manage.py startapp catalog

And we register it with the project, in papyrus_web/settings.py, so Django takes it into account:

INSTALLED_APPS = [
    "django.contrib.admin",          # the admin panel
    "django.contrib.auth",           # users and permissions
    # ... other included batteries ...
    "catalog",                       # ← our app
]

That INSTALLED_APPS is literally the list of connected batteries: notice how many things come switched on from the factory.

The file structure, file by file

After the two commands, the tree looks like this (the important files, annotated):

papyrus_web/                  ← project root folder
├── manage.py                 ← your Swiss army knife: runserver, migrate, startapp...
├── papyrus_web/              ← the project's "heart" (configuration)
│   ├── settings.py           ← global configuration
│   ├── urls.py               ← root URL table
│   └── wsgi.py / asgi.py     ← entry points for servers (production)
└── catalog/                  ← the app
    ├── models.py             ← the data: Book will be reborn here
    ├── views.py              ← the views (like Flask's)
    ├── admin.py              ← what gets managed from the panel
    ├── migrations/           ← history of model changes
    └── tests.py              ← Django believes in tests too (M9 smiles)
File Responsibility Flask equivalent
manage.py Run project commands the flask CLI
settings.py Configuration: apps, database, language, time zone the little you configured in app.py
urls.py Map URLs → views the @app.route calls (no decorator here!)
views.py Receive request, return response your view functions
models.py Define the data and its persistence your models.py + warehouse.py + JSON
admin.py Configure the admin panel doesn't exist: you'd build it

One courtesy tweak in settings.py before moving on: the default LANGUAGE_CODE = "en-us" already suits us (the admin will speak English out of the box), so we only set TIME_ZONE = "Europe/Madrid" — Papyrus keeps shop hours in Madrid time.

The MTV pattern (and its cousin MVC)

Django organizes code according to the MTV pattern: Model–Template–View. It's the separation of responsibilities we've been practicing since M3, with proper names:

flowchart LR
    P["Request<br/>GET /books/"] --> U["urls.py<br/>which view handles this?"]
    U --> V["View (views.py)<br/>logic: what data is needed"]
    V --> M["Model (models.py)<br/>the data: Book and its persistence"]
    M --> V
    V --> T["Template<br/>HTML with holes"]
    T --> R["HTML response"]
  • Model: the data and the rules for accessing it. In Papyrus-Flask this was the dataclass plus warehouse.py plus the JSON; in Django it will be a single class.
  • Template: the presentation — like 10-02's Jinja2 templates, with almost identical syntax.
  • View: the logic connecting the two — it receives the request, queries models, picks a template.

If you've heard of MVC (Model-View-Controller), the classic pattern: it's the same thing with the labels shuffled. Django's "View" plays the role of MVC's Controller, and the Template plays MVC's "View". Confusing to name, identical in spirit: data, logic and presentation, each in its place.

First view: HttpResponse and urls.py

The Django "hello world", to see the mechanism laid bare. In catalog/views.py:

from django.http import HttpResponse

def home(request):
    return HttpResponse("<h1>Papyrus, your neighborhood bookshop</h1>")

Two differences from Flask jump out: the function always receives request as its first parameter (in Flask it was a global object you imported), and there is no route decorator. In Django, URLs live apart from views, in papyrus_web/urls.py:

from django.contrib import admin
from django.urls import path
from catalog import views

urlpatterns = [
    path("admin/", admin.site.urls),   # the panel, already plugged in
    path("", views.home),              # "" = the site root
]

path(route, view) does what @app.route did: record it in the table. The difference is philosophical: Django prefers all URLs together in one index rather than scattered across decorators — in large projects that single index is a blessing. Start it up and see for yourself:

python manage.py runserver

http://127.0.0.1:8000 (Django uses port 8000; Flask, 5000) shows your greeting. runserver includes automatic reloading, like Flask's debug mode — and with the same warning: it's a development server, not a production one.

The Book model: from dataclass to models.Model

Here comes the lesson's star moment. Remember the M5 dataclass:

@dataclass
class Book:
    title: str
    price: float
    stock: int = 0

Its Django reincarnation, in catalog/models.py:

from django.db import models

class Book(models.Model):
    title = models.CharField(max_length=200)
    price = models.DecimalField(max_digits=6, decimal_places=2)
    stock = models.IntegerField(default=0)

    BOOK_VAT = 0.04
    MEMBER_DISCOUNT = 0.05

    def final_price(self, member=False):
        price = float(self.price) * (1 + self.BOOK_VAT)
        if member:
            price *= 1 - self.MEMBER_DISCOUNT
        return round(price, 2)

    def in_stock(self):
        return self.stock > 0

    def __str__(self):
        return self.title

Compare member by member:

Dataclass (M5) Django model What Django adds
title: str CharField(max_length=200) Maximum length, validation
price: float DecimalField(max_digits=6, decimal_places=2) Exact decimals for money (an improvement!)
stock: int = 0 IntegerField(default=0) Type and default value persisted
inherits from models.Model Saving, loading, querying: free

The same three fields as always, the same final_price() and in_stock() methods (Hamlet will still cost 9.83 for members — the M9 invariants travel with us). The novelty is what you don't see: by inheriting from models.Model (M5 inheritance in action), every Book knows how to save itself with book.save() and be queried with Book.objects... (we'll squeeze that in 10-05). In Flask you wrote load_catalog and save_catalog on top of JSON; this is the persistence Django gives you for free. Underneath it uses a SQLite database — a db.sqlite3 file in your project, nothing to install or configure. SQL database theory is beyond this course: today it's enough that the gift works. A professional bonus detail: DecimalField avoids float's odd roundings with money — Django even improves on our dataclass.

Migrations: makemigrations and migrate

Defining the model creates nothing yet: you have to tell the database what shape the data has. That dialogue is the migrations, in two steps:

python manage.py makemigrations catalog
# Migrations for 'catalog':
#   catalog/migrations/0001_initial.py
#     + Create model Book

python manage.py migrate
# Applying catalog.0001_initial... OK  (plus admin's, auth's...)

Conceptually:

  • makemigrations compares your models.py with the last known state and writes the recipe for the change (a Python file in migrations/): "create the Book table with these columns".
  • migrate applies the pending recipes to the actual database.

It's version control for the shape of your data: every future model change (adding an author field, for example) will generate its 0002_... migration, and any teammate reproduces your database by running migrate. You'll notice the first run also applies admin and auth migrations: those are the included batteries setting up their own tables.

The Django admin: the star gift

And now, the show of force. Three lines in catalog/admin.py:

from django.contrib import admin
from .models import Book

admin.site.register(Book)

A superuser to log in with (it will ask for a username and password in the terminal):

python manage.py createsuperuser

Start runserver, go to http://127.0.0.1:8000/admin/, sign in and behold: a complete admin panel — book listing, creation form with type validation, editing, deletion with confirmation, search — without having written a single template or a single view. Enter the canonical catalog: The Odyssey (12.50, 4), Hamlet (9.95, 6), Don Quixote (15.90, 8) and Faust (21.00, 10). Marta can now manage the inventory from the browser; in Flask, that panel would have cost you several afternoons. The __str__ we defined is what the admin shows in listings — without it you'd see "Book object (1)".

runserver and the complete map

The Django work cycle you'll repeat a thousand times, summarized:

python manage.py runserver          # develop and test
# ... you edit models.py ...
python manage.py makemigrations     # record the data change
python manage.py migrate            # apply it

Common Mistakes and Tips

  • Forgetting to register the app in INSTALLED_APPS: the classic symptom is makemigrations answering "No changes detected". Django only looks at registered apps.
  • Editing models.py and not migrating: on access you'll see OperationalError: no such table. Changed model = makemigrations + migrate, always, in that order.
  • Confusing django-admin with manage.py: django-admin only for startproject; after that, everything with python manage.py ..., which loads your project's configuration.
  • Using FloatField for money: the temptation comes from the dataclass. DecimalField exists precisely for amounts; get used to it from your very first model.
  • Deleting db.sqlite3 or migrations/ "to fix" an error: while learning you can afford it, but it's dynamite: you lose data and history. Better to understand the error — it's almost always an unapplied migration.
  • Looking for the if __name__ == "__main__": in Django you don't start the application; manage.py does. Let go of the reins: the framework calls your code, not the other way around (that is, literally, what defines a framework).

Exercises

Exercise 1: the author field

The canonical catalog always had authors (Homer, Shakespeare, Cervantes, Goethe) and the model doesn't store them. Add author = models.CharField(max_length=100, default="") to the model, generate and apply the migration, and fill in the authors from the admin. Adjust __str__ to show "Hamlet — Shakespeare".

Exercise 2: a members page

Add the view members (using HttpResponse and minimal HTML listing Luis, Marta and Pau with their cards LUIS-001, MARTA-002 and PAU-003) and wire it up to the URL /members/. Check it in the browser.

Exercise 3: member prices in the Django console

python manage.py shell opens an interpreter with your project loaded. Use it to loop over all the books and print title: final_price(member=True). Verify against the canonical values: 12.35, 9.83, 15.71 and 20.75. (Unavoidable preview: for "all the books" you'll need Book.objects.all() — the doorway to 10-05.)

Solutions

Exercise 1:

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.CharField(max_length=100, default="")
    # ... rest unchanged ...

    def __str__(self):
        return f"{self.title} — {self.author}"
python manage.py makemigrations catalog   # creates 0002_book_author.py
python manage.py migrate

The default="" is necessary: the database already contains books and Django needs to know what to put in the new column for the existing rows (if you don't provide it, makemigrations will ask you interactively).

Exercise 2:

# catalog/views.py
def members(request):
    return HttpResponse(
        "<h1>Papyrus members</h1>"
        "<ul><li>Luis — LUIS-001</li>"
        "<li>Marta — MARTA-002</li>"
        "<li>Pau — PAU-003</li></ul>"
    )
# papyrus_web/urls.py
urlpatterns = [
    path("admin/", admin.site.urls),
    path("", views.home),
    path("members/", views.members),
]

HTML in strings again — just as awkward as in Flask. The Django templates that fix it arrive in 10-05.

Exercise 3:

>>> from catalog.models import Book
>>> for book in Book.objects.all():
...     print(f"{book.title}: {book.final_price(member=True)}")
The Odyssey: 12.35
Hamlet: 9.83
Don Quixote: 15.71
Faust: 20.75

The four canonical figures, now coming out of a database instead of a JSON file. Book.objects.all() is your first QuerySet: the full query syntax awaits you in the next lesson.

Conclusion

Django has shown its cards: an imposed but clear structure (settings.py configures, urls.py routes, views.py responds, models.py defines the data), the MTV pattern separating data, logic and presentation, and above all the batteries — the Book model that, by inheriting from models.Model, gains the persistence you hand-coded over JSON in Flask, the migrations that version the shape of your data, and that admin which turned Marta into the inventory manager with three lines of code. The M5 dataclass and the Django model are the same book with a different cover: same data, same final_price() and in_stock(), same 12.35, 9.83, 15.71 and 20.75. But for now visitors only see an HttpResponse with embedded HTML: no browsable catalog, no searches, no public book entry. In the next lesson we'll complete the app: queries with QuerySets, templates with inheritance (they'll feel very familiar), forms with ModelForm that validate themselves, and the Papyrus website finished for the second time — same functionality, two roads, and you able to walk both.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved